gargoyle-receipts 0.10.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.10.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.11.0.dev0}/PKG-INFO +19 -2
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/README.md +18 -1
- gargoyle_receipts-0.11.0.dev0/gargoyle/adapters.py +279 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/cli.py +4 -2
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/mcp_server.py +24 -21
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/remote.py +2 -1
- {gargoyle_receipts-0.10.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.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/state.py +3 -1
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/static/approve.html +4 -1
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +19 -2
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +3 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/pyproject.toml +2 -2
- gargoyle_receipts-0.11.0.dev0/tests/test_graph.py +165 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_revert.py +8 -7
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/approval.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/approvers.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/consumer.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/envelope.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/evidence.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/execution.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/identity.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/issuer_service.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/policy.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/published_record_source.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/replay_cache.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/rules.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/static/approve.js +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle/verifier.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_approvers.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_cli.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_console_js.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_entra_identity.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_evidence.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_historical.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_issuer_service.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_mcp_self_issue.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_remote.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_rules.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_state.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_threshold.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.10.0.dev0 → gargoyle_receipts-0.11.0.dev0}/tests/test_wire_spec.py +0 -0
{gargoyle_receipts-0.10.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
|
|
@@ -292,6 +292,22 @@ 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
|
+
## 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
|
+
|
|
295
311
|
## Remote mode
|
|
296
312
|
|
|
297
313
|
Claude's hosted connectors (claude.ai in the browser, Claude Desktop's
|
|
@@ -341,6 +357,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
341
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 |
|
|
342
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 |
|
|
343
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) |
|
|
344
361
|
| `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
|
|
345
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 |
|
|
346
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
|
|
@@ -254,6 +254,22 @@ 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
|
+
## 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
|
+
|
|
257
273
|
## Remote mode
|
|
258
274
|
|
|
259
275
|
Claude's hosted connectors (claude.ai in the browser, Claude Desktop's
|
|
@@ -303,6 +319,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
303
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 |
|
|
304
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 |
|
|
305
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) |
|
|
306
323
|
| `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
|
|
307
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 |
|
|
308
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:
|
|
@@ -375,8 +376,9 @@ def main(argv=None) -> int:
|
|
|
375
376
|
p.add_argument("--ssl-certfile"); p.add_argument("--ssl-keyfile")
|
|
376
377
|
p.add_argument("--upstream-url", help="front a remote MCP server (Streamable HTTP)")
|
|
377
378
|
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
|
|
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)")
|
|
380
382
|
p.add_argument("--verbose", action="store_true")
|
|
381
383
|
p.add_argument("rest", nargs=argparse.REMAINDER, help="-- <command> for a stdio server to front")
|
|
382
384
|
p.set_defaults(fn=cmd_remote)
|
|
@@ -99,6 +99,7 @@ ENV_APPROVAL_TIMEOUT = "GARGOYLE_MCP_APPROVAL_TIMEOUT_SECONDS"
|
|
|
99
99
|
ENV_POLL_SECONDS = "GARGOYLE_MCP_POLL_SECONDS"
|
|
100
100
|
ENV_AUDIT_LOG = "GARGOYLE_MCP_AUDIT_LOG"
|
|
101
101
|
ENV_STATE_PROBE = "GARGOYLE_MCP_STATE_PROBE"
|
|
102
|
+
ENV_ADAPTER_SPEC = "GARGOYLE_MCP_ADAPTER_SPEC" # with GARGOYLE_MCP_STATE_PROBE=graph: the tool mapping JSON
|
|
102
103
|
ENV_SNAPSHOT_DIR = "GARGOYLE_MCP_SNAPSHOT_DIR" # where pre-change snapshots live (default: snapshots/ beside the audit log); "none" disables undo # "filesystem" (default): bind held calls to the target file's state; "none" to disable
|
|
103
104
|
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
|
|
104
105
|
|
|
@@ -302,15 +303,18 @@ class ServerState:
|
|
|
302
303
|
audit_log: Optional[str] = None,
|
|
303
304
|
state_probe: Any = "filesystem",
|
|
304
305
|
snapshot_dir: Optional[str] = None,
|
|
306
|
+
adapter_spec: Optional[str] = None,
|
|
305
307
|
) -> None:
|
|
306
|
-
from gargoyle.
|
|
308
|
+
from gargoyle.adapters import adapter_from_name
|
|
307
309
|
from gargoyle.revert import SnapshotStore
|
|
308
310
|
self.config = config
|
|
309
|
-
|
|
311
|
+
# The adapter describes, explains and undoes what a tool changes
|
|
312
|
+
# (gargoyle.adapters): files on this host by default, a Graph server by spec.
|
|
313
|
+
self.adapter = adapter_from_name(state_probe, upstream, adapter_spec)
|
|
310
314
|
if snapshot_dir is None and audit_log:
|
|
311
315
|
snapshot_dir = str(Path(audit_log).parent / "snapshots")
|
|
312
316
|
self.snapshots: Optional[SnapshotStore] = (SnapshotStore(snapshot_dir)
|
|
313
|
-
if snapshot_dir and snapshot_dir != "none" and self.
|
|
317
|
+
if snapshot_dir and snapshot_dir != "none" and self.adapter is not None else None)
|
|
314
318
|
self.upstream = upstream
|
|
315
319
|
self.issuer_client = issuer_client
|
|
316
320
|
self.evidence: Optional[EvidenceLog] = EvidenceLog(audit_log) if audit_log else None
|
|
@@ -363,8 +367,8 @@ def _gate(state: ServerState, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
363
367
|
# verified against the pinned issuer key like any other. The
|
|
364
368
|
# target's current state goes with the request (gargoyle.state), so
|
|
365
369
|
# a hold is approved against it and bound to it.
|
|
366
|
-
|
|
367
|
-
res = state.issuer_client.issue(state.config["target_id"], name, args, state=
|
|
370
|
+
probed = state.adapter.probe(name, args) if state.adapter is not None else None
|
|
371
|
+
res = state.issuer_client.issue(state.config["target_id"], name, args, state=probed[0] if probed else None)
|
|
368
372
|
if res["envelope"] is None:
|
|
369
373
|
return refused(res["reason"])
|
|
370
374
|
envelope, self_issued = res["envelope"], True
|
|
@@ -376,12 +380,12 @@ def _gate(state: ServerState, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
376
380
|
|
|
377
381
|
# A state-bound receipt is checked against the state as it is now, after
|
|
378
382
|
# any wait for approval: if the target moved, the call does not run.
|
|
379
|
-
live_state = None
|
|
383
|
+
live_state, now_probe = None, None
|
|
380
384
|
if isinstance(envelope, dict) and isinstance(envelope.get("request_context"), dict) \
|
|
381
|
-
and "state_sha256" in envelope["request_context"] and state.
|
|
385
|
+
and "state_sha256" in envelope["request_context"] and state.adapter is not None:
|
|
382
386
|
from gargoyle.state import state_sha256 as _state_hash
|
|
383
|
-
|
|
384
|
-
live_state = _state_hash(
|
|
387
|
+
now_probe = state.adapter.probe(name, args)
|
|
388
|
+
live_state = _state_hash(now_probe[0]) if now_probe is not None else None
|
|
385
389
|
verdict = state.consumer.check(envelope, call_for(name, args, state.config["principal"], live_state),
|
|
386
390
|
grant=grant)
|
|
387
391
|
if not verdict.honored:
|
|
@@ -390,12 +394,9 @@ def _gate(state: ServerState, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
390
394
|
# Undo material: for a held, state-bound call, keep the target as it is
|
|
391
395
|
# right now (the state the receipt bound and the check just confirmed).
|
|
392
396
|
if (state.snapshots is not None and isinstance(envelope, dict) and envelope.get("requires_approval")
|
|
393
|
-
and
|
|
397
|
+
and now_probe is not None and isinstance(envelope.get("decision_id"), str)):
|
|
394
398
|
try:
|
|
395
|
-
|
|
396
|
-
if desc is not None and state.config and state.config.get("principal"):
|
|
397
|
-
from gargoyle.state import read_target
|
|
398
|
-
state.snapshots.put(envelope["decision_id"], desc, read_target(desc), name)
|
|
399
|
+
state.snapshots.put(envelope["decision_id"], now_probe[0], now_probe[1], name, args)
|
|
399
400
|
except Exception as exc:
|
|
400
401
|
sys.stderr.write("gargoyle: snapshot failed: %s\n" % exc)
|
|
401
402
|
|
|
@@ -445,14 +446,15 @@ def _attest(state: ServerState, envelope: Optional[Dict[str, Any]], outputs: Any
|
|
|
445
446
|
probed again after the run, so the receipt says what it changed."""
|
|
446
447
|
if state.consumer is None or not state.consumer.attests or envelope is None:
|
|
447
448
|
return None
|
|
448
|
-
post = None
|
|
449
|
-
if params is not None and state.
|
|
449
|
+
post, post_data = None, None
|
|
450
|
+
if params is not None and state.adapter is not None and "state_sha256" in (envelope.get("request_context") or {}):
|
|
450
451
|
from gargoyle.state import state_sha256 as _state_hash
|
|
451
|
-
|
|
452
|
-
|
|
452
|
+
probed = state.adapter.probe(str(params.get("name")), params.get("arguments", {}))
|
|
453
|
+
if probed is not None:
|
|
454
|
+
post, post_data = _state_hash(probed[0]), probed[1]
|
|
453
455
|
if post is not None and state.snapshots is not None and isinstance(envelope.get("decision_id"), str):
|
|
454
456
|
try:
|
|
455
|
-
state.snapshots.record_post(envelope["decision_id"], post)
|
|
457
|
+
state.snapshots.record_post(envelope["decision_id"], post, post_data)
|
|
456
458
|
except Exception:
|
|
457
459
|
pass
|
|
458
460
|
return state.consumer.attest(envelope, outputs, post_state_sha256=post)
|
|
@@ -532,7 +534,7 @@ def handle_message(state: ServerState, msg: Dict[str, Any]) -> Optional[Dict[str
|
|
|
532
534
|
if state.snapshots is None:
|
|
533
535
|
return _success(msg_id, {"content": [{"type": "text", "text": "refused: undo is not enabled"}], "isError": True,
|
|
534
536
|
"_meta": {EXECUTED_META_KEY: False, REASON_META_KEY: "REF_REVERT_UNKNOWN"}})
|
|
535
|
-
plan = plan_revert(state.snapshots, str((params.get("arguments") or {}).get("decision_id") or "last"), state.
|
|
537
|
+
plan = plan_revert(state.snapshots, str((params.get("arguments") or {}).get("decision_id") or "last"), state.adapter)
|
|
536
538
|
if "reason" in plan:
|
|
537
539
|
return _success(msg_id, {"content": [{"type": "text", "text": "refused: " + plan["reason"]}], "isError": True,
|
|
538
540
|
"_meta": {EXECUTED_META_KEY: False, REASON_META_KEY: plan["reason"]}})
|
|
@@ -719,7 +721,8 @@ if __name__ == "__main__":
|
|
|
719
721
|
try:
|
|
720
722
|
serve_stdio(ServerState(config_from_env(), upstream=_upstream, issuer_client=issuer_client_from_env(),
|
|
721
723
|
audit_log=_os.environ.get(ENV_AUDIT_LOG) or None,
|
|
722
|
-
state_probe=_os.environ.get(ENV_STATE_PROBE) or "filesystem"
|
|
724
|
+
state_probe=_os.environ.get(ENV_STATE_PROBE) or "filesystem",
|
|
725
|
+
adapter_spec=_os.environ.get(ENV_ADAPTER_SPEC) or None))
|
|
723
726
|
finally:
|
|
724
727
|
if _upstream is not None:
|
|
725
728
|
_upstream.close()
|
|
@@ -138,6 +138,7 @@ def build_remote_app(
|
|
|
138
138
|
issuer_url: Optional[str] = None,
|
|
139
139
|
audit_log: Optional[str] = None,
|
|
140
140
|
state_probe: Any = "none",
|
|
141
|
+
adapter_spec: Optional[str] = None,
|
|
141
142
|
snapshot_dir: Optional[str] = None,
|
|
142
143
|
issuer_http: Any = None,
|
|
143
144
|
approval_timeout: float = 300.0,
|
|
@@ -177,7 +178,7 @@ def build_remote_app(
|
|
|
177
178
|
kw["sleep"] = sleep
|
|
178
179
|
ic = IssuerClient(issuer_url, issuer_token, **kw)
|
|
179
180
|
st = ServerState(dict(config, principal=principal), replay_cache=replay, upstream=upstream, issuer_client=ic,
|
|
180
|
-
audit_log=audit_log, state_probe=state_probe, snapshot_dir=snapshot_dir)
|
|
181
|
+
audit_log=audit_log, state_probe=state_probe, snapshot_dir=snapshot_dir, adapter_spec=adapter_spec)
|
|
181
182
|
# One log and one snapshot store for the whole process: the chain and
|
|
182
183
|
# the sidecars must not interleave from several session objects.
|
|
183
184
|
if st.evidence is not None:
|
|
@@ -32,6 +32,8 @@ from pathlib import Path
|
|
|
32
32
|
from typing import Any, Dict, List, Optional
|
|
33
33
|
|
|
34
34
|
from gargoyle.state import state_sha256
|
|
35
|
+
from gargoyle.adapters import (REF_REVERT_IRREVERSIBLE, REF_REVERT_NEEDS_DELETE, REF_REVERT_NOT_TEXT, REF_REVERT_UNKNOWN,
|
|
36
|
+
REF_REVERT_UNSUPPORTED)
|
|
35
37
|
|
|
36
38
|
REVERT_TOOL = "gargoyle_revert"
|
|
37
39
|
REVERT_TOOL_DEF = {
|
|
@@ -42,10 +44,7 @@ REVERT_TOOL_DEF = {
|
|
|
42
44
|
"inputSchema": {"type": "object", "properties": {"decision_id": {"type": "string", "default": "last"}}},
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
REF_REVERT_UNKNOWN = "REF_REVERT_UNKNOWN"
|
|
46
47
|
REF_REVERT_STATE_CHANGED = "REF_REVERT_STATE_CHANGED"
|
|
47
|
-
REF_REVERT_NEEDS_DELETE = "REF_REVERT_NEEDS_DELETE"
|
|
48
|
-
REF_REVERT_NOT_TEXT = "REF_REVERT_NOT_TEXT"
|
|
49
48
|
|
|
50
49
|
|
|
51
50
|
class SnapshotStore:
|
|
@@ -57,9 +56,12 @@ class SnapshotStore:
|
|
|
57
56
|
def _ensure(self) -> None:
|
|
58
57
|
self.dir.mkdir(parents=True, exist_ok=True)
|
|
59
58
|
|
|
60
|
-
def put(self, decision_id: str, description: Dict[str, Any], data: Optional[bytes], tool: str
|
|
59
|
+
def put(self, decision_id: str, description: Dict[str, Any], data: Optional[bytes], tool: str,
|
|
60
|
+
args: Any = None) -> Dict[str, Any]:
|
|
61
61
|
"""Keep the pre-state of `decision_id`: the bytes (None when the target
|
|
62
|
-
did not exist) and a sidecar
|
|
62
|
+
did not exist) and a sidecar naming the call, so the same probe can be
|
|
63
|
+
repeated later to ask whether the target is still as the call left it.
|
|
64
|
+
Returns the sidecar."""
|
|
63
65
|
self._ensure()
|
|
64
66
|
key = state_sha256(description)
|
|
65
67
|
if data is not None:
|
|
@@ -70,17 +72,23 @@ class SnapshotStore:
|
|
|
70
72
|
os.chmod(blob, stat.S_IRUSR | stat.S_IWUSR)
|
|
71
73
|
except OSError:
|
|
72
74
|
pass
|
|
73
|
-
meta = {"decision_id": decision_id, "tool": tool, "ref": description["ref"], "pre_state_sha256": key,
|
|
75
|
+
meta = {"decision_id": decision_id, "tool": tool, "args": args, "ref": description["ref"], "pre_state_sha256": key,
|
|
74
76
|
"existed": data is not None, "post_state_sha256": None, "at": datetime.now(timezone.utc).isoformat()}
|
|
75
77
|
(self.dir / (decision_id + ".json")).write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8")
|
|
76
78
|
return meta
|
|
77
79
|
|
|
78
|
-
def record_post(self, decision_id: str, post_state_sha256: Optional[str]) -> None:
|
|
80
|
+
def record_post(self, decision_id: str, post_state_sha256: Optional[str], data: Optional[bytes] = None) -> None:
|
|
81
|
+
"""The state the call left: its hash in the sidecar, its bytes under
|
|
82
|
+
that hash (an adapter may need both snapshots to compose the undo)."""
|
|
79
83
|
p = self.dir / (decision_id + ".json")
|
|
80
84
|
if p.exists():
|
|
81
85
|
meta = json.loads(p.read_text(encoding="utf-8"))
|
|
82
86
|
meta["post_state_sha256"] = post_state_sha256
|
|
83
87
|
p.write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8")
|
|
88
|
+
if data is not None and post_state_sha256:
|
|
89
|
+
blob = self.dir / (post_state_sha256 + ".bin")
|
|
90
|
+
if not blob.exists():
|
|
91
|
+
blob.write_bytes(data)
|
|
84
92
|
|
|
85
93
|
def sidecars(self) -> List[Dict[str, Any]]:
|
|
86
94
|
if not self.dir.exists():
|
|
@@ -100,25 +108,26 @@ class SnapshotStore:
|
|
|
100
108
|
return p.read_bytes() if p.exists() else None
|
|
101
109
|
|
|
102
110
|
|
|
103
|
-
def plan_revert(store: SnapshotStore, decision_id: str,
|
|
104
|
-
"""The
|
|
105
|
-
{"params": {"name"
|
|
106
|
-
|
|
111
|
+
def plan_revert(store: SnapshotStore, decision_id: str, adapter) -> Dict[str, Any]:
|
|
112
|
+
"""The call that undoes `decision_id`, or a refusal:
|
|
113
|
+
{"params": {"name", "arguments"}, "reverts": id} | {"reason": REF_REVERT_*}.
|
|
114
|
+
The adapter composes the inverse from the two snapshots and confirms the
|
|
107
115
|
target is still as that call left it."""
|
|
108
116
|
meta = store.find(decision_id or "last")
|
|
109
117
|
if meta is None or not meta.get("post_state_sha256"):
|
|
110
118
|
return {"reason": REF_REVERT_UNKNOWN}
|
|
111
|
-
if
|
|
112
|
-
return {"reason":
|
|
113
|
-
|
|
114
|
-
if
|
|
119
|
+
if adapter is None:
|
|
120
|
+
return {"reason": REF_REVERT_UNSUPPORTED}
|
|
121
|
+
pre = store.content(meta["pre_state_sha256"]) if meta.get("existed") else None
|
|
122
|
+
if meta.get("existed") and pre is None:
|
|
115
123
|
return {"reason": REF_REVERT_UNKNOWN}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
return
|
|
120
|
-
|
|
121
|
-
now
|
|
122
|
-
if
|
|
124
|
+
post = store.content(meta["post_state_sha256"])
|
|
125
|
+
plan = adapter.restore(meta, pre, post)
|
|
126
|
+
if "reason" in plan:
|
|
127
|
+
return plan
|
|
128
|
+
# Still as the original call left it? Repeat the original call's probe
|
|
129
|
+
# now and compare with the state recorded after it ran.
|
|
130
|
+
probe = adapter.probe(meta["tool"], meta.get("args") if meta.get("args") is not None else {"path": meta["ref"]})
|
|
131
|
+
if probe is None or state_sha256(probe[0]) != meta["post_state_sha256"]:
|
|
123
132
|
return {"reason": REF_REVERT_STATE_CHANGED}
|
|
124
|
-
return {"params":
|
|
133
|
+
return {"params": plan["params"], "reverts": meta["decision_id"]}
|