gargoyle-receipts 0.14.1.dev0__tar.gz → 0.15.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.14.1.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.15.0.dev0}/PKG-INFO +3 -2
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/README.md +2 -1
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/cli.py +8 -1
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/mcp_server.py +27 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/reconcile.py +11 -2
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/remote.py +4 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/witness.py +4 -2
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +3 -2
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/pyproject.toml +1 -1
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_mcp_self_issue.py +24 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_remote.py +1 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_witness.py +13 -8
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/adapters.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/approval.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/approvers.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/consumer.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/envelope.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/evidence.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/execution.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/holdstore.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/identity.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/issuer_service.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/plans.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/policy.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/published_record_source.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/replay_cache.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/revert.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/rules.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/state.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/static/approve.html +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/static/approve.js +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/verifier.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_approvers.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_cli.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_console_js.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_entra_identity.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_evidence.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_graph.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_historical.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_issuer_service.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_plans.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_revert.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_rules.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_state.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_threshold.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_wire_spec.py +0 -0
{gargoyle_receipts-0.14.1.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.15.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.15.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.15.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
|
|
@@ -383,6 +383,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
383
383
|
| issuer `GARGOYLE_WITNESS_PATH` | the issuer's signed, hash-chained record of every decision (`gargoyle issuer` sets `witness.jsonl` beside the keys); `gargoyle audit --witness` reconciles a client's evidence against it, `--check-tree DIR` finds files changed outside the gate |
|
|
384
384
|
| issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
|
|
385
385
|
| `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 |
|
|
386
|
+
| `GARGOYLE_MCP_INSTRUCTIONS` | replaces the standing instructions the gate hands the model in the MCP handshake (default: all work on the target goes through these tools, a wait is normal, a refusal is final, plans and undo exist); the user never has to name the server |
|
|
386
387
|
| `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 |
|
|
387
388
|
| `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
|
|
388
389
|
| `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
|
|
@@ -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.15.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
|
|
@@ -345,6 +345,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
345
345
|
| issuer `GARGOYLE_WITNESS_PATH` | the issuer's signed, hash-chained record of every decision (`gargoyle issuer` sets `witness.jsonl` beside the keys); `gargoyle audit --witness` reconciles a client's evidence against it, `--check-tree DIR` finds files changed outside the gate |
|
|
346
346
|
| issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
|
|
347
347
|
| `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 |
|
|
348
|
+
| `GARGOYLE_MCP_INSTRUCTIONS` | replaces the standing instructions the gate hands the model in the MCP handshake (default: all work on the target goes through these tools, a wait is normal, a refusal is final, plans and undo exist); the user never has to name the server |
|
|
348
349
|
| `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 |
|
|
349
350
|
| `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
|
|
350
351
|
| `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
|
|
@@ -375,9 +375,16 @@ def cmd_audit(args) -> int:
|
|
|
375
375
|
print("witness: %d entries, %s, tail %s" % (w["count"], w["reason"], w["tail_sha256"][:16]))
|
|
376
376
|
principal = next((e.get("principal") for e in entries if isinstance(e, dict) and e.get("principal")), None)
|
|
377
377
|
r = reconcile(entries, read_witness(args.witness), principal=principal)
|
|
378
|
+
older = [f for f in r["findings"] if f["finding"] == "PREDATES_WITNESS"]
|
|
378
379
|
for f in r["findings"]:
|
|
380
|
+
if f["finding"] == "PREDATES_WITNESS":
|
|
381
|
+
continue
|
|
379
382
|
print(" FINDING %-22s %s" % (f["finding"], " ".join("%s=%s" % (k, str(v)[:40]) for k, v in f.items() if k != "finding")))
|
|
380
|
-
|
|
383
|
+
if older:
|
|
384
|
+
print(" note: %d honored row(s) predate the witness (started %s) and cannot be vouched for either way: seq %s"
|
|
385
|
+
% (len(older), (r.get("horizon") or "?")[:19], ", ".join(str(f["seq"]) for f in older[:12]) + (" ..." if len(older) > 12 else "")))
|
|
386
|
+
hard = len(r["findings"]) - len(older)
|
|
387
|
+
print("reconciled: %d issued by the issuer, %d in evidence, %d finding(s)" % (r["issued"], r["evidence_with_receipt"], hard))
|
|
381
388
|
rc = rc or (0 if (w["accepted"] and r["accepted"]) else 1)
|
|
382
389
|
if args.check_tree:
|
|
383
390
|
# Every file the gate wrote must still be as the gate left it.
|
|
@@ -100,6 +100,7 @@ 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
102
|
ENV_ADAPTER_SPEC = "GARGOYLE_MCP_ADAPTER_SPEC" # with GARGOYLE_MCP_STATE_PROBE=graph: the tool mapping JSON
|
|
103
|
+
ENV_INSTRUCTIONS = "GARGOYLE_MCP_INSTRUCTIONS" # override the standing instructions the gate gives the model (default: gate_instructions())
|
|
103
104
|
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
|
|
104
105
|
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
|
|
105
106
|
|
|
@@ -284,6 +285,26 @@ def _without_gargoyle_meta(params: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
284
285
|
return forwarded
|
|
285
286
|
|
|
286
287
|
|
|
288
|
+
def gate_instructions(target_id: str, upstream_instructions: Optional[str] = None, override: Optional[str] = None) -> str:
|
|
289
|
+
"""The standing instructions an MCP server may hand the model in its
|
|
290
|
+
initialize answer; clients place them in the model's context for the
|
|
291
|
+
session. This is how the gate is mandated rather than named by the
|
|
292
|
+
user each time: the model is told, by the server itself, that all work
|
|
293
|
+
on this target goes through these tools, that a refusal is final, and
|
|
294
|
+
that there is nothing to type. The upstream's own instructions, if
|
|
295
|
+
any, follow the gate's."""
|
|
296
|
+
text = override if override else (
|
|
297
|
+
"Every operation on %s goes through this server's tools; do not use any other tool, built-in "
|
|
298
|
+
"capability or workaround for that target. Reads run at once. A write may pause for a human "
|
|
299
|
+
"approval: wait for the result, it is normal. A refused call is a decision, not an error: report "
|
|
300
|
+
"the reason to the user and stop; do not retry or route around it. For a task with several "
|
|
301
|
+
"writes, gargoyle_plan may ask for one approval covering them; gargoyle_revert undoes an approved "
|
|
302
|
+
"change through the same approval. The user never needs to name this server." % target_id)
|
|
303
|
+
if upstream_instructions:
|
|
304
|
+
text += "\n\n" + upstream_instructions
|
|
305
|
+
return text
|
|
306
|
+
|
|
307
|
+
|
|
287
308
|
def client_label(params: Any) -> Optional[str]:
|
|
288
309
|
""""name version" from an initialize request's clientInfo, or None."""
|
|
289
310
|
info = params.get("clientInfo") if isinstance(params, dict) else None
|
|
@@ -348,6 +369,8 @@ class ServerState:
|
|
|
348
369
|
)
|
|
349
370
|
self.executed: List[Dict[str, Any]] = []
|
|
350
371
|
self.plans: List[Dict[str, Any]] = [] # approved scoped grants: {"plan", "grants", "consumed": set()}
|
|
372
|
+
import os as _os
|
|
373
|
+
self.instructions: Optional[str] = _os.environ.get(ENV_INSTRUCTIONS) or None
|
|
351
374
|
self.initialized = False
|
|
352
375
|
self.client: Optional[str] = None # "name version" from the MCP initialize handshake
|
|
353
376
|
|
|
@@ -576,6 +599,9 @@ def handle_message(state: ServerState, msg: Dict[str, Any]) -> Optional[Dict[str
|
|
|
576
599
|
if method == "initialize":
|
|
577
600
|
state.client = client_label(params)
|
|
578
601
|
reply = _relay(state, msg)
|
|
602
|
+
if method == "initialize" and isinstance(reply, dict) and isinstance(reply.get("result"), dict) and state.config:
|
|
603
|
+
reply["result"]["instructions"] = gate_instructions(state.config.get("target_id", "the gated target"),
|
|
604
|
+
reply["result"].get("instructions"), state.instructions)
|
|
579
605
|
if method == "tools/list" and state.snapshots is not None and isinstance(reply, dict) \
|
|
580
606
|
and isinstance(reply.get("result"), dict) and isinstance(reply["result"].get("tools"), list):
|
|
581
607
|
from gargoyle.revert import REVERT_TOOL_DEF
|
|
@@ -595,6 +621,7 @@ def handle_message(state: ServerState, msg: Dict[str, Any]) -> Optional[Dict[str
|
|
|
595
621
|
"protocolVersion": PROTOCOL_VERSION,
|
|
596
622
|
"capabilities": {"tools": {"listChanged": False}},
|
|
597
623
|
"serverInfo": SERVER_INFO,
|
|
624
|
+
"instructions": gate_instructions((state.config or {}).get("target_id", "the gated target"), None, state.instructions),
|
|
598
625
|
},
|
|
599
626
|
)
|
|
600
627
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
the gated folder against the evidence.
|
|
3
3
|
|
|
4
4
|
Findings, never silently fixed:
|
|
5
|
+
PREDATES_WITNESS evidence from before the witness began; it can be vouched for neither way (information)
|
|
5
6
|
UNWITNESSED_RECEIPT evidence honors a decision the issuer never issued (forged or foreign)
|
|
6
7
|
UNWITNESSED_RELEASE a held call was honored but the issuer saw no grant for its hold (or plan)
|
|
7
8
|
MISSING_EVIDENCE the issuer issued a receipt to this principal and the client recorded nothing
|
|
@@ -33,9 +34,12 @@ def reconcile(evidence: Iterable[Any], witness: Iterable[Any], *, principal: Opt
|
|
|
33
34
|
issued: Dict[str, Dict[str, Any]] = {}
|
|
34
35
|
granted: Dict[str, List[Dict[str, Any]]] = {}
|
|
35
36
|
plan_granted: Dict[str, List[Dict[str, Any]]] = {}
|
|
37
|
+
horizon: Optional[datetime] = None
|
|
36
38
|
for w in witness:
|
|
37
39
|
if not isinstance(w, dict):
|
|
38
40
|
continue
|
|
41
|
+
if horizon is None:
|
|
42
|
+
horizon = _parse(w.get("at"))
|
|
39
43
|
if w.get("event") == "issued" and isinstance(w.get("decision_id"), str):
|
|
40
44
|
issued[w["decision_id"]] = w
|
|
41
45
|
elif w.get("event") == "granted" and isinstance(w.get("approval_request_id"), str):
|
|
@@ -54,7 +58,11 @@ def reconcile(evidence: Iterable[Any], witness: Iterable[Any], *, principal: Opt
|
|
|
54
58
|
w = issued.get(did)
|
|
55
59
|
if e.get("kind") == "honored":
|
|
56
60
|
if w is None:
|
|
57
|
-
|
|
61
|
+
at_e = _parse(e.get("at"))
|
|
62
|
+
if horizon is not None and at_e is not None and at_e < horizon:
|
|
63
|
+
findings.append({"finding": "PREDATES_WITNESS", "seq": e.get("seq"), "decision_id": did, "tool": e.get("tool")})
|
|
64
|
+
else:
|
|
65
|
+
findings.append({"finding": "UNWITNESSED_RECEIPT", "seq": e.get("seq"), "decision_id": did, "tool": e.get("tool")})
|
|
58
66
|
continue
|
|
59
67
|
if w.get("decision_sha256") != receipt.get("decision_sha256") or w.get("principal") != receipt.get("request_context", {}).get("principal"):
|
|
60
68
|
findings.append({"finding": "UNWITNESSED_RECEIPT", "seq": e.get("seq"), "decision_id": did, "tool": e.get("tool")})
|
|
@@ -78,7 +86,8 @@ def reconcile(evidence: Iterable[Any], witness: Iterable[Any], *, principal: Opt
|
|
|
78
86
|
continue
|
|
79
87
|
findings.append({"finding": "MISSING_EVIDENCE", "decision_id": did, "tool": w.get("tool"), "principal": w.get("principal"),
|
|
80
88
|
"held": bool(w.get("held")), "at": w.get("at")})
|
|
81
|
-
|
|
89
|
+
hard = [f for f in findings if f["finding"] != "PREDATES_WITNESS"]
|
|
90
|
+
return {"accepted": not hard, "findings": findings, "horizon": horizon.isoformat() if horizon else None,
|
|
82
91
|
"issued": len(issued), "evidence_with_receipt": len(seen_decisions)}
|
|
83
92
|
|
|
84
93
|
|
|
@@ -168,6 +168,10 @@ def build_remote_app(
|
|
|
168
168
|
upstream_init["serverInfo"] = {"name": "gargoyle+" + str(upstream_init.get("serverInfo", {}).get("name", "upstream")),
|
|
169
169
|
"version": SERVER_INFO["version"]}
|
|
170
170
|
upstream.send({"jsonrpc": "2.0", "method": "notifications/initialized"})
|
|
171
|
+
from gargoyle.mcp_server import gate_instructions
|
|
172
|
+
import os as _os
|
|
173
|
+
upstream_init["instructions"] = gate_instructions(config.get("target_id", "the gated target"), upstream_init.get("instructions"),
|
|
174
|
+
_os.environ.get("GARGOYLE_MCP_INSTRUCTIONS") or None)
|
|
171
175
|
|
|
172
176
|
def new_state(principal: str, issuer_token: str) -> ServerState:
|
|
173
177
|
nonlocal shared_evidence, shared_snapshots
|
|
@@ -64,10 +64,12 @@ class WitnessLog:
|
|
|
64
64
|
self.entries.append(e)
|
|
65
65
|
self._seq, self._tail = int(e["seq"]), e["entry_sha256"]
|
|
66
66
|
elif self.path is not None:
|
|
67
|
-
# Exists from the first start, so an operator can copy it before
|
|
68
|
-
# the first decision and a monitor can see it is in place.
|
|
69
67
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
68
|
self.path.touch()
|
|
69
|
+
# Every start is on record: the first "started" entry is the witness's
|
|
70
|
+
# horizon (evidence older than it cannot be vouched for either way),
|
|
71
|
+
# and later ones mark restarts for the operator.
|
|
72
|
+
self.record("started", resumed=bool(self.entries))
|
|
71
73
|
|
|
72
74
|
def record(self, event: str, **fields: Any) -> Dict[str, Any]:
|
|
73
75
|
with self._lock:
|
{gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gargoyle-receipts
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.15.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.15.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
|
|
@@ -383,6 +383,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
383
383
|
| issuer `GARGOYLE_WITNESS_PATH` | the issuer's signed, hash-chained record of every decision (`gargoyle issuer` sets `witness.jsonl` beside the keys); `gargoyle audit --witness` reconciles a client's evidence against it, `--check-tree DIR` finds files changed outside the gate |
|
|
384
384
|
| issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
|
|
385
385
|
| `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 |
|
|
386
|
+
| `GARGOYLE_MCP_INSTRUCTIONS` | replaces the standing instructions the gate hands the model in the MCP handshake (default: all work on the target goes through these tools, a wait is normal, a refusal is final, plans and undo exist); the user never has to name the server |
|
|
386
387
|
| `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 |
|
|
387
388
|
| `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
|
|
388
389
|
| `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gargoyle-receipts"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.15.0.dev0"
|
|
8
8
|
description = "Signed, single-use, argument-bound receipts and human-approval holds for agent tool calls."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
{gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_mcp_self_issue.py
RENAMED
|
@@ -229,3 +229,27 @@ def test_every_decision_lands_in_the_evidence_log(tmp_path):
|
|
|
229
229
|
approver_public_keys={"approver-alice": approver.public_key()})
|
|
230
230
|
assert v["accepted"] and (v["honored"], v["refused"]) == (1, 2)
|
|
231
231
|
assert [r["reason"] for r in v["entries"]] == [ACCEPT_CHAIN_VERIFIED, ACCEPT_ENTRY_VERIFIED, ACCEPT_ENTRY_VERIFIED]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_the_gate_hands_the_model_standing_instructions(monkeypatch):
|
|
235
|
+
"""The initialize answer carries instructions in both modes, so the user
|
|
236
|
+
never has to name the server; an operator may override the text."""
|
|
237
|
+
from gargoyle.mcp_server import gate_instructions
|
|
238
|
+
client = TestClient(issuer_app())
|
|
239
|
+
state = middleware(client)
|
|
240
|
+
r = handle_message(state, {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})
|
|
241
|
+
text = r["result"]["instructions"]
|
|
242
|
+
assert TARGET in text and "do not use any other tool" in text and "gargoyle_plan" in text and "never needs to name this server" in text
|
|
243
|
+
# Through a real upstream, the upstream's own instructions follow the gate's.
|
|
244
|
+
fixture = str(Path(__file__).parent / "fixtures" / "echo_mcp_server.py")
|
|
245
|
+
up = StdioUpstream([sys.executable, "-I", fixture])
|
|
246
|
+
try:
|
|
247
|
+
state = middleware(client); state.upstream = up
|
|
248
|
+
r = handle_message(state, {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})
|
|
249
|
+
assert r["result"]["instructions"].startswith("Every operation on " + TARGET)
|
|
250
|
+
finally:
|
|
251
|
+
up.close()
|
|
252
|
+
monkeypatch.setenv("GARGOYLE_MCP_INSTRUCTIONS", "House rule: files only.")
|
|
253
|
+
state = middleware(client)
|
|
254
|
+
assert handle_message(state, {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})["result"]["instructions"] == "House rule: files only."
|
|
255
|
+
assert gate_instructions("t", "Upstream says hi").endswith("Upstream says hi")
|
|
@@ -79,6 +79,7 @@ def test_remote_gate_over_stdio_upstream(tmp_path):
|
|
|
79
79
|
assert r.status_code == 200 and r.headers[SESSION_HEADER]
|
|
80
80
|
sid = r.headers[SESSION_HEADER]
|
|
81
81
|
assert r.json()["result"]["serverInfo"]["name"].startswith("gargoyle+")
|
|
82
|
+
assert "do not use any other tool" in r.json()["result"]["instructions"]
|
|
82
83
|
assert rpc(c, alice, {"jsonrpc": "2.0", "method": "notifications/initialized"}, sid).status_code == 202
|
|
83
84
|
# Tools: the upstream's plus undo.
|
|
84
85
|
tools = rpc(c, alice, {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, sid).json()["result"]["tools"]
|
|
@@ -46,12 +46,13 @@ def test_witness_entries_are_signed_and_chained(tmp_path):
|
|
|
46
46
|
again.record("declined", approval_request_id="r-2")
|
|
47
47
|
entries = read_witness(str(tmp_path / "w.jsonl"))
|
|
48
48
|
v = verify_witness(entries, issuer_public_keys=ISSUER_KEYS)
|
|
49
|
-
assert v["accepted"] and v["count"] ==
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
assert v["accepted"] and v["count"] == 5 and [e["event"] for e in entries] == ["started", "issued", "granted", "started", "declined"]
|
|
50
|
+
assert entries[3]["resumed"] is True and entries[0]["resumed"] is False
|
|
51
|
+
forged = dict(entries[2], approver="mallory")
|
|
52
|
+
assert verify_witness(entries[:2] + [forged] + entries[3:], issuer_public_keys=ISSUER_KEYS)["entries"][2]["reason"] == REF_WITNESS_HASH_MISMATCH
|
|
52
53
|
from gargoyle.witness import entry_sha256
|
|
53
54
|
forged["entry_sha256"] = entry_sha256(forged) # rehashed, but the issuer did not sign it
|
|
54
|
-
assert verify_witness(
|
|
55
|
+
assert verify_witness(entries[:2] + [forged], issuer_public_keys=ISSUER_KEYS)["entries"][2]["reason"] == REF_WITNESS_SIGNATURE_INVALID
|
|
55
56
|
assert not verify_witness(entries, issuer_public_keys={"other": executor.public_key()})["accepted"]
|
|
56
57
|
|
|
57
58
|
|
|
@@ -95,14 +96,18 @@ def test_honest_evidence_reconciles_and_tampering_is_found(tmp_path):
|
|
|
95
96
|
finally:
|
|
96
97
|
up.close()
|
|
97
98
|
evidence = read_log(str(tmp_path / "audit.jsonl")); witness = read_witness(str(tmp_path / "witness.jsonl"))
|
|
98
|
-
assert [e["event"] for e in witness] == ["issued", "issued", "granted"]
|
|
99
|
+
assert [e["event"] for e in witness] == ["started", "issued", "issued", "granted"]
|
|
99
100
|
assert evidence[1]["ref"] == str(root / "n.md") and evidence[1]["evidence_version"] == "gargoyle-evidence/4"
|
|
100
101
|
assert verify_log(evidence, issuer_public_keys=ISSUER_KEYS, executor_public_keys={"executor-1": executor.public_key()},
|
|
101
102
|
approver_public_keys={"approver-1": approver.public_key()})["accepted"]
|
|
102
103
|
r = reconcile(evidence, witness, principal=PRINCIPAL)
|
|
103
104
|
assert r["accepted"] and r["issued"] == 2 and r["evidence_with_receipt"] == 2
|
|
104
105
|
# The witness is served to an authenticated caller too.
|
|
105
|
-
assert len(c.get("/witness", headers=h).json()["entries"]) ==
|
|
106
|
+
assert len(c.get("/witness", headers=h).json()["entries"]) == 4
|
|
107
|
+
# Evidence from before the witness began is information, not a finding.
|
|
108
|
+
old = dict(evidence[0], at="2026-01-01T00:00:00+00:00", receipt=dict(evidence[0]["receipt"], decision_id="before-the-witness"))
|
|
109
|
+
r = reconcile(evidence + [old], witness, principal=PRINCIPAL) # beside the real rows, not in place of one
|
|
110
|
+
assert r["accepted"] and [f["finding"] for f in r["findings"]] == ["PREDATES_WITNESS"] and r["horizon"]
|
|
106
111
|
|
|
107
112
|
# 1. A forged honored entry for a decision the issuer never issued: the user holds the executor key, so it verifies alone.
|
|
108
113
|
now = datetime.now(timezone.utc)
|
|
@@ -145,7 +150,7 @@ def test_audit_command_reports_reconciliation_and_tree(tmp_path, capsys):
|
|
|
145
150
|
assert main(["audit", "--log", str(tmp_path / "audit.jsonl"), "--out", str(out), "--witness", str(tmp_path / "witness.jsonl"),
|
|
146
151
|
"--check-tree", str(root)]) == 0
|
|
147
152
|
text = capsys.readouterr().out
|
|
148
|
-
assert "witness:
|
|
153
|
+
assert "witness: 3 entries, WITNESS_VERIFIED" in text and "0 finding(s)" in text and "1 gated file(s) checked" in text
|
|
149
154
|
(root / "n.md").write_text("tampered", encoding="utf-8")
|
|
150
155
|
assert main(["audit", "--log", str(tmp_path / "audit.jsonl"), "--out", str(out), "--check-tree", str(root)]) == 1
|
|
151
156
|
assert "CHANGED_OUTSIDE_GATE" in capsys.readouterr().out
|
|
@@ -162,4 +167,4 @@ def test_audit_names_a_missing_witness_or_folder_plainly(tmp_path, capsys):
|
|
|
162
167
|
assert "not a directory" in capsys.readouterr().err
|
|
163
168
|
# The issuer creates the witness file at start, before any decision.
|
|
164
169
|
w = WitnessLog(str(tmp_path / "fresh" / "witness.jsonl"), issuer, "issuer-1")
|
|
165
|
-
assert (tmp_path / "fresh" / "witness.jsonl").exists() and w.entries == []
|
|
170
|
+
assert (tmp_path / "fresh" / "witness.jsonl").exists() and [e["event"] for e in w.entries] == ["started"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/key_record_source.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/published_record_source.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/specs/graph-reference.json
RENAMED
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/gargoyle/static/approve.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_entra_identity.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_issuer_service.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_published_record.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_stdio_encoding.py
RENAMED
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.14.1.dev0 → gargoyle_receipts-0.15.0.dev0}/tests/test_typescript_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|