gargoyle-receipts 0.16.2.dev0__tar.gz → 0.16.4.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.16.2.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.16.4.dev0}/PKG-INFO +12 -4
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/README.md +11 -3
- gargoyle_receipts-0.16.4.dev0/gargoyle/__main__.py +9 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/approval.py +19 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/approvers.py +48 -3
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/cli.py +65 -8
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/mcp_server.py +8 -6
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0/gargoyle_receipts.egg-info}/PKG-INFO +12 -4
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +1 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/pyproject.toml +1 -1
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_approvers.py +36 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_cli.py +36 -1
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/adapters.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/consumer.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/envelope.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/evidence.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/execution.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/holdstore.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/identity.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/issuer_service.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/plans.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/policy.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/published_record_source.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/reconcile.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/remote.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/replay_cache.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/revert.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/rules.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/specs/graph-reference.json +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/state.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/static/approve.html +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/static/approve.js +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/verifier.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/witness.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_console_js.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_entra_identity.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_evidence.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_graph.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_historical.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_issuer_service.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_mcp_self_issue.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_plans.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_remote.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_revert.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_rules.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_state.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_threshold.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_wire_spec.py +0 -0
- {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_witness.py +0 -0
{gargoyle_receipts-0.16.2.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.16.4.dev0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gargoyle-receipts
|
|
3
|
-
Version: 0.16.
|
|
3
|
+
Version: 0.16.4.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.16.
|
|
62
|
+
Status: pre-release (`0.16.4.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
|
|
@@ -278,9 +278,14 @@ chat channel can approve anything.
|
|
|
278
278
|
|
|
279
279
|
pip install gargoyle-receipts
|
|
280
280
|
|
|
281
|
-
Three commands, no policy engine, no containers, no client changes
|
|
281
|
+
Three commands, no policy engine, no containers, no client changes
|
|
282
|
+
(`--observe` starts a deployment in record-only mode, every call allowed
|
|
283
|
+
including destructive ones and every call recorded; `gargoyle audit
|
|
284
|
+
--summary` then shows calls by principal and tool, the input for the
|
|
285
|
+
hold and deny rules):
|
|
282
286
|
|
|
283
287
|
gargoyle init /path/the/agent/may/touch # keys, config, rules.json
|
|
288
|
+
gargoyle init --observe /path/... # or: allow and record everything, hold nothing, tighten later
|
|
284
289
|
gargoyle issuer # the issuing service, on rules.json
|
|
285
290
|
gargoyle approve --watch 2 # where the approver sits (terminal)
|
|
286
291
|
|
|
@@ -298,7 +303,10 @@ stay on file so earlier grants still audit; new ones are refused at the
|
|
|
298
303
|
issuer and at every middleware on its next held call. Revocation retires
|
|
299
304
|
a key, not a person: while the Approver role stands, the person can sign
|
|
300
305
|
in and enroll a fresh key, so remove the role too; the revoked key itself
|
|
301
|
-
is never accepted again.
|
|
306
|
+
is never accepted again. Every key an approver held stays in the file
|
|
307
|
+
(`history`), so an audit of grants released under an earlier key still
|
|
308
|
+
verifies: copy the issuer's current `approvers.json` beside the evidence
|
|
309
|
+
before auditing. Approvers, and
|
|
302
310
|
only approvers, see the whole queue and the witness; everyone else sees
|
|
303
311
|
their own holds. Then paste the server entry from
|
|
304
312
|
`gargoyle-deploy/mcp-config.json` into your MCP client.
|
|
@@ -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.16.
|
|
24
|
+
Status: pre-release (`0.16.4.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
|
|
@@ -240,9 +240,14 @@ chat channel can approve anything.
|
|
|
240
240
|
|
|
241
241
|
pip install gargoyle-receipts
|
|
242
242
|
|
|
243
|
-
Three commands, no policy engine, no containers, no client changes
|
|
243
|
+
Three commands, no policy engine, no containers, no client changes
|
|
244
|
+
(`--observe` starts a deployment in record-only mode, every call allowed
|
|
245
|
+
including destructive ones and every call recorded; `gargoyle audit
|
|
246
|
+
--summary` then shows calls by principal and tool, the input for the
|
|
247
|
+
hold and deny rules):
|
|
244
248
|
|
|
245
249
|
gargoyle init /path/the/agent/may/touch # keys, config, rules.json
|
|
250
|
+
gargoyle init --observe /path/... # or: allow and record everything, hold nothing, tighten later
|
|
246
251
|
gargoyle issuer # the issuing service, on rules.json
|
|
247
252
|
gargoyle approve --watch 2 # where the approver sits (terminal)
|
|
248
253
|
|
|
@@ -260,7 +265,10 @@ stay on file so earlier grants still audit; new ones are refused at the
|
|
|
260
265
|
issuer and at every middleware on its next held call. Revocation retires
|
|
261
266
|
a key, not a person: while the Approver role stands, the person can sign
|
|
262
267
|
in and enroll a fresh key, so remove the role too; the revoked key itself
|
|
263
|
-
is never accepted again.
|
|
268
|
+
is never accepted again. Every key an approver held stays in the file
|
|
269
|
+
(`history`), so an audit of grants released under an earlier key still
|
|
270
|
+
verifies: copy the issuer's current `approvers.json` beside the evidence
|
|
271
|
+
before auditing. Approvers, and
|
|
264
272
|
only approvers, see the whole queue and the witness; everyone else sees
|
|
265
273
|
their own holds. Then paste the server entry from
|
|
266
274
|
`gargoyle-deploy/mcp-config.json` into your MCP client.
|
|
@@ -32,6 +32,21 @@ _SIGNATURE_EXCLUDED_KEYS = ("approver_signature",)
|
|
|
32
32
|
|
|
33
33
|
# Refusal codes.
|
|
34
34
|
REF_APPROVAL_MALFORMED = "REF_APPROVAL_MALFORMED"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _AnyOf:
|
|
38
|
+
"""A verifying key made of several: the signature is valid if any verifies it."""
|
|
39
|
+
def __init__(self, keys: list) -> None:
|
|
40
|
+
self.keys = keys
|
|
41
|
+
|
|
42
|
+
def verify(self, signature: bytes, message: bytes) -> None:
|
|
43
|
+
for key in self.keys[:-1]:
|
|
44
|
+
try:
|
|
45
|
+
key.verify(signature, message)
|
|
46
|
+
return
|
|
47
|
+
except Exception:
|
|
48
|
+
continue
|
|
49
|
+
self.keys[-1].verify(signature, message)
|
|
35
50
|
REF_APPROVAL_MISSING_GRANT_ID = "REF_APPROVAL_MISSING_GRANT_ID"
|
|
36
51
|
REF_APPROVAL_SIGNATURE_INVALID = "REF_APPROVAL_SIGNATURE_INVALID"
|
|
37
52
|
REF_APPROVAL_KEY_UNKNOWN = "REF_APPROVAL_KEY_UNKNOWN"
|
|
@@ -178,6 +193,10 @@ def verify_grant(
|
|
|
178
193
|
|
|
179
194
|
# 4. known approver key
|
|
180
195
|
public_key = approver_public_keys.get(key_id)
|
|
196
|
+
if isinstance(public_key, (list, tuple)):
|
|
197
|
+
# Several keys for one approver id: an audit of grants from before the
|
|
198
|
+
# approver's browser key was replaced or revoked. Any of them may verify.
|
|
199
|
+
public_key = _AnyOf(list(public_key)) if public_key else None
|
|
181
200
|
if public_key is None:
|
|
182
201
|
return _reject(REF_APPROVAL_KEY_UNKNOWN)
|
|
183
202
|
|
|
@@ -26,7 +26,7 @@ import stat
|
|
|
26
26
|
import threading
|
|
27
27
|
from datetime import datetime, timezone
|
|
28
28
|
from pathlib import Path
|
|
29
|
-
from typing import Any, Dict, Optional
|
|
29
|
+
from typing import List, Any, Dict, Optional
|
|
30
30
|
|
|
31
31
|
ENV_APPROVERS_PATH = "GARGOYLE_APPROVERS_PATH"
|
|
32
32
|
ENV_APPROVER_ROLE = "GARGOYLE_APPROVER_ROLE" # Entra app role an enrolling token must carry (default Approver)
|
|
@@ -53,6 +53,7 @@ class ApproverRegistry:
|
|
|
53
53
|
self._lock = threading.Lock()
|
|
54
54
|
self._approvers: Dict[str, Dict[str, Any]] = {}
|
|
55
55
|
self._revoked: Dict[str, Dict[str, Any]] = {} # former approvers, kept so old grants still verify in audits
|
|
56
|
+
self._history: Dict[str, List[Dict[str, Any]]] = {} # every key each UPN held and how it ended (replaced, revoked)
|
|
56
57
|
self._mtime: Optional[float] = None
|
|
57
58
|
self._load()
|
|
58
59
|
|
|
@@ -67,7 +68,16 @@ class ApproverRegistry:
|
|
|
67
68
|
for upn, entry in (doc.get("revoked") or {}).items():
|
|
68
69
|
if isinstance(entry, dict) and valid_public_key_hex(entry.get("public_key_hex")):
|
|
69
70
|
revoked[upn] = dict(entry)
|
|
70
|
-
|
|
71
|
+
history: Dict[str, List[Dict[str, Any]]] = {}
|
|
72
|
+
for upn, items in (doc.get("history") or {}).items():
|
|
73
|
+
if isinstance(items, list):
|
|
74
|
+
history[upn] = [dict(h) for h in items if isinstance(h, dict) and valid_public_key_hex(h.get("public_key_hex"))]
|
|
75
|
+
for upn, e in revoked.items():
|
|
76
|
+
# A file from before history existed: its revoked record is the one former key known.
|
|
77
|
+
if not any(h.get("public_key_hex") == e["public_key_hex"] for h in history.get(upn, [])):
|
|
78
|
+
history.setdefault(upn, []).append({**{k: v for k, v in e.items() if k not in ("revoked_at", "revoked_by")},
|
|
79
|
+
"ended_at": e.get("revoked_at"), "ended_by": e.get("revoked_by"), "reason": "revoked"})
|
|
80
|
+
self._approvers, self._revoked, self._history = approvers, revoked, history
|
|
71
81
|
self._mtime = self.path.stat().st_mtime
|
|
72
82
|
|
|
73
83
|
def _refresh(self) -> None:
|
|
@@ -89,6 +99,8 @@ class ApproverRegistry:
|
|
|
89
99
|
doc = {"approvers": self._approvers}
|
|
90
100
|
if self._revoked:
|
|
91
101
|
doc["revoked"] = self._revoked
|
|
102
|
+
if self._history:
|
|
103
|
+
doc["history"] = self._history
|
|
92
104
|
self.path.write_text(json.dumps(doc, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
93
105
|
try:
|
|
94
106
|
os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
|
|
@@ -120,13 +132,45 @@ class ApproverRegistry:
|
|
|
120
132
|
entry["tid"] = tid
|
|
121
133
|
with self._lock:
|
|
122
134
|
self._refresh()
|
|
123
|
-
if
|
|
135
|
+
if public_key_hex in self._revoked_keys():
|
|
124
136
|
raise RevokedKeyError("that key was revoked; enroll a new one")
|
|
125
137
|
replaced = upn in self._approvers
|
|
138
|
+
if replaced and self._approvers[upn].get("public_key_hex") != public_key_hex:
|
|
139
|
+
# The superseded key is kept: grants signed under it still audit.
|
|
140
|
+
self._remember(upn, self._approvers[upn], "replaced", enrolled_by or upn, now)
|
|
126
141
|
self._approvers[upn] = entry
|
|
127
142
|
self._save()
|
|
128
143
|
return {"upn": upn, "replaced": replaced, **entry}
|
|
129
144
|
|
|
145
|
+
def _remember(self, upn: str, entry: Dict[str, Any], reason: str, ended_by: str, now: Optional[datetime]) -> None:
|
|
146
|
+
hist = self._history.setdefault(upn, [])
|
|
147
|
+
if not any(h.get("public_key_hex") == entry.get("public_key_hex") for h in hist):
|
|
148
|
+
hist.append({**{k: v for k, v in entry.items() if k not in ("revoked_at", "revoked_by")},
|
|
149
|
+
"ended_at": (now or datetime.now(timezone.utc)).isoformat(), "ended_by": ended_by, "reason": reason})
|
|
150
|
+
|
|
151
|
+
def _revoked_keys(self) -> set:
|
|
152
|
+
"""Every public key ever revoked, under any UPN: none may enroll again."""
|
|
153
|
+
return ({e.get("public_key_hex") for e in self._revoked.values()}
|
|
154
|
+
| {h.get("public_key_hex") for hist in self._history.values() for h in hist if h.get("reason") == "revoked"})
|
|
155
|
+
|
|
156
|
+
def audit_keys_hex(self) -> Dict[str, List[str]]:
|
|
157
|
+
"""{upn: [public_key_hex, ...]}: the current key first, then every key
|
|
158
|
+
the UPN held before. An audit verifies a grant with any of them; the
|
|
159
|
+
live service and middleware trust only the current one."""
|
|
160
|
+
with self._lock:
|
|
161
|
+
self._refresh()
|
|
162
|
+
out: Dict[str, List[str]] = {}
|
|
163
|
+
for upn, e in self._approvers.items():
|
|
164
|
+
out[upn] = [e["public_key_hex"]]
|
|
165
|
+
for upn, hist in self._history.items():
|
|
166
|
+
for h in hist:
|
|
167
|
+
if h["public_key_hex"] not in out.setdefault(upn, []):
|
|
168
|
+
out[upn].append(h["public_key_hex"])
|
|
169
|
+
for upn, e in self._revoked.items():
|
|
170
|
+
if e["public_key_hex"] not in out.setdefault(upn, []):
|
|
171
|
+
out[upn].append(e["public_key_hex"])
|
|
172
|
+
return out
|
|
173
|
+
|
|
130
174
|
def revoke(self, upn: str, *, revoked_by: Optional[str] = None, now: Optional[datetime] = None) -> bool:
|
|
131
175
|
"""Stop trusting `upn`'s key from now on. The entry moves to the
|
|
132
176
|
revoked list with when and by whom, so an audit of earlier evidence
|
|
@@ -139,6 +183,7 @@ class ApproverRegistry:
|
|
|
139
183
|
return False
|
|
140
184
|
self._revoked[upn] = {**entry, "revoked_at": (now or datetime.now(timezone.utc)).isoformat(),
|
|
141
185
|
"revoked_by": revoked_by or upn}
|
|
186
|
+
self._remember(upn, entry, "revoked", revoked_by or upn, now)
|
|
142
187
|
self._save()
|
|
143
188
|
return True
|
|
144
189
|
|
|
@@ -39,6 +39,7 @@ the response metadata; `gargoyle audit` verifies them later, offline.
|
|
|
39
39
|
|
|
40
40
|
import argparse
|
|
41
41
|
import json
|
|
42
|
+
from typing import Any, Dict
|
|
42
43
|
import os
|
|
43
44
|
import re
|
|
44
45
|
import stat
|
|
@@ -50,6 +51,17 @@ from pathlib import Path
|
|
|
50
51
|
|
|
51
52
|
PRINCIPAL = "spiffe://gargoyle.local/agents/assistant"
|
|
52
53
|
|
|
54
|
+
OBSERVE_RULES = {
|
|
55
|
+
# Observe mode: every call allowed (deletes too), every call recorded, none held. The
|
|
56
|
+
# first week of a deployment, so the hold and deny rules are written
|
|
57
|
+
# from the audit rather than guessed. The policy pin changes when the
|
|
58
|
+
# rules do, so switching modes means restarting the issuer and copying
|
|
59
|
+
# the new record.json to each client.
|
|
60
|
+
"version": "1",
|
|
61
|
+
"default": "allow",
|
|
62
|
+
"rules": [],
|
|
63
|
+
}
|
|
64
|
+
|
|
53
65
|
DEFAULT_RULES = {
|
|
54
66
|
"version": "1",
|
|
55
67
|
"default": "deny",
|
|
@@ -117,7 +129,22 @@ def cmd_init(args) -> int:
|
|
|
117
129
|
issuer_url = args.issuer_url or "http://%s:%d" % (args.host, args.port)
|
|
118
130
|
|
|
119
131
|
rules = out / "rules.json"
|
|
120
|
-
if
|
|
132
|
+
if args.observe:
|
|
133
|
+
current = None
|
|
134
|
+
if rules.exists():
|
|
135
|
+
try:
|
|
136
|
+
current = json.loads(rules.read_text())
|
|
137
|
+
except ValueError as exc:
|
|
138
|
+
print("%s is not valid JSON (%s); fix or remove it before --observe" % (rules, exc), file=sys.stderr)
|
|
139
|
+
return 2
|
|
140
|
+
if current is None or current == DEFAULT_RULES:
|
|
141
|
+
_write(rules, json.dumps(OBSERVE_RULES, indent=2) + "\n")
|
|
142
|
+
elif current != OBSERVE_RULES:
|
|
143
|
+
print("rules.json was edited by hand and is left as it is; --observe changed nothing", file=sys.stderr)
|
|
144
|
+
print("observe mode: rules.json allows every call, destructive ones included; the gate records all of "
|
|
145
|
+
"them and holds none. Tighten rules.json from `gargoyle audit --summary`, then restart the issuer "
|
|
146
|
+
"and copy the new record.json to the clients.")
|
|
147
|
+
elif not rules.exists():
|
|
121
148
|
_write(rules, json.dumps(DEFAULT_RULES, indent=2) + "\n")
|
|
122
149
|
_write(out / "issuer.env", _env_lines({
|
|
123
150
|
"GARGOYLE_ISSUER_KEY_ID": "issuer-1", "GARGOYLE_ISSUER_KEY_HEX": issuer_hex,
|
|
@@ -359,20 +386,45 @@ def cmd_approvers(args) -> int:
|
|
|
359
386
|
return 1
|
|
360
387
|
|
|
361
388
|
|
|
389
|
+
def _audit_summary(report, raw) -> None:
|
|
390
|
+
"""Calls by principal and tool with their outcomes, then who released
|
|
391
|
+
and which clients: a week of observe mode as a table, the input for
|
|
392
|
+
writing the hold and deny rules."""
|
|
393
|
+
from collections import Counter
|
|
394
|
+
counts: Dict[tuple, Counter] = {}
|
|
395
|
+
releases: Counter = Counter()
|
|
396
|
+
clients: Counter = Counter()
|
|
397
|
+
ats = [e.get("at") for e in raw if isinstance(e, dict) and isinstance(e.get("at"), str)]
|
|
398
|
+
for r in report:
|
|
399
|
+
counts.setdefault((r["principal"] or "?", r["tool"] or "?"), Counter())[r["kind"] or "?"] += 1
|
|
400
|
+
for who in r.get("released_by") or []:
|
|
401
|
+
releases[who] += 1
|
|
402
|
+
clients[r.get("client") or "(client unnamed)"] += 1
|
|
403
|
+
print("summary: %d call(s)%s" % (len(report), (", %s to %s" % (min(ats)[:19], max(ats)[:19])) if ats else ""))
|
|
404
|
+
print(" %-28s %-24s %7s %7s" % ("principal", "tool", "honored", "refused"))
|
|
405
|
+
for (principal, tool), c in sorted(counts.items(), key=lambda kv: (kv[0][0], -sum(kv[1].values()), kv[0][1])):
|
|
406
|
+
print(" %-28s %-24s %7d %7d" % (principal[:28], tool[:24], c.get("honored", 0), c.get("refused", 0)))
|
|
407
|
+
if releases:
|
|
408
|
+
print(" releases: " + ", ".join("%s %d" % (w, n) for w, n in releases.most_common()))
|
|
409
|
+
print(" clients: " + ", ".join("%s %d" % (w, n) for w, n in clients.most_common()))
|
|
410
|
+
|
|
411
|
+
|
|
362
412
|
def cmd_audit(args) -> int:
|
|
363
413
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
364
414
|
from gargoyle.execution import verify_chain
|
|
365
415
|
|
|
366
416
|
keys = json.loads((Path(args.out).resolve() / "public-keys.json").read_text())
|
|
367
|
-
|
|
368
|
-
|
|
417
|
+
one = lambda h: Ed25519PublicKey.from_public_bytes(bytes.fromhex(h))
|
|
418
|
+
load = lambda m: {k: ([one(x) for x in v] if isinstance(v, list) else one(v)) for k, v in m.items()}
|
|
419
|
+
approvers: Dict[str, Any] = dict(keys["approver"])
|
|
369
420
|
enrolled = Path(args.out).resolve() / "approvers.json" # approvers who enrolled through Entra, by UPN
|
|
370
421
|
if enrolled.exists():
|
|
371
|
-
|
|
372
|
-
#
|
|
373
|
-
#
|
|
374
|
-
|
|
375
|
-
|
|
422
|
+
from gargoyle.approvers import ApproverRegistry
|
|
423
|
+
# Every key each approver ever held, current first: grants signed
|
|
424
|
+
# under a key since replaced in the browser or revoked still have to
|
|
425
|
+
# verify. Only the live service and middleware are limited to the
|
|
426
|
+
# current key. Copy the issuer's file beside the evidence first.
|
|
427
|
+
approvers.update(ApproverRegistry(str(enrolled)).audit_keys_hex())
|
|
376
428
|
trust = dict(issuer_public_keys=load(keys["issuer"]), executor_public_keys=load(keys["executor"]),
|
|
377
429
|
approver_public_keys=load(approvers))
|
|
378
430
|
if args.log:
|
|
@@ -403,6 +455,8 @@ def cmd_audit(args) -> int:
|
|
|
403
455
|
% (v["count"], v["honored"], v["refused"], failed, v["reason"], v["tail_sha256"][:16]))
|
|
404
456
|
rc = 0 if v["accepted"] else 1
|
|
405
457
|
entries = read_log(args.log)
|
|
458
|
+
if args.summary:
|
|
459
|
+
_audit_summary(v["entries"], entries)
|
|
406
460
|
if args.witness and not Path(args.witness).exists():
|
|
407
461
|
print("witness file not found: %s (copy the issuer's witness.jsonl here first)" % args.witness, file=sys.stderr)
|
|
408
462
|
return 2
|
|
@@ -457,6 +511,8 @@ def main(argv=None) -> int:
|
|
|
457
511
|
p.add_argument("--issuer-url", help="URL the client reaches the issuer at (default http://HOST:PORT)")
|
|
458
512
|
p.add_argument("--client-python", help="the client's Python executable for the MCP entry (default: this one)")
|
|
459
513
|
p.add_argument("--client-config-dir", help="where the client keeps middleware.env, record.json, audit.jsonl (default: --out)")
|
|
514
|
+
p.add_argument("--observe", action="store_true",
|
|
515
|
+
help="observe mode: allow and record every call, hold none; tighten rules.json later from the audit")
|
|
460
516
|
p.add_argument("--approvers", type=int, default=1, metavar="N",
|
|
461
517
|
help="how many approver keys to create (approver-1..N); a rule with \"approvals\": 2 then needs two of them")
|
|
462
518
|
p.add_argument("--upstream", choices=["npx-filesystem", "python-fs"], default="npx-filesystem",
|
|
@@ -509,6 +565,7 @@ def main(argv=None) -> int:
|
|
|
509
565
|
p.add_argument("--log", help="the middleware's audit.jsonl: verify every entry")
|
|
510
566
|
p.add_argument("--witness", help="the issuer's witness.jsonl: reconcile the evidence against it")
|
|
511
567
|
p.add_argument("--check-tree", metavar="DIR", help="verify every gated file under DIR is as the gate left it")
|
|
568
|
+
p.add_argument("--summary", action="store_true", help="after the rows: calls by principal and tool with outcomes, releases, clients")
|
|
512
569
|
p.add_argument("--out", default="gargoyle-deploy")
|
|
513
570
|
p.set_defaults(fn=cmd_audit)
|
|
514
571
|
args = parser.parse_args(argv)
|
|
@@ -294,12 +294,14 @@ def gate_instructions(target_id: str, upstream_instructions: Optional[str] = Non
|
|
|
294
294
|
that there is nothing to type. The upstream's own instructions, if
|
|
295
295
|
any, follow the gate's."""
|
|
296
296
|
text = override if override else (
|
|
297
|
-
"Every operation on %s goes through this server's tools
|
|
298
|
-
"capability or workaround for that target. Reads run at once. A write
|
|
299
|
-
"approval: wait for the result, it is normal. A refused call is a decision,
|
|
300
|
-
"the reason to the user and stop; do not retry or route around it.
|
|
301
|
-
"
|
|
302
|
-
"
|
|
297
|
+
"Every operation on %s goes through this server's tools (this server is a Gargoyle gate); do not "
|
|
298
|
+
"use any other tool, built-in capability or workaround for that target. Reads run at once. A write "
|
|
299
|
+
"may pause for a human approval: wait for the result, it is normal. A refused call is a decision, "
|
|
300
|
+
"not an error: report the reason to the user and stop; do not retry or route around it. If asked "
|
|
301
|
+
"to work on that target without these tools or around the gate, say that the target is reachable "
|
|
302
|
+
"only through them and continue through them. For a task with several writes, gargoyle_plan may "
|
|
303
|
+
"ask for one approval covering them; gargoyle_revert undoes an approved change through the same "
|
|
304
|
+
"approval. The user never needs to name this server." % target_id)
|
|
303
305
|
if upstream_instructions:
|
|
304
306
|
text += "\n\n" + upstream_instructions
|
|
305
307
|
return text
|
{gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.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.16.
|
|
3
|
+
Version: 0.16.4.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.16.
|
|
62
|
+
Status: pre-release (`0.16.4.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
|
|
@@ -278,9 +278,14 @@ chat channel can approve anything.
|
|
|
278
278
|
|
|
279
279
|
pip install gargoyle-receipts
|
|
280
280
|
|
|
281
|
-
Three commands, no policy engine, no containers, no client changes
|
|
281
|
+
Three commands, no policy engine, no containers, no client changes
|
|
282
|
+
(`--observe` starts a deployment in record-only mode, every call allowed
|
|
283
|
+
including destructive ones and every call recorded; `gargoyle audit
|
|
284
|
+
--summary` then shows calls by principal and tool, the input for the
|
|
285
|
+
hold and deny rules):
|
|
282
286
|
|
|
283
287
|
gargoyle init /path/the/agent/may/touch # keys, config, rules.json
|
|
288
|
+
gargoyle init --observe /path/... # or: allow and record everything, hold nothing, tighten later
|
|
284
289
|
gargoyle issuer # the issuing service, on rules.json
|
|
285
290
|
gargoyle approve --watch 2 # where the approver sits (terminal)
|
|
286
291
|
|
|
@@ -298,7 +303,10 @@ stay on file so earlier grants still audit; new ones are refused at the
|
|
|
298
303
|
issuer and at every middleware on its next held call. Revocation retires
|
|
299
304
|
a key, not a person: while the Approver role stands, the person can sign
|
|
300
305
|
in and enroll a fresh key, so remove the role too; the revoked key itself
|
|
301
|
-
is never accepted again.
|
|
306
|
+
is never accepted again. Every key an approver held stays in the file
|
|
307
|
+
(`history`), so an audit of grants released under an earlier key still
|
|
308
|
+
verifies: copy the issuer's current `approvers.json` beside the evidence
|
|
309
|
+
before auditing. Approvers, and
|
|
302
310
|
only approvers, see the whole queue and the witness; everyone else sees
|
|
303
311
|
their own holds. Then paste the server entry from
|
|
304
312
|
`gargoyle-deploy/mcp-config.json` into your MCP client.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gargoyle-receipts"
|
|
7
|
-
version = "0.16.
|
|
7
|
+
version = "0.16.4.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"
|
|
@@ -204,3 +204,39 @@ def test_an_operators_revocation_on_the_host_reaches_the_witness(tmp_path):
|
|
|
204
204
|
c.get("/approvers")
|
|
205
205
|
revoked = [e for e in events if e["event"] == "revoked"]
|
|
206
206
|
assert [(e["approver"], e["by"]) for e in revoked] == [("dana@contoso.com", "operator:root"), ("lee@contoso.com", "lee@contoso.com")]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_every_key_an_approver_held_stays_for_audits(tmp_path):
|
|
210
|
+
"""The client audit of 2026-10-20 failed a row released under a key that a
|
|
211
|
+
later browser enrollment had replaced: the registry had dropped it. Now
|
|
212
|
+
every key a UPN held is kept (history: replaced or revoked, when, by whom),
|
|
213
|
+
the audit map carries them all, and a grant verifies with any of them."""
|
|
214
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
215
|
+
from gargoyle.approval import ACCEPT_GRANT_VALID, build_grant, sign_grant, verify_grant
|
|
216
|
+
from gargoyle.approvers import RevokedKeyError
|
|
217
|
+
path = tmp_path / "a.json"
|
|
218
|
+
reg = ApproverRegistry(str(path))
|
|
219
|
+
k1, k2, k3 = (Ed25519PrivateKey.generate() for _ in range(3))
|
|
220
|
+
reg.enroll("dana@contoso.com", pub_hex(k1))
|
|
221
|
+
assert reg.enroll("dana@contoso.com", pub_hex(k2))["replaced"] is True # a new browser
|
|
222
|
+
assert reg.revoke("dana@contoso.com", revoked_by="operator:root")
|
|
223
|
+
reg.enroll("dana@contoso.com", pub_hex(k3))
|
|
224
|
+
assert reg.audit_keys_hex() == {"dana@contoso.com": [pub_hex(k3), pub_hex(k1), pub_hex(k2)]}
|
|
225
|
+
doc = json.loads(path.read_text())
|
|
226
|
+
assert [(h["reason"], h["ended_by"]) for h in doc["history"]["dana@contoso.com"]] == [("replaced", "dana@contoso.com"), ("revoked", "operator:root")]
|
|
227
|
+
now = datetime.now(timezone.utc)
|
|
228
|
+
keys = {u: [Ed25519PublicKey.from_public_bytes(bytes.fromhex(x)) for x in hs] for u, hs in reg.audit_keys_hex().items()}
|
|
229
|
+
for k in (k1, k2, k3):
|
|
230
|
+
g = sign_grant(build_grant(decision_sha256="a" * 64, approval_request_id="r", grant_id="g", not_after=now + timedelta(minutes=5)), k, "dana@contoso.com")
|
|
231
|
+
v = verify_grant(g, expected_decision_sha256="a" * 64, expected_approval_request_id="r", approver_public_keys=keys, gate_key_id="issuer-1", now=now)
|
|
232
|
+
assert v["accepted"] is True and v["reason"] == ACCEPT_GRANT_VALID
|
|
233
|
+
stranger = sign_grant(build_grant(decision_sha256="a" * 64, approval_request_id="r", grant_id="g", not_after=now + timedelta(minutes=5)),
|
|
234
|
+
Ed25519PrivateKey.generate(), "dana@contoso.com")
|
|
235
|
+
assert verify_grant(stranger, expected_decision_sha256="a" * 64, expected_approval_request_id="r", approver_public_keys=keys, gate_key_id="issuer-1", now=now)["accepted"] is False
|
|
236
|
+
# The revoked key k2 is refused for anyone, from history, even after Dana's revoked record is gone.
|
|
237
|
+
with pytest.raises(RevokedKeyError):
|
|
238
|
+
reg.enroll("lee@contoso.com", pub_hex(k2))
|
|
239
|
+
# A file from before history existed: its revoked record seeds the history.
|
|
240
|
+
old = tmp_path / "old.json"
|
|
241
|
+
old.write_text(json.dumps({"approvers": {}, "revoked": {"x@contoso.com": {"public_key_hex": pub_hex(k1), "revoked_at": "t", "revoked_by": "b"}}}))
|
|
242
|
+
assert ApproverRegistry(str(old)).audit_keys_hex() == {"x@contoso.com": [pub_hex(k1)]}
|
|
@@ -108,8 +108,9 @@ def test_init_for_a_windows_client_and_audit_log_verification(tmp_path, monkeypa
|
|
|
108
108
|
gate = _gate(state, params); assert not gate["executed"]
|
|
109
109
|
_record(state, REFUSED, gate, params)
|
|
110
110
|
assert len(log.read_text().splitlines()) == 4
|
|
111
|
-
assert main(["audit", "--log", str(log), "--out", str(out)]) == 0
|
|
111
|
+
assert main(["audit", "--log", str(log), "--out", str(out), "--summary"]) == 0
|
|
112
112
|
out_text = capsys.readouterr().out
|
|
113
|
+
assert "summary:" in out_text and "principal" in out_text and "clients:" in out_text
|
|
113
114
|
assert "4 entries (3 honored, 1 refused), 0 failed; chain EVIDENCE_VERIFIED" in out_text
|
|
114
115
|
assert out_text.count(" ok ") == 4 and "refused delete_file" in out_text
|
|
115
116
|
assert "ENTRY_VERIFIED (DESTRUCTIVE_TOOL)" in out_text
|
|
@@ -136,3 +137,37 @@ def test_approvers_list_and_revoke_on_the_issuer_host(tmp_path, capsys):
|
|
|
136
137
|
assert doc["approvers"] == {} and doc["revoked"]["dana@contoso.com"]["revoked_by"].startswith("operator:")
|
|
137
138
|
assert main(["approvers", "list", "--out", str(out)]) == 0
|
|
138
139
|
assert "revoked: dana@contoso.com" in capsys.readouterr().out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_the_package_runs_as_a_module():
|
|
143
|
+
"""`python -m gargoyle --help` works where gargoyle.exe is not on PATH."""
|
|
144
|
+
import subprocess, sys
|
|
145
|
+
r = subprocess.run([sys.executable, "-m", "gargoyle", "--help"], capture_output=True, text=True, timeout=60)
|
|
146
|
+
assert r.returncode == 0 and "audit" in r.stdout
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_init_observe_allows_everything_and_records_it(tmp_path, capsys):
|
|
150
|
+
"""Observe mode: the rules file allows every call, so the gate holds
|
|
151
|
+
nothing and refuses nothing while every call still gets a receipt and an
|
|
152
|
+
evidence entry. The default rules are not touched once someone edited them."""
|
|
153
|
+
from gargoyle.cli import DEFAULT_RULES, OBSERVE_RULES
|
|
154
|
+
from gargoyle.rules import RulesPolicy
|
|
155
|
+
out = tmp_path / "deploy"
|
|
156
|
+
assert main(["init", str(tmp_path / "project"), "--out", str(out), "--observe"]) == 0
|
|
157
|
+
assert "observe mode" in capsys.readouterr().out
|
|
158
|
+
assert json.loads((out / "rules.json").read_text()) == OBSERVE_RULES
|
|
159
|
+
policy = RulesPolicy(str(out / "rules.json"))
|
|
160
|
+
for tool in ("read_file", "write_file", "delete_file", "anything_else"):
|
|
161
|
+
d = policy.decide("mcp://x", {"tool": tool, "args": {}, "principal": "p"})
|
|
162
|
+
assert d.eligible and not d.requires_approval
|
|
163
|
+
assert policy.pin()["policy_sha256"] != RulesPolicy.__module__ and policy.pin()["policy_id"]
|
|
164
|
+
# A hand-edited rules file is left alone by a later --observe.
|
|
165
|
+
custom = {**DEFAULT_RULES, "rules": DEFAULT_RULES["rules"][:1]}
|
|
166
|
+
(out / "rules.json").write_text(json.dumps(custom))
|
|
167
|
+
assert main(["init", str(tmp_path / "project"), "--out", str(out), "--observe"]) == 0
|
|
168
|
+
assert json.loads((out / "rules.json").read_text()) == custom
|
|
169
|
+
assert "left as it is" in capsys.readouterr().err
|
|
170
|
+
# A broken rules file is refused, not overwritten and not a traceback.
|
|
171
|
+
(out / "rules.json").write_text("{not json")
|
|
172
|
+
assert main(["init", str(tmp_path / "project"), "--out", str(out), "--observe"]) == 2
|
|
173
|
+
assert (out / "rules.json").read_text() == "{not json"
|
|
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.16.2.dev0 → gargoyle_receipts-0.16.4.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.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/published_record_source.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/specs/graph-reference.json
RENAMED
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.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
|
{gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.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.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_issuer_service.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_mcp_self_issue.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_published_record.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_stdio_encoding.py
RENAMED
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_typescript_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|