gargoyle-receipts 0.10.0.dev0__tar.gz → 0.12.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.10.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.12.0.dev0}/PKG-INFO +32 -2
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/README.md +31 -1
- gargoyle_receipts-0.12.0.dev0/gargoyle/adapters.py +279 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/cli.py +6 -2
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/consumer.py +21 -1
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/evidence.py +9 -4
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/execution.py +9 -2
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/issuer_service.py +96 -11
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/mcp_server.py +128 -29
- gargoyle_receipts-0.12.0.dev0/gargoyle/plans.py +209 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/policy.py +1 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/remote.py +2 -1
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/revert.py +32 -23
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/rules.py +6 -2
- gargoyle_receipts-0.12.0.dev0/gargoyle/specs/graph-reference.json +56 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/state.py +3 -1
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/static/approve.html +36 -1
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +32 -2
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +5 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/pyproject.toml +2 -2
- gargoyle_receipts-0.12.0.dev0/tests/test_graph.py +165 -0
- gargoyle_receipts-0.12.0.dev0/tests/test_plans.py +187 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_revert.py +8 -7
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/approval.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/approvers.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/envelope.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/identity.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/published_record_source.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/replay_cache.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/static/approve.js +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/verifier.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_approvers.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_cli.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_console_js.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_entra_identity.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_evidence.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_historical.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_issuer_service.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_mcp_self_issue.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_remote.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_rules.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_state.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_threshold.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_wire_spec.py +0 -0
{gargoyle_receipts-0.10.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.12.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.12.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.12.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
|
|
@@ -292,6 +292,35 @@ and `gargoyle audit` verifies them later offline.
|
|
|
292
292
|
and through the alternative with OPA in Docker for sites that run a policy
|
|
293
293
|
engine.
|
|
294
294
|
|
|
295
|
+
## Scoped grants
|
|
296
|
+
|
|
297
|
+
One card per held call is right for a mailbox rule and wrong for a long
|
|
298
|
+
task. The assistant can ask for a plan (`gargoyle_plan`): a list of steps,
|
|
299
|
+
each a tool with exact arguments or argument patterns, and a note saying
|
|
300
|
+
why. The issuer judges every step under the policy, signs the plan, and
|
|
301
|
+
holds it as one card; the approver signs once; each later call matching
|
|
302
|
+
an unused step runs without a card of its own until the plan expires.
|
|
303
|
+
Every step is still bound, claimed once, and recorded with the plan and
|
|
304
|
+
step that released it. The rules file decides what may be planned
|
|
305
|
+
(`"plan": false` keeps a tool individual; sends should be), because a
|
|
306
|
+
plan approves scope rather than content.
|
|
307
|
+
|
|
308
|
+
## Microsoft 365 and other targets: adapters
|
|
309
|
+
|
|
310
|
+
What a held call changes is the adapter's business (`gargoyle.adapters`):
|
|
311
|
+
it describes the target's state before and after, tells the approver what
|
|
312
|
+
the call means in plain words, and composes the call that undoes it. Files
|
|
313
|
+
on the executor's host are the default. `GARGOYLE_MCP_STATE_PROBE=graph`
|
|
314
|
+
selects the Graph adapter for a Microsoft Graph MCP server: inbox rules,
|
|
315
|
+
calendar events, contacts and Teams membership are bound to their state,
|
|
316
|
+
explained on the card ("creates inbox rule 'Fwd invoices' (to an outside
|
|
317
|
+
address: x@evil.example)"), undone by the inverse Graph call, and a send
|
|
318
|
+
is marked as something that cannot be undone. The adapter reads state
|
|
319
|
+
through the upstream server itself, so the gate needs no Graph credential
|
|
320
|
+
of its own. Which tool is which is a JSON spec; the reference naming is in
|
|
321
|
+
`gargoyle/specs/graph-reference.json`, and a different server's names are
|
|
322
|
+
mapped there, not in code.
|
|
323
|
+
|
|
295
324
|
## Remote mode
|
|
296
325
|
|
|
297
326
|
Claude's hosted connectors (claude.ai in the browser, Claude Desktop's
|
|
@@ -341,6 +370,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
341
370
|
| 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 |
|
|
342
371
|
| `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 |
|
|
343
372
|
| `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 |
|
|
373
|
+
| `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
|
|
344
374
|
| `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
|
|
345
375
|
| `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 |
|
|
346
376
|
| `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.12.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
|
|
@@ -254,6 +254,35 @@ and `gargoyle audit` verifies them later offline.
|
|
|
254
254
|
and through the alternative with OPA in Docker for sites that run a policy
|
|
255
255
|
engine.
|
|
256
256
|
|
|
257
|
+
## Scoped grants
|
|
258
|
+
|
|
259
|
+
One card per held call is right for a mailbox rule and wrong for a long
|
|
260
|
+
task. The assistant can ask for a plan (`gargoyle_plan`): a list of steps,
|
|
261
|
+
each a tool with exact arguments or argument patterns, and a note saying
|
|
262
|
+
why. The issuer judges every step under the policy, signs the plan, and
|
|
263
|
+
holds it as one card; the approver signs once; each later call matching
|
|
264
|
+
an unused step runs without a card of its own until the plan expires.
|
|
265
|
+
Every step is still bound, claimed once, and recorded with the plan and
|
|
266
|
+
step that released it. The rules file decides what may be planned
|
|
267
|
+
(`"plan": false` keeps a tool individual; sends should be), because a
|
|
268
|
+
plan approves scope rather than content.
|
|
269
|
+
|
|
270
|
+
## Microsoft 365 and other targets: adapters
|
|
271
|
+
|
|
272
|
+
What a held call changes is the adapter's business (`gargoyle.adapters`):
|
|
273
|
+
it describes the target's state before and after, tells the approver what
|
|
274
|
+
the call means in plain words, and composes the call that undoes it. Files
|
|
275
|
+
on the executor's host are the default. `GARGOYLE_MCP_STATE_PROBE=graph`
|
|
276
|
+
selects the Graph adapter for a Microsoft Graph MCP server: inbox rules,
|
|
277
|
+
calendar events, contacts and Teams membership are bound to their state,
|
|
278
|
+
explained on the card ("creates inbox rule 'Fwd invoices' (to an outside
|
|
279
|
+
address: x@evil.example)"), undone by the inverse Graph call, and a send
|
|
280
|
+
is marked as something that cannot be undone. The adapter reads state
|
|
281
|
+
through the upstream server itself, so the gate needs no Graph credential
|
|
282
|
+
of its own. Which tool is which is a JSON spec; the reference naming is in
|
|
283
|
+
`gargoyle/specs/graph-reference.json`, and a different server's names are
|
|
284
|
+
mapped there, not in code.
|
|
285
|
+
|
|
257
286
|
## Remote mode
|
|
258
287
|
|
|
259
288
|
Claude's hosted connectors (claude.ai in the browser, Claude Desktop's
|
|
@@ -303,6 +332,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
303
332
|
| 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 |
|
|
304
333
|
| `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 |
|
|
305
334
|
| `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 |
|
|
335
|
+
| `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
|
|
306
336
|
| `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
|
|
307
337
|
| `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 |
|
|
308
338
|
| `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:
|
|
@@ -310,6 +311,8 @@ def cmd_audit(args) -> int:
|
|
|
310
311
|
why = (" (%s)" % r["verdict"]) if r["kind"] == "refused" and r["verdict"] else ""
|
|
311
312
|
if r.get("released_by"):
|
|
312
313
|
why += " released by " + ", ".join(r["released_by"])
|
|
314
|
+
if r.get("plan"):
|
|
315
|
+
why += " (plan %s step %d)" % (str(r["plan"].get("plan_id") or "?")[:8], r["plan"].get("step", -1) + 1)
|
|
313
316
|
if r.get("state"):
|
|
314
317
|
# A state-bound call: the target as it was approved, and as the run left it.
|
|
315
318
|
pre, post = (r["state"].get("pre") or "")[:8], (r["state"].get("post") or "")[:8]
|
|
@@ -375,8 +378,9 @@ def main(argv=None) -> int:
|
|
|
375
378
|
p.add_argument("--ssl-certfile"); p.add_argument("--ssl-keyfile")
|
|
376
379
|
p.add_argument("--upstream-url", help="front a remote MCP server (Streamable HTTP)")
|
|
377
380
|
p.add_argument("--upstream-token", help="bearer for that server (or GARGOYLE_MCP_UPSTREAM_TOKEN)")
|
|
378
|
-
p.add_argument("--state-probe", default="none", choices=["none", "filesystem"],
|
|
379
|
-
help="filesystem
|
|
381
|
+
p.add_argument("--state-probe", default="none", choices=["none", "filesystem", "graph"],
|
|
382
|
+
help="filesystem when the upstream is a local filesystem server on this host; graph for a Microsoft Graph MCP server")
|
|
383
|
+
p.add_argument("--adapter-spec", help="with --state-probe graph: the tool mapping JSON (default: the reference naming)")
|
|
380
384
|
p.add_argument("--verbose", action="store_true")
|
|
381
385
|
p.add_argument("rest", nargs=argparse.REMAINDER, help="-- <command> for a stdio server to front")
|
|
382
386
|
p.set_defaults(fn=cmd_remote)
|
|
@@ -273,6 +273,7 @@ class Consumer:
|
|
|
273
273
|
*,
|
|
274
274
|
grant: Any = None,
|
|
275
275
|
now: Optional[datetime] = None,
|
|
276
|
+
plan_release: Any = None,
|
|
276
277
|
) -> Verdict:
|
|
277
278
|
"""Decide whether to honor `envelope` for `call` ({"tool", "args",
|
|
278
279
|
"principal"}, the principal being the one this consumer
|
|
@@ -321,7 +322,26 @@ class Consumer:
|
|
|
321
322
|
# inside the signed region, so they are the issuer's word, not the
|
|
322
323
|
# caller's. The grant is checked and claimed before the receipt is
|
|
323
324
|
# claimed, so a refused grant leaves the receipt unspent.
|
|
324
|
-
if envelope["requires_approval"]:
|
|
325
|
+
if envelope["requires_approval"] and plan_release is not None:
|
|
326
|
+
# A scoped grant (gargoyle.plans): the plan's step is claimed once
|
|
327
|
+
# in place of a per-call grant id.
|
|
328
|
+
from gargoyle.plans import verify_plan_release
|
|
329
|
+
approver_keys = self._approver_keys(key_record_view, envelope.get("issuer_key_id"), now)
|
|
330
|
+
if not approver_keys:
|
|
331
|
+
return Verdict(False, REF_TARGET_NO_APPROVER_TRUST)
|
|
332
|
+
r = verify_plan_release(plan_release, envelope, call, issuer_public_keys=pinned_public_keys,
|
|
333
|
+
approver_public_keys=approver_keys, now=now, clock_skew=self.clock_skew)
|
|
334
|
+
if not r["accepted"]:
|
|
335
|
+
return Verdict(False, r["reason"])
|
|
336
|
+
plan = plan_release["plan"]
|
|
337
|
+
try:
|
|
338
|
+
plan_exp = datetime.fromisoformat(plan["not_after"]) + self.clock_skew
|
|
339
|
+
except (ValueError, TypeError, KeyError):
|
|
340
|
+
plan_exp = None
|
|
341
|
+
if not self.replay_cache.check_and_claim("plan:%s:%d" % (plan["plan_id"], r["step"]), plan_exp, now=now):
|
|
342
|
+
from gargoyle.plans import REF_PLAN_STEP_CONSUMED
|
|
343
|
+
return Verdict(False, REF_PLAN_STEP_CONSUMED)
|
|
344
|
+
elif envelope["requires_approval"]:
|
|
325
345
|
grants = as_grant_list(grant)
|
|
326
346
|
if not grants:
|
|
327
347
|
return Verdict(False, REF_TARGET_APPROVAL_REQUIRED)
|
|
@@ -192,7 +192,7 @@ def verify_log(
|
|
|
192
192
|
rc = rc if isinstance(rc, dict) else {}
|
|
193
193
|
report.append({"seq": None, "kind": None, "tool": rc.get("tool"), "principal": rc.get("principal"),
|
|
194
194
|
"accepted": False, "reason": REF_EVIDENCE_UNCHAINED if legacy else REF_EVIDENCE_MALFORMED,
|
|
195
|
-
"verdict": None, "released_by": [], "state": None, "client": None})
|
|
195
|
+
"verdict": None, "released_by": [], "state": None, "client": None, "plan": None})
|
|
196
196
|
all_ok = False
|
|
197
197
|
continue
|
|
198
198
|
e = raw
|
|
@@ -218,14 +218,19 @@ def verify_log(
|
|
|
218
218
|
now=_parse_at(e["at"]), historical=True)
|
|
219
219
|
ok, reason = p["accepted"], (ACCEPT_ENTRY_VERIFIED if p["accepted"] else p["reason"])
|
|
220
220
|
counts[e["kind"]] += 1
|
|
221
|
-
|
|
222
|
-
|
|
221
|
+
plan_ref = None
|
|
222
|
+
grant_set = e["grant"]
|
|
223
|
+
if isinstance(grant_set, dict) and "plan" in grant_set and "step" in grant_set:
|
|
224
|
+
plan_ref = {"plan_id": (grant_set["plan"] or {}).get("plan_id"), "step": grant_set["step"]}
|
|
225
|
+
grant_set = grant_set.get("grants")
|
|
226
|
+
released_by = [g.get("approver_key_id") for g in (grant_set if isinstance(grant_set, list) else [grant_set])
|
|
227
|
+
if isinstance(g, dict)] if grant_set is not None else []
|
|
223
228
|
ex = e["execution"] if isinstance(e["execution"], dict) else {}
|
|
224
229
|
state = ({"pre": ex.get("pre_state_sha256"), "post": ex.get("post_state_sha256")}
|
|
225
230
|
if ex.get("pre_state_sha256") or ex.get("post_state_sha256") else None)
|
|
226
231
|
report.append({"seq": e["seq"], "kind": e["kind"], "tool": e["tool"], "principal": e["principal"],
|
|
227
232
|
"accepted": ok, "reason": reason, "verdict": e["reason"], "released_by": released_by,
|
|
228
|
-
"state": state, "client": e.get("client")})
|
|
233
|
+
"state": state, "client": e.get("client"), "plan": plan_ref})
|
|
229
234
|
all_ok = all_ok and ok
|
|
230
235
|
# The chain continues from what the file says, so one bad entry is
|
|
231
236
|
# reported once rather than cascading into every later one.
|
|
@@ -218,10 +218,17 @@ def verify_chain(
|
|
|
218
218
|
return {"accepted": False, "reason": provenance["reason"], "steps": steps}
|
|
219
219
|
|
|
220
220
|
if envelope["requires_approval"]:
|
|
221
|
-
|
|
221
|
+
from gargoyle.plans import is_plan_release, verify_plan_release
|
|
222
|
+
if is_plan_release(grant):
|
|
223
|
+
# Released by a scoped grant: the plan, its grants and the step,
|
|
224
|
+
# re-matched against the arguments the release recorded.
|
|
225
|
+
g = verify_plan_release(grant, envelope, {}, issuer_public_keys=issuer_public_keys,
|
|
226
|
+
approver_public_keys=approver_public_keys or {}, now=executed_at)
|
|
227
|
+
elif not as_grant_list(grant):
|
|
222
228
|
steps.append(("approval", REF_EXEC_GRANT_MISSING))
|
|
223
229
|
return {"accepted": False, "reason": REF_EXEC_GRANT_MISSING, "steps": steps}
|
|
224
|
-
|
|
230
|
+
else:
|
|
231
|
+
g = verify_grants(grant, envelope=envelope, approver_public_keys=approver_public_keys or {}, now=executed_at)
|
|
225
232
|
steps.append(("approval", g["reason"]))
|
|
226
233
|
if not g["accepted"]:
|
|
227
234
|
return {"accepted": False, "reason": g["reason"], "steps": steps}
|