gargoyle-receipts 0.9.0.dev0__tar.gz → 0.11.0.dev0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {gargoyle_receipts-0.9.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.11.0.dev0}/PKG-INFO +23 -3
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/README.md +22 -2
- gargoyle_receipts-0.11.0.dev0/gargoyle/adapters.py +279 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/cli.py +6 -2
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/evidence.py +21 -8
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/mcp_server.py +39 -22
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/remote.py +4 -1
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/revert.py +32 -23
- gargoyle_receipts-0.11.0.dev0/gargoyle/specs/graph-reference.json +56 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/state.py +3 -1
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/static/approve.html +4 -1
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +23 -3
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +3 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/pyproject.toml +2 -2
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_evidence.py +19 -0
- gargoyle_receipts-0.11.0.dev0/tests/test_graph.py +165 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_mcp_self_issue.py +3 -1
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_remote.py +1 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_revert.py +8 -7
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/approval.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/approvers.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/consumer.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/envelope.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/execution.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/identity.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/issuer_service.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/policy.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/published_record_source.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/replay_cache.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/rules.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/static/approve.js +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/verifier.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_approvers.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_cli.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_console_js.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_entra_identity.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_historical.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_issuer_service.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_rules.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_state.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_threshold.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.9.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_wire_spec.py +0 -0
{gargoyle_receipts-0.9.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.11.0.dev0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gargoyle-receipts
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0.dev0
|
|
4
4
|
Summary: Signed, single-use, argument-bound receipts and human-approval holds for agent tool calls.
|
|
5
5
|
Author: Justin LaPorte
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -59,7 +59,7 @@ your MCP server and lets through only what both agreed to.
|
|
|
59
59
|
|
|
60
60
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
61
61
|
|
|
62
|
-
Status: pre-release (`0.
|
|
62
|
+
Status: pre-release (`0.11.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
|
|
63
63
|
still change before the first release; it is specified in [SPEC.md](SPEC.md).
|
|
64
64
|
|
|
65
65
|
## What a receipt gives you
|
|
@@ -235,7 +235,10 @@ undone the same way: the middleware keeps what a held write replaced and
|
|
|
235
235
|
offers `gargoyle_revert`, which composes the restoring write and sends it
|
|
236
236
|
through the gate, held, shown as a diff, bound to the current state, and
|
|
237
237
|
refused if anything changed since. The audit marks a revert from the
|
|
238
|
-
state hashes alone.
|
|
238
|
+
state hashes alone. Every entry also names the MCP client that asked, from
|
|
239
|
+
its initialize handshake, so a fleet with Claude on some desks and ChatGPT
|
|
240
|
+
on others still has one trail that says who, through what, did what. The
|
|
241
|
+
receipt itself stays model-agnostic.
|
|
239
242
|
|
|
240
243
|
The MCP middleware also keeps an evidence log: one hash-chained line per
|
|
241
244
|
decision, refusals and declined holds included, so the trail answers "what
|
|
@@ -289,6 +292,22 @@ and `gargoyle audit` verifies them later offline.
|
|
|
289
292
|
and through the alternative with OPA in Docker for sites that run a policy
|
|
290
293
|
engine.
|
|
291
294
|
|
|
295
|
+
## Microsoft 365 and other targets: adapters
|
|
296
|
+
|
|
297
|
+
What a held call changes is the adapter's business (`gargoyle.adapters`):
|
|
298
|
+
it describes the target's state before and after, tells the approver what
|
|
299
|
+
the call means in plain words, and composes the call that undoes it. Files
|
|
300
|
+
on the executor's host are the default. `GARGOYLE_MCP_STATE_PROBE=graph`
|
|
301
|
+
selects the Graph adapter for a Microsoft Graph MCP server: inbox rules,
|
|
302
|
+
calendar events, contacts and Teams membership are bound to their state,
|
|
303
|
+
explained on the card ("creates inbox rule 'Fwd invoices' (to an outside
|
|
304
|
+
address: x@evil.example)"), undone by the inverse Graph call, and a send
|
|
305
|
+
is marked as something that cannot be undone. The adapter reads state
|
|
306
|
+
through the upstream server itself, so the gate needs no Graph credential
|
|
307
|
+
of its own. Which tool is which is a JSON spec; the reference naming is in
|
|
308
|
+
`gargoyle/specs/graph-reference.json`, and a different server's names are
|
|
309
|
+
mapped there, not in code.
|
|
310
|
+
|
|
292
311
|
## Remote mode
|
|
293
312
|
|
|
294
313
|
Claude's hosted connectors (claude.ai in the browser, Claude Desktop's
|
|
@@ -338,6 +357,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
338
357
|
| 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 |
|
|
339
358
|
| `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 |
|
|
340
359
|
| `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 |
|
|
360
|
+
| `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
|
|
341
361
|
| `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
|
|
342
362
|
| `GARGOYLE_MCP_SNAPSHOT_DIR` | where the bytes a held, state-bound write replaces are kept (default `snapshots/` beside the audit log; `none` disables undo); the middleware then offers a `gargoyle_revert` tool that restores an approved change through the gate, held and state-bound like any write |
|
|
343
363
|
| `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 |
|
|
@@ -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.11.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
|
|
25
25
|
still change before the first release; it is specified in [SPEC.md](SPEC.md).
|
|
26
26
|
|
|
27
27
|
## What a receipt gives you
|
|
@@ -197,7 +197,10 @@ undone the same way: the middleware keeps what a held write replaced and
|
|
|
197
197
|
offers `gargoyle_revert`, which composes the restoring write and sends it
|
|
198
198
|
through the gate, held, shown as a diff, bound to the current state, and
|
|
199
199
|
refused if anything changed since. The audit marks a revert from the
|
|
200
|
-
state hashes alone.
|
|
200
|
+
state hashes alone. Every entry also names the MCP client that asked, from
|
|
201
|
+
its initialize handshake, so a fleet with Claude on some desks and ChatGPT
|
|
202
|
+
on others still has one trail that says who, through what, did what. The
|
|
203
|
+
receipt itself stays model-agnostic.
|
|
201
204
|
|
|
202
205
|
The MCP middleware also keeps an evidence log: one hash-chained line per
|
|
203
206
|
decision, refusals and declined holds included, so the trail answers "what
|
|
@@ -251,6 +254,22 @@ and `gargoyle audit` verifies them later offline.
|
|
|
251
254
|
and through the alternative with OPA in Docker for sites that run a policy
|
|
252
255
|
engine.
|
|
253
256
|
|
|
257
|
+
## Microsoft 365 and other targets: adapters
|
|
258
|
+
|
|
259
|
+
What a held call changes is the adapter's business (`gargoyle.adapters`):
|
|
260
|
+
it describes the target's state before and after, tells the approver what
|
|
261
|
+
the call means in plain words, and composes the call that undoes it. Files
|
|
262
|
+
on the executor's host are the default. `GARGOYLE_MCP_STATE_PROBE=graph`
|
|
263
|
+
selects the Graph adapter for a Microsoft Graph MCP server: inbox rules,
|
|
264
|
+
calendar events, contacts and Teams membership are bound to their state,
|
|
265
|
+
explained on the card ("creates inbox rule 'Fwd invoices' (to an outside
|
|
266
|
+
address: x@evil.example)"), undone by the inverse Graph call, and a send
|
|
267
|
+
is marked as something that cannot be undone. The adapter reads state
|
|
268
|
+
through the upstream server itself, so the gate needs no Graph credential
|
|
269
|
+
of its own. Which tool is which is a JSON spec; the reference naming is in
|
|
270
|
+
`gargoyle/specs/graph-reference.json`, and a different server's names are
|
|
271
|
+
mapped there, not in code.
|
|
272
|
+
|
|
254
273
|
## Remote mode
|
|
255
274
|
|
|
256
275
|
Claude's hosted connectors (claude.ai in the browser, Claude Desktop's
|
|
@@ -300,6 +319,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
300
319
|
| issuer `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys) and which Entra app role (default `Approver`) or groups may enroll |
|
|
301
320
|
| `GARGOYLE_HOLD_WEBHOOK_URL`, `GARGOYLE_APPROVE_URL` | issuer: POST a notice of every new hold (Slack incoming-webhook shape, `text` plus the hold) with a link to the console; notice only, the grant still needs the approver's key |
|
|
302
321
|
| `GARGOYLE_MCP_STATE_PROBE` | `filesystem` (default) binds a held file write to the file's current state: the approver sees the change as a diff, the call refuses if the file moved before the run (`REF_VERIFY_STATE_MISMATCH`), and the execution receipt records pre and post state; `none` disables |
|
|
322
|
+
| `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
|
|
303
323
|
| `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
|
|
304
324
|
| `GARGOYLE_MCP_SNAPSHOT_DIR` | where the bytes a held, state-bound write replaces are kept (default `snapshots/` beside the audit log; `none` disables undo); the middleware then offers a `gargoyle_revert` tool that restores an approved change through the gate, held and state-bound like any write |
|
|
305
325
|
| `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 |
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Adapters: what a held call changes, in a form the gate can bind, show,
|
|
2
|
+
and undo. The gate itself knows only tool names and arguments. An adapter
|
|
3
|
+
knows the target: it describes the target's state before and after a call
|
|
4
|
+
(gargoyle.state), tells the approver what the call means, and composes the
|
|
5
|
+
call that restores the earlier state (gargoyle.revert).
|
|
6
|
+
|
|
7
|
+
probe(tool, args) -> (description, bytes) | None
|
|
8
|
+
description: {"ref", "sha256", "size", "text"?, "summary"?, "irreversible"?}
|
|
9
|
+
bytes: the state's canonical bytes (the hash's preimage), kept as the
|
|
10
|
+
snapshot; None when the target does not exist
|
|
11
|
+
restore(meta, pre_bytes, post_bytes) -> {"params": {"name", "arguments"}} | {"reason": REF_REVERT_*}
|
|
12
|
+
the inverse call, given the sidecar and the two snapshots
|
|
13
|
+
|
|
14
|
+
FilesystemAdapter: a tool with a path changes that file. GraphAdapter: a
|
|
15
|
+
Microsoft Graph MCP server's tools change mail rules, events, contacts,
|
|
16
|
+
Teams objects; which tool is which is a JSON spec (gargoyle/specs/
|
|
17
|
+
graph-reference.json is the reference naming; map your server's names).
|
|
18
|
+
The Graph adapter reads state through the upstream server itself, with
|
|
19
|
+
the same credential the write will use, so the gate needs no Graph access
|
|
20
|
+
of its own.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
27
|
+
|
|
28
|
+
from gargoyle.envelope import canonical_json
|
|
29
|
+
from gargoyle.state import describe, filesystem_probe, read_target
|
|
30
|
+
|
|
31
|
+
REF_REVERT_UNKNOWN = "REF_REVERT_UNKNOWN"
|
|
32
|
+
REF_REVERT_NEEDS_DELETE = "REF_REVERT_NEEDS_DELETE"
|
|
33
|
+
REF_REVERT_NOT_TEXT = "REF_REVERT_NOT_TEXT"
|
|
34
|
+
REF_REVERT_IRREVERSIBLE = "REF_REVERT_IRREVERSIBLE"
|
|
35
|
+
REF_REVERT_UNSUPPORTED = "REF_REVERT_UNSUPPORTED"
|
|
36
|
+
|
|
37
|
+
Probe = Optional[Tuple[Dict[str, Any], Optional[bytes]]]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FilesystemAdapter:
|
|
41
|
+
"""Files on the executor's host (the stdio middleware's own case)."""
|
|
42
|
+
|
|
43
|
+
name = "filesystem"
|
|
44
|
+
|
|
45
|
+
def probe(self, tool: str, args: Any) -> Probe:
|
|
46
|
+
desc = filesystem_probe(tool, args)
|
|
47
|
+
if desc is None:
|
|
48
|
+
return None
|
|
49
|
+
return desc, read_target(desc)
|
|
50
|
+
|
|
51
|
+
def restore(self, meta: Dict[str, Any], pre: Optional[bytes], post: Optional[bytes]) -> Dict[str, Any]:
|
|
52
|
+
if pre is None:
|
|
53
|
+
return {"reason": REF_REVERT_NEEDS_DELETE} # undoing a create is a delete, which the gate refuses
|
|
54
|
+
try:
|
|
55
|
+
text = pre.decode("utf-8")
|
|
56
|
+
except UnicodeDecodeError:
|
|
57
|
+
return {"reason": REF_REVERT_NOT_TEXT}
|
|
58
|
+
return {"params": {"name": "write_file", "arguments": {"path": meta["ref"], "content": text}}}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _tool_json(reply: Any) -> Any:
|
|
62
|
+
"""The JSON a Graph MCP tool returned, from its text content."""
|
|
63
|
+
if not isinstance(reply, dict) or "result" not in reply:
|
|
64
|
+
return None
|
|
65
|
+
result = reply["result"]
|
|
66
|
+
if isinstance(result, dict) and result.get("isError"):
|
|
67
|
+
return None
|
|
68
|
+
if isinstance(result, dict) and isinstance(result.get("structuredContent"), (dict, list)):
|
|
69
|
+
return result["structuredContent"]
|
|
70
|
+
for item in (result.get("content") or []) if isinstance(result, dict) else []:
|
|
71
|
+
if isinstance(item, dict) and item.get("type") == "text":
|
|
72
|
+
try:
|
|
73
|
+
return json.loads(item["text"])
|
|
74
|
+
except (ValueError, TypeError):
|
|
75
|
+
return None
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class GraphAdapter:
|
|
80
|
+
"""A Microsoft Graph MCP server, by spec.
|
|
81
|
+
|
|
82
|
+
spec = {
|
|
83
|
+
"internal_domains": ["contoso.com"],
|
|
84
|
+
"kinds": {
|
|
85
|
+
"<kind>": {"label": "inbox rule", "name_field": "displayName", "id_field": "id",
|
|
86
|
+
"list_tool": "...", "list_args": {}, "items_path": "value",
|
|
87
|
+
"get_tool": "...", "id_arg": "id",
|
|
88
|
+
"create_tool": "...", "create_arg": "rule",
|
|
89
|
+
"update_tool": "...", "update_arg": "rule",
|
|
90
|
+
"delete_tool": "...",
|
|
91
|
+
"writable_fields": ["displayName", "sequence", "conditions", "actions", "isEnabled"]}
|
|
92
|
+
},
|
|
93
|
+
"tools": {
|
|
94
|
+
"<tool name>": {"kind": "<kind>", "op": "create" | "update" | "delete"},
|
|
95
|
+
"<tool name>": {"op": "irreversible", "label": "send mail", "summary": "sends mail to {toRecipients}: {subject}"}
|
|
96
|
+
}
|
|
97
|
+
}"""
|
|
98
|
+
|
|
99
|
+
name = "graph"
|
|
100
|
+
|
|
101
|
+
def __init__(self, upstream: Any, spec: Dict[str, Any]) -> None:
|
|
102
|
+
self.upstream = upstream
|
|
103
|
+
self.spec = spec
|
|
104
|
+
self._next_id = 0
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def from_file(cls, upstream: Any, path: str) -> "GraphAdapter":
|
|
108
|
+
return cls(upstream, json.loads(Path(path).read_text(encoding="utf-8")))
|
|
109
|
+
|
|
110
|
+
# ---- talking to the upstream ----
|
|
111
|
+
def _call(self, tool: str, args: Dict[str, Any]) -> Any:
|
|
112
|
+
self._next_id += 1
|
|
113
|
+
reply = self.upstream.send({"jsonrpc": "2.0", "id": "gargoyle-probe-%d" % self._next_id, "method": "tools/call",
|
|
114
|
+
"params": {"name": tool, "arguments": args}})
|
|
115
|
+
return _tool_json(reply)
|
|
116
|
+
|
|
117
|
+
def _items(self, kind: Dict[str, Any]) -> Optional[List[Dict[str, Any]]]:
|
|
118
|
+
doc = self._call(kind["list_tool"], dict(kind.get("list_args") or {}))
|
|
119
|
+
if doc is None:
|
|
120
|
+
return None
|
|
121
|
+
items = doc
|
|
122
|
+
for key in (kind.get("items_path") or "").split("."):
|
|
123
|
+
if key:
|
|
124
|
+
items = items.get(key) if isinstance(items, dict) else None
|
|
125
|
+
if not isinstance(items, list):
|
|
126
|
+
return None
|
|
127
|
+
idf = kind.get("id_field", "id")
|
|
128
|
+
return sorted((i for i in items if isinstance(i, dict) and idf in i), key=lambda i: str(i[idf]))
|
|
129
|
+
|
|
130
|
+
def _item(self, kind: Dict[str, Any], item_id: str) -> Optional[Dict[str, Any]]:
|
|
131
|
+
doc = self._call(kind["get_tool"], {kind.get("id_arg", "id"): item_id})
|
|
132
|
+
return doc if isinstance(doc, dict) else None
|
|
133
|
+
|
|
134
|
+
# ---- descriptions ----
|
|
135
|
+
@staticmethod
|
|
136
|
+
def _desc(ref: str, obj: Any, summary: str, irreversible: bool = False) -> Tuple[Dict[str, Any], Optional[bytes]]:
|
|
137
|
+
data = canonical_json(obj).encode("utf-8") if obj is not None else None
|
|
138
|
+
desc = describe(ref, data, with_text=False)
|
|
139
|
+
if data is not None:
|
|
140
|
+
desc["text"] = json.dumps(obj, indent=2, sort_keys=True)
|
|
141
|
+
desc["summary"] = summary
|
|
142
|
+
if irreversible:
|
|
143
|
+
desc["irreversible"] = True
|
|
144
|
+
return desc, data
|
|
145
|
+
|
|
146
|
+
def _external(self, value: Any) -> List[str]:
|
|
147
|
+
"""Addresses in `value` outside the internal domains."""
|
|
148
|
+
internal = [d.lower() for d in self.spec.get("internal_domains", [])]
|
|
149
|
+
found: List[str] = []
|
|
150
|
+
|
|
151
|
+
def walk(v):
|
|
152
|
+
if isinstance(v, str) and "@" in v:
|
|
153
|
+
dom = v.rsplit("@", 1)[1].strip(">").lower()
|
|
154
|
+
if internal and dom not in internal:
|
|
155
|
+
found.append(v)
|
|
156
|
+
elif isinstance(v, dict):
|
|
157
|
+
for x in v.values():
|
|
158
|
+
walk(x)
|
|
159
|
+
elif isinstance(v, list):
|
|
160
|
+
for x in v:
|
|
161
|
+
walk(x)
|
|
162
|
+
walk(value)
|
|
163
|
+
return found
|
|
164
|
+
|
|
165
|
+
def probe(self, tool: str, args: Any) -> Probe:
|
|
166
|
+
rule = (self.spec.get("tools") or {}).get(tool)
|
|
167
|
+
if rule is None or not isinstance(args, dict):
|
|
168
|
+
return None
|
|
169
|
+
op = rule.get("op")
|
|
170
|
+
if op == "irreversible":
|
|
171
|
+
text = rule.get("summary", "{tool}").replace("{tool}", tool)
|
|
172
|
+
for k, v in args.items():
|
|
173
|
+
text = text.replace("{" + k + "}", v if isinstance(v, str) else json.dumps(v))
|
|
174
|
+
ext = self._external(args)
|
|
175
|
+
if ext:
|
|
176
|
+
text += " (to an outside address: %s)" % ", ".join(ext[:3])
|
|
177
|
+
return self._desc("%s:%s" % (tool, rule.get("label", tool)), None, text + ". Cannot be undone.", irreversible=True)
|
|
178
|
+
kind = (self.spec.get("kinds") or {}).get(rule.get("kind"))
|
|
179
|
+
if kind is None:
|
|
180
|
+
return None
|
|
181
|
+
label = kind.get("label", rule.get("kind"))
|
|
182
|
+
name_field = kind.get("name_field")
|
|
183
|
+
if op == "create":
|
|
184
|
+
items = self._items(kind)
|
|
185
|
+
if items is None:
|
|
186
|
+
return None
|
|
187
|
+
payload = args.get(kind.get("create_arg", "item"), args)
|
|
188
|
+
name = payload.get(name_field) if isinstance(payload, dict) and name_field else None
|
|
189
|
+
summary = "creates %s%s" % (label, (" '%s'" % name) if name else "")
|
|
190
|
+
ext = self._external(payload)
|
|
191
|
+
if ext:
|
|
192
|
+
summary += " (to an outside address: %s)" % ", ".join(ext[:3])
|
|
193
|
+
return self._desc("%s:all" % rule["kind"], items, summary + "; %d %s(s) exist now" % (len(items), label))
|
|
194
|
+
item_id = args.get(kind.get("id_arg", "id"))
|
|
195
|
+
if not isinstance(item_id, str) or not item_id:
|
|
196
|
+
return None
|
|
197
|
+
item = self._item(kind, item_id)
|
|
198
|
+
name = item.get(name_field) if isinstance(item, dict) and name_field else None
|
|
199
|
+
verb = "updates" if op == "update" else "deletes" if op == "delete" else op
|
|
200
|
+
summary = "%s %s%s" % (verb, label, (" '%s'" % name) if name else " " + item_id)
|
|
201
|
+
if op == "update":
|
|
202
|
+
ext = self._external(args.get(kind.get("update_arg", "item"), args))
|
|
203
|
+
if ext:
|
|
204
|
+
summary += " (to an outside address: %s)" % ", ".join(ext[:3])
|
|
205
|
+
if item is None:
|
|
206
|
+
summary += " (does not exist now)"
|
|
207
|
+
return self._desc("%s:%s" % (rule["kind"], item_id), item, summary)
|
|
208
|
+
|
|
209
|
+
# ---- undo ----
|
|
210
|
+
def restore(self, meta: Dict[str, Any], pre: Optional[bytes], post: Optional[bytes]) -> Dict[str, Any]:
|
|
211
|
+
rule = (self.spec.get("tools") or {}).get(meta.get("tool"))
|
|
212
|
+
if rule is None:
|
|
213
|
+
return {"reason": REF_REVERT_UNSUPPORTED}
|
|
214
|
+
if rule.get("op") == "irreversible":
|
|
215
|
+
return {"reason": REF_REVERT_IRREVERSIBLE}
|
|
216
|
+
kind = (self.spec.get("kinds") or {}).get(rule.get("kind")) or {}
|
|
217
|
+
idf = kind.get("id_field", "id")
|
|
218
|
+
load = lambda b: json.loads(b.decode("utf-8")) if b is not None else None
|
|
219
|
+
before, after = load(pre), load(post)
|
|
220
|
+
if rule["op"] == "create":
|
|
221
|
+
# The created object is the one in the post set that was not in the pre set.
|
|
222
|
+
if not isinstance(before, list) or not isinstance(after, list) or not kind.get("delete_tool"):
|
|
223
|
+
return {"reason": REF_REVERT_UNSUPPORTED}
|
|
224
|
+
old = {str(i.get(idf)) for i in before}
|
|
225
|
+
new = [i for i in after if str(i.get(idf)) not in old]
|
|
226
|
+
if len(new) != 1:
|
|
227
|
+
return {"reason": REF_REVERT_UNKNOWN}
|
|
228
|
+
return {"params": {"name": kind["delete_tool"], "arguments": {kind.get("id_arg", "id"): str(new[0][idf])}}}
|
|
229
|
+
if rule["op"] == "update":
|
|
230
|
+
if not isinstance(before, dict) or not kind.get("update_tool"):
|
|
231
|
+
return {"reason": REF_REVERT_UNSUPPORTED}
|
|
232
|
+
fields = kind.get("writable_fields")
|
|
233
|
+
body = {k: v for k, v in before.items() if (fields is None or k in fields) and k != idf}
|
|
234
|
+
return {"params": {"name": kind["update_tool"], "arguments": {kind.get("id_arg", "id"): str(before[idf]),
|
|
235
|
+
kind.get("update_arg", "item"): body}}}
|
|
236
|
+
if rule["op"] == "delete":
|
|
237
|
+
if not isinstance(before, dict) or not kind.get("create_tool"):
|
|
238
|
+
return {"reason": REF_REVERT_UNSUPPORTED}
|
|
239
|
+
fields = kind.get("writable_fields")
|
|
240
|
+
body = {k: v for k, v in before.items() if (fields is None or k in fields) and k != idf}
|
|
241
|
+
return {"params": {"name": kind["create_tool"], "arguments": {kind.get("create_arg", "item"): body}}}
|
|
242
|
+
return {"reason": REF_REVERT_UNSUPPORTED}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def adapter_from_name(name: Any, upstream: Any = None, spec_path: Optional[str] = None) -> Any:
|
|
246
|
+
"""GARGOYLE_MCP_STATE_PROBE: "filesystem" (default), "none", or "graph"
|
|
247
|
+
with GARGOYLE_MCP_ADAPTER_SPEC naming the tool mapping (default: the
|
|
248
|
+
reference naming shipped in gargoyle/specs/graph-reference.json)."""
|
|
249
|
+
if name is None or name == "" or name == "filesystem":
|
|
250
|
+
return FilesystemAdapter()
|
|
251
|
+
if name == "none":
|
|
252
|
+
return None
|
|
253
|
+
if name == "graph":
|
|
254
|
+
if upstream is None:
|
|
255
|
+
raise ValueError("the graph adapter needs the upstream server")
|
|
256
|
+
path = spec_path or str(Path(__file__).parent / "specs" / "graph-reference.json")
|
|
257
|
+
return GraphAdapter.from_file(upstream, path)
|
|
258
|
+
if hasattr(name, "probe"):
|
|
259
|
+
return name
|
|
260
|
+
if callable(name):
|
|
261
|
+
return _LegacyProbeAdapter(name)
|
|
262
|
+
raise ValueError("unknown state probe: %s" % name)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class _LegacyProbeAdapter:
|
|
266
|
+
"""A bare probe function (tool, args) -> description, for tests and
|
|
267
|
+
integrators who wrote one before adapters existed."""
|
|
268
|
+
|
|
269
|
+
name = "custom"
|
|
270
|
+
|
|
271
|
+
def __init__(self, fn) -> None:
|
|
272
|
+
self.fn = fn
|
|
273
|
+
|
|
274
|
+
def probe(self, tool: str, args: Any) -> Probe:
|
|
275
|
+
desc = self.fn(tool, args)
|
|
276
|
+
return None if desc is None else (desc, read_target(desc))
|
|
277
|
+
|
|
278
|
+
def restore(self, meta, pre, post):
|
|
279
|
+
return FilesystemAdapter().restore(meta, pre, post)
|
|
@@ -238,6 +238,7 @@ def cmd_remote(args) -> int:
|
|
|
238
238
|
app = build_remote_app(config, tokens, upstream=upstream, issuer_url=env.get("GARGOYLE_MCP_ISSUER_URL"),
|
|
239
239
|
audit_log=env.get("GARGOYLE_MCP_AUDIT_LOG") or str(out / "remote-audit.jsonl"),
|
|
240
240
|
state_probe=args.state_probe, snapshot_dir=env.get("GARGOYLE_MCP_SNAPSHOT_DIR"),
|
|
241
|
+
adapter_spec=args.adapter_spec or env.get("GARGOYLE_MCP_ADAPTER_SPEC"),
|
|
241
242
|
approval_timeout=float(env.get("GARGOYLE_MCP_APPROVAL_TIMEOUT_SECONDS", "300")))
|
|
242
243
|
tls = {}
|
|
243
244
|
if args.ssl_certfile or args.ssl_keyfile:
|
|
@@ -316,6 +317,8 @@ def cmd_audit(args) -> int:
|
|
|
316
317
|
why += " state %s -> %s" % (pre or "?", post or "?") if post else " state %s" % pre
|
|
317
318
|
if r.get("reverts"):
|
|
318
319
|
why += " reverts #%d" % r["reverts"]
|
|
320
|
+
if r.get("client"):
|
|
321
|
+
why += " via " + r["client"]
|
|
319
322
|
print("%4s %-5s %-8s %-24s %-28s %s%s" % (r["seq"] if r["seq"] is not None else "?",
|
|
320
323
|
"ok" if r["accepted"] else "FAIL", r["kind"] or "?",
|
|
321
324
|
(r["tool"] or "?")[:24], (r["principal"] or "?")[:28], r["reason"], why))
|
|
@@ -373,8 +376,9 @@ def main(argv=None) -> int:
|
|
|
373
376
|
p.add_argument("--ssl-certfile"); p.add_argument("--ssl-keyfile")
|
|
374
377
|
p.add_argument("--upstream-url", help="front a remote MCP server (Streamable HTTP)")
|
|
375
378
|
p.add_argument("--upstream-token", help="bearer for that server (or GARGOYLE_MCP_UPSTREAM_TOKEN)")
|
|
376
|
-
p.add_argument("--state-probe", default="none", choices=["none", "filesystem"],
|
|
377
|
-
help="filesystem
|
|
379
|
+
p.add_argument("--state-probe", default="none", choices=["none", "filesystem", "graph"],
|
|
380
|
+
help="filesystem when the upstream is a local filesystem server on this host; graph for a Microsoft Graph MCP server")
|
|
381
|
+
p.add_argument("--adapter-spec", help="with --state-probe graph: the tool mapping JSON (default: the reference naming)")
|
|
378
382
|
p.add_argument("--verbose", action="store_true")
|
|
379
383
|
p.add_argument("rest", nargs=argparse.REMAINDER, help="-- <command> for a stdio server to front")
|
|
380
384
|
p.set_defaults(fn=cmd_remote)
|
|
@@ -48,8 +48,9 @@ from gargoyle.envelope import args_sha256, canonical_json, canonical_json_legacy
|
|
|
48
48
|
from gargoyle.execution import verify_chain
|
|
49
49
|
from gargoyle.verifier import verify_provenance
|
|
50
50
|
|
|
51
|
-
EVIDENCE_VERSION = "gargoyle-evidence/
|
|
52
|
-
EVIDENCE_VERSIONS = {"gargoyle-evidence/1": canonical_json_legacy,
|
|
51
|
+
EVIDENCE_VERSION = "gargoyle-evidence/3" # version 3 adds `client`; hashes over RFC 8785 canonical JSON
|
|
52
|
+
EVIDENCE_VERSIONS = {"gargoyle-evidence/1": canonical_json_legacy, "gargoyle-evidence/2": canonical_json,
|
|
53
|
+
EVIDENCE_VERSION: canonical_json}
|
|
53
54
|
GENESIS = "0" * 64
|
|
54
55
|
|
|
55
56
|
HONORED = "honored"
|
|
@@ -65,6 +66,11 @@ REF_EVIDENCE_UNCHAINED = "REF_EVIDENCE_UNCHAINED" # a pre-chain {receipt,
|
|
|
65
66
|
|
|
66
67
|
_FIELDS = ("evidence_version", "seq", "prev_sha256", "at", "kind", "tool", "args_sha256",
|
|
67
68
|
"principal", "reason", "receipt", "grant", "execution", "entry_sha256")
|
|
69
|
+
# Version 3 names the MCP client that made the call ("Claude Desktop 1.2", "ChatGPT"),
|
|
70
|
+
# from the initialize handshake; null when the client did not say. The
|
|
71
|
+
# receipt stays model-agnostic: this is evidence about the session, not a
|
|
72
|
+
# bound field of the decision.
|
|
73
|
+
_FIELDS_V3 = _FIELDS + ("client",)
|
|
68
74
|
|
|
69
75
|
|
|
70
76
|
def entry_sha256(entry: Dict[str, Any]) -> str:
|
|
@@ -78,9 +84,11 @@ def entry_sha256(entry: Dict[str, Any]) -> str:
|
|
|
78
84
|
|
|
79
85
|
def build_entry(*, seq: int, prev_sha256: str, kind: str, tool: str, args: Any, principal: str,
|
|
80
86
|
reason: str, receipt: Any = None, grant: Any = None, execution: Any = None,
|
|
81
|
-
at: Optional[datetime] = None) -> Dict[str, Any]:
|
|
87
|
+
at: Optional[datetime] = None, client: Optional[str] = None) -> Dict[str, Any]:
|
|
82
88
|
if kind not in (HONORED, REFUSED):
|
|
83
89
|
raise ValueError("kind must be honored or refused")
|
|
90
|
+
if client is not None and not isinstance(client, str):
|
|
91
|
+
raise ValueError("client must be a string or None")
|
|
84
92
|
entry = {
|
|
85
93
|
"evidence_version": EVIDENCE_VERSION,
|
|
86
94
|
"seq": seq,
|
|
@@ -94,6 +102,7 @@ def build_entry(*, seq: int, prev_sha256: str, kind: str, tool: str, args: Any,
|
|
|
94
102
|
"receipt": receipt,
|
|
95
103
|
"grant": grant,
|
|
96
104
|
"execution": execution,
|
|
105
|
+
"client": client,
|
|
97
106
|
}
|
|
98
107
|
entry["entry_sha256"] = entry_sha256(entry)
|
|
99
108
|
return entry
|
|
@@ -121,13 +130,15 @@ class EvidenceLog:
|
|
|
121
130
|
self._seq, self._tail = int(last["seq"]), str(last["entry_sha256"])
|
|
122
131
|
|
|
123
132
|
def append(self, kind: str, tool: str, args: Any, principal: str, reason: str, *,
|
|
124
|
-
receipt: Any = None, grant: Any = None, execution: Any = None
|
|
133
|
+
receipt: Any = None, grant: Any = None, execution: Any = None,
|
|
134
|
+
client: Optional[str] = None) -> Dict[str, Any]:
|
|
125
135
|
"""Write one entry and return it. Raises OSError if the file cannot
|
|
126
136
|
be written; the caller decides whether that stops the call."""
|
|
127
137
|
if self._seq is None:
|
|
128
138
|
self._load_tail()
|
|
129
139
|
entry = build_entry(seq=self._seq + 1, prev_sha256=self._tail, kind=kind, tool=tool, args=args,
|
|
130
|
-
principal=principal, reason=reason, receipt=receipt, grant=grant, execution=execution
|
|
140
|
+
principal=principal, reason=reason, receipt=receipt, grant=grant, execution=execution,
|
|
141
|
+
client=client)
|
|
131
142
|
with open(self.path, "a", encoding="utf-8") as f:
|
|
132
143
|
f.write(json.dumps(entry, sort_keys=True) + "\n")
|
|
133
144
|
self._seq, self._tail = entry["seq"], entry["entry_sha256"]
|
|
@@ -135,8 +146,10 @@ class EvidenceLog:
|
|
|
135
146
|
|
|
136
147
|
|
|
137
148
|
def _well_formed(entry: Any) -> bool:
|
|
138
|
-
|
|
149
|
+
expected = _FIELDS_V3 if isinstance(entry, dict) and entry.get("evidence_version") == EVIDENCE_VERSION else _FIELDS
|
|
150
|
+
return (isinstance(entry, dict) and set(entry) == set(expected)
|
|
139
151
|
and entry["evidence_version"] in EVIDENCE_VERSIONS
|
|
152
|
+
and (entry.get("client") is None or isinstance(entry.get("client"), str))
|
|
140
153
|
and isinstance(entry["seq"], int) and entry["seq"] >= 1
|
|
141
154
|
and isinstance(entry["prev_sha256"], str) and len(entry["prev_sha256"]) == 64
|
|
142
155
|
and isinstance(entry["at"], str) and entry["kind"] in (HONORED, REFUSED)
|
|
@@ -179,7 +192,7 @@ def verify_log(
|
|
|
179
192
|
rc = rc if isinstance(rc, dict) else {}
|
|
180
193
|
report.append({"seq": None, "kind": None, "tool": rc.get("tool"), "principal": rc.get("principal"),
|
|
181
194
|
"accepted": False, "reason": REF_EVIDENCE_UNCHAINED if legacy else REF_EVIDENCE_MALFORMED,
|
|
182
|
-
"verdict": None, "released_by": [], "state": None})
|
|
195
|
+
"verdict": None, "released_by": [], "state": None, "client": None})
|
|
183
196
|
all_ok = False
|
|
184
197
|
continue
|
|
185
198
|
e = raw
|
|
@@ -212,7 +225,7 @@ def verify_log(
|
|
|
212
225
|
if ex.get("pre_state_sha256") or ex.get("post_state_sha256") else None)
|
|
213
226
|
report.append({"seq": e["seq"], "kind": e["kind"], "tool": e["tool"], "principal": e["principal"],
|
|
214
227
|
"accepted": ok, "reason": reason, "verdict": e["reason"], "released_by": released_by,
|
|
215
|
-
"state": state})
|
|
228
|
+
"state": state, "client": e.get("client")})
|
|
216
229
|
all_ok = all_ok and ok
|
|
217
230
|
# The chain continues from what the file says, so one bad entry is
|
|
218
231
|
# reported once rather than cascading into every later one.
|