gargoyle-receipts 0.17.0.dev0__tar.gz → 0.18.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.17.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.18.0.dev0}/PKG-INFO +6 -5
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/README.md +5 -4
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/cli.py +99 -16
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/evidence.py +51 -12
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/issuer_service.py +14 -1
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/mcp_server.py +131 -9
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/published_record_source.py +14 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +6 -5
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +2 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/pyproject.toml +1 -1
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_cli.py +2 -2
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_issuer_service.py +2 -1
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_mcp_self_issue.py +3 -3
- gargoyle_receipts-0.18.0.dev0/tests/test_operability.py +126 -0
- gargoyle_receipts-0.18.0.dev0/tests/test_record_follow.py +92 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_rules.py +1 -1
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/__main__.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/adapters.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/approval.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/approvers.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/consumer.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/envelope.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/execution.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/globs.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/holdstore.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/identity.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/plans.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/policy.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/reconcile.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/remote.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/replay_cache.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/revert.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/rules.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/state.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/static/approve.html +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/static/approve.js +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/verifier.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/witness.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_approvers.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_console_js.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_entra_identity.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_evidence.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_globs.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_graph.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_historical.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_plans.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_remote.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_revert.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_review_fixes.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_state.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_state_text.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_threshold.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_wire_spec.py +0 -0
- {gargoyle_receipts-0.17.0.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_witness.py +0 -0
{gargoyle_receipts-0.17.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.18.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.18.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.18.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
|
|
@@ -103,7 +103,7 @@ Python 3.12 or later. A TypeScript verifier and consumer for Node 20+
|
|
|
103
103
|
lives in [ts/](ts/) (`cd ts && npm install && npm test`); its suite
|
|
104
104
|
recomputes the same conformance vectors byte for byte, so the two
|
|
105
105
|
implementations cannot drift apart silently. Redis is optional (`.[redis]`) and only needed to
|
|
106
|
-
share the replay cache and pending-approval set across
|
|
106
|
+
share the replay cache and pending-approval set across replicas of the remote gate; a single gate keeps its replay claims in SQLite beside its evidence log.
|
|
107
107
|
|
|
108
108
|
## Issuing a receipt
|
|
109
109
|
|
|
@@ -288,6 +288,7 @@ hold and deny rules):
|
|
|
288
288
|
|
|
289
289
|
gargoyle init /path/the/agent/may/touch # keys, config, rules.json
|
|
290
290
|
gargoyle init --observe /path/... # or: allow and record everything, hold nothing, tighten later
|
|
291
|
+
gargoyle tokens --out gargoyle-deploy # later: new client and approver tokens, same keys
|
|
291
292
|
gargoyle issuer # the issuing service, on rules.json
|
|
292
293
|
gargoyle approve --watch 2 # where the approver sits (terminal)
|
|
293
294
|
|
|
@@ -397,7 +398,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
397
398
|
| `GARGOYLE_MCP_GATE_KEY_ID`, `GARGOYLE_MCP_GATE_PUBLIC_KEY_HEX` | the pinned issuer key |
|
|
398
399
|
| `GARGOYLE_MCP_TARGET_ID` | the target identity receipts bind to |
|
|
399
400
|
| `GARGOYLE_MCP_PRINCIPAL` | the client this server serves |
|
|
400
|
-
| `GARGOYLE_MCP_RECORD_PATH` | the
|
|
401
|
+
| `GARGOYLE_MCP_RECORD_PATH`, `GARGOYLE_MCP_RECORD_URL` | the live policy pin: a file copied out of band, or (what `init` sets) the issuer's signed record at `<issuer>/record`, followed live and cached a minute, so a rule change reaches every gate without a copy or a restart |
|
|
401
402
|
| `GARGOYLE_MCP_APPROVER_KEY_ID`, `GARGOYLE_MCP_APPROVER_PUBLIC_KEY_HEX` | the approver keys whose grants release flagged calls (optional, both or neither; comma-separated lists for several) |
|
|
402
403
|
| `GARGOYLE_MCP_EXECUTOR_KEY_ID`, `GARGOYLE_MCP_EXECUTOR_KEY_HEX` | this server's own signing key, so honored calls carry an execution receipt in `_meta["gargoyle/execution"]` (optional, both or neither) |
|
|
403
404
|
| `GARGOYLE_MCP_ISSUER_URL`, `GARGOYLE_MCP_CLIENT_TOKEN` | self-issue mode: the issuing service and the identity token (whose subject is this server's principal) to obtain receipts with; `GARGOYLE_MCP_APPROVAL_TIMEOUT_SECONDS` and `GARGOYLE_MCP_POLL_SECONDS` tune the wait for a grant |
|
|
@@ -459,7 +460,7 @@ on it.
|
|
|
459
460
|
| `jws` | the receipt as a JWS any JWT library verifies |
|
|
460
461
|
| `envelope` | receipt construction, hash region, signature region, currency check |
|
|
461
462
|
| `verifier` | the pure target-side check and every refusal code |
|
|
462
|
-
| `consumer` | `Consumer` (pin, keys, verify, claim, attest)
|
|
463
|
+
| `consumer` | `Consumer` (pin, keys, verify, claim, attest) |
|
|
463
464
|
| `execution` | execution receipts and the offline chain check |
|
|
464
465
|
| `evidence` | the executor's hash-chained log of every decision, and its offline verifier |
|
|
465
466
|
| `mcp_server` | the stdio MCP middleware (and demo executor), with self-issue mode |
|
|
@@ -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.18.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
|
|
@@ -65,7 +65,7 @@ Python 3.12 or later. A TypeScript verifier and consumer for Node 20+
|
|
|
65
65
|
lives in [ts/](ts/) (`cd ts && npm install && npm test`); its suite
|
|
66
66
|
recomputes the same conformance vectors byte for byte, so the two
|
|
67
67
|
implementations cannot drift apart silently. Redis is optional (`.[redis]`) and only needed to
|
|
68
|
-
share the replay cache and pending-approval set across
|
|
68
|
+
share the replay cache and pending-approval set across replicas of the remote gate; a single gate keeps its replay claims in SQLite beside its evidence log.
|
|
69
69
|
|
|
70
70
|
## Issuing a receipt
|
|
71
71
|
|
|
@@ -250,6 +250,7 @@ hold and deny rules):
|
|
|
250
250
|
|
|
251
251
|
gargoyle init /path/the/agent/may/touch # keys, config, rules.json
|
|
252
252
|
gargoyle init --observe /path/... # or: allow and record everything, hold nothing, tighten later
|
|
253
|
+
gargoyle tokens --out gargoyle-deploy # later: new client and approver tokens, same keys
|
|
253
254
|
gargoyle issuer # the issuing service, on rules.json
|
|
254
255
|
gargoyle approve --watch 2 # where the approver sits (terminal)
|
|
255
256
|
|
|
@@ -359,7 +360,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
359
360
|
| `GARGOYLE_MCP_GATE_KEY_ID`, `GARGOYLE_MCP_GATE_PUBLIC_KEY_HEX` | the pinned issuer key |
|
|
360
361
|
| `GARGOYLE_MCP_TARGET_ID` | the target identity receipts bind to |
|
|
361
362
|
| `GARGOYLE_MCP_PRINCIPAL` | the client this server serves |
|
|
362
|
-
| `GARGOYLE_MCP_RECORD_PATH` | the
|
|
363
|
+
| `GARGOYLE_MCP_RECORD_PATH`, `GARGOYLE_MCP_RECORD_URL` | the live policy pin: a file copied out of band, or (what `init` sets) the issuer's signed record at `<issuer>/record`, followed live and cached a minute, so a rule change reaches every gate without a copy or a restart |
|
|
363
364
|
| `GARGOYLE_MCP_APPROVER_KEY_ID`, `GARGOYLE_MCP_APPROVER_PUBLIC_KEY_HEX` | the approver keys whose grants release flagged calls (optional, both or neither; comma-separated lists for several) |
|
|
364
365
|
| `GARGOYLE_MCP_EXECUTOR_KEY_ID`, `GARGOYLE_MCP_EXECUTOR_KEY_HEX` | this server's own signing key, so honored calls carry an execution receipt in `_meta["gargoyle/execution"]` (optional, both or neither) |
|
|
365
366
|
| `GARGOYLE_MCP_ISSUER_URL`, `GARGOYLE_MCP_CLIENT_TOKEN` | self-issue mode: the issuing service and the identity token (whose subject is this server's principal) to obtain receipts with; `GARGOYLE_MCP_APPROVAL_TIMEOUT_SECONDS` and `GARGOYLE_MCP_POLL_SECONDS` tune the wait for a grant |
|
|
@@ -421,7 +422,7 @@ on it.
|
|
|
421
422
|
| `jws` | the receipt as a JWS any JWT library verifies |
|
|
422
423
|
| `envelope` | receipt construction, hash region, signature region, currency check |
|
|
423
424
|
| `verifier` | the pure target-side check and every refusal code |
|
|
424
|
-
| `consumer` | `Consumer` (pin, keys, verify, claim, attest)
|
|
425
|
+
| `consumer` | `Consumer` (pin, keys, verify, claim, attest) |
|
|
425
426
|
| `execution` | execution receipts and the offline chain check |
|
|
426
427
|
| `evidence` | the executor's hash-chained log of every decision, and its offline verifier |
|
|
427
428
|
| `mcp_server` | the stdio MCP middleware (and demo executor), with self-issue mode |
|
|
@@ -55,8 +55,8 @@ OBSERVE_RULES = {
|
|
|
55
55
|
# Observe mode: every call allowed (deletes too), every call recorded, none held. The
|
|
56
56
|
# first week of a deployment, so the hold and deny rules are written
|
|
57
57
|
# from the audit rather than guessed. The policy pin changes when the
|
|
58
|
-
# rules do
|
|
59
|
-
# the new
|
|
58
|
+
# rules do; the issuer applies the edit at once and clients that follow
|
|
59
|
+
# its signed record pick the new pin up within a minute.
|
|
60
60
|
"version": "1",
|
|
61
61
|
"default": "allow",
|
|
62
62
|
"rules": [],
|
|
@@ -93,6 +93,27 @@ def _write(path: Path, text: str, private: bool = False) -> None:
|
|
|
93
93
|
print("wrote", path)
|
|
94
94
|
|
|
95
95
|
|
|
96
|
+
def _observe_rules(rules: Path) -> int:
|
|
97
|
+
"""Switch a deployment's rules file to observe mode (allow and record
|
|
98
|
+
everything), leaving a hand-edited file alone."""
|
|
99
|
+
current = None
|
|
100
|
+
if rules.exists():
|
|
101
|
+
try:
|
|
102
|
+
current = json.loads(rules.read_text())
|
|
103
|
+
except ValueError as exc:
|
|
104
|
+
print("%s is not valid JSON (%s); fix or remove it before --observe" % (rules, exc), file=sys.stderr)
|
|
105
|
+
return 2
|
|
106
|
+
if current is None or current == DEFAULT_RULES:
|
|
107
|
+
_write(rules, json.dumps(OBSERVE_RULES, indent=2) + "\n")
|
|
108
|
+
elif current != OBSERVE_RULES:
|
|
109
|
+
print("rules.json was edited by hand and is left as it is; --observe changed nothing", file=sys.stderr)
|
|
110
|
+
print("observe mode: rules.json allows every call, destructive ones included; the gate records all of "
|
|
111
|
+
"them and holds none. Tighten rules.json from `gargoyle audit --summary`; the issuer applies the "
|
|
112
|
+
"change at once and clients that follow its record (GARGOYLE_MCP_RECORD_URL) pick the new pin up "
|
|
113
|
+
"within a minute. A client on a static record.json needs the new file.")
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
96
117
|
def cmd_init(args) -> int:
|
|
97
118
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
98
119
|
from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat
|
|
@@ -100,12 +121,26 @@ def cmd_init(args) -> int:
|
|
|
100
121
|
|
|
101
122
|
out = Path(args.out).resolve()
|
|
102
123
|
out.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
if (out / "issuer.env").exists() and not args.force:
|
|
125
|
+
# A deployment lives here. Its keys and tokens are what every client
|
|
126
|
+
# and approver holds; regenerating them by accident strands them all.
|
|
127
|
+
if args.observe:
|
|
128
|
+
return _observe_rules(out / "rules.json")
|
|
129
|
+
print("%s already holds a deployment; its keys and tokens are kept. New tokens: `gargoyle tokens --out %s`. "
|
|
130
|
+
"Fresh keys: `gargoyle init --force` (every client and approver must then be re-provisioned), or a new --out."
|
|
131
|
+
% (out, out), file=sys.stderr)
|
|
132
|
+
return 2
|
|
103
133
|
# A Windows path given on a Linux issuer host is the client's and is kept as typed.
|
|
104
134
|
windows = re.match(r"^[A-Za-z]:[\\/]", args.directory) is not None
|
|
105
135
|
allowed = args.directory if windows else str(Path(args.directory).resolve())
|
|
106
136
|
sep = "\\" if windows else "/"
|
|
107
137
|
client_dir = args.client_config_dir or str(out)
|
|
108
138
|
client_python = args.client_python or sys.executable
|
|
139
|
+
from gargoyle.globs import normalize_path
|
|
140
|
+
if normalize_path(client_dir) == normalize_path(allowed) or normalize_path(client_dir).startswith(normalize_path(allowed).rstrip("/") + "/"):
|
|
141
|
+
print("note: the client config folder %s is inside the folder the gate serves (%s). The gate refuses calls into its "
|
|
142
|
+
"own files (REF_TARGET_GATE_FILES), but secrets are better kept out of the served tree: --client-config-dir."
|
|
143
|
+
% (client_dir, allowed), file=sys.stderr)
|
|
109
144
|
|
|
110
145
|
def new_key():
|
|
111
146
|
k = Ed25519PrivateKey.generate()
|
|
@@ -130,20 +165,9 @@ def cmd_init(args) -> int:
|
|
|
130
165
|
|
|
131
166
|
rules = out / "rules.json"
|
|
132
167
|
if args.observe:
|
|
133
|
-
|
|
134
|
-
if
|
|
135
|
-
|
|
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.")
|
|
168
|
+
rc = _observe_rules(rules)
|
|
169
|
+
if rc:
|
|
170
|
+
return rc
|
|
147
171
|
elif not rules.exists():
|
|
148
172
|
_write(rules, json.dumps(DEFAULT_RULES, indent=2) + "\n")
|
|
149
173
|
_write(out / "issuer.env", _env_lines({
|
|
@@ -159,6 +183,7 @@ def cmd_init(args) -> int:
|
|
|
159
183
|
"GARGOYLE_MCP_GATE_KEY_ID": "issuer-1", "GARGOYLE_MCP_GATE_PUBLIC_KEY_HEX": issuer_pub,
|
|
160
184
|
"GARGOYLE_MCP_TARGET_ID": "mcp://filesystem/" + allowed, "GARGOYLE_MCP_PRINCIPAL": PRINCIPAL,
|
|
161
185
|
"GARGOYLE_MCP_RECORD_PATH": client_dir + sep + "record.json",
|
|
186
|
+
"GARGOYLE_MCP_RECORD_URL": issuer_url + "/record", # follow the issuer's signed pin; the file is the offline fallback
|
|
162
187
|
"GARGOYLE_MCP_APPROVER_KEY_ID": approver_ids, "GARGOYLE_MCP_APPROVER_PUBLIC_KEY_HEX": approver_pubs,
|
|
163
188
|
"GARGOYLE_MCP_EXECUTOR_KEY_ID": "executor-1", "GARGOYLE_MCP_EXECUTOR_KEY_HEX": executor_hex,
|
|
164
189
|
"GARGOYLE_MCP_ISSUER_URL": issuer_url, "GARGOYLE_MCP_CLIENT_TOKEN": client_token,
|
|
@@ -226,6 +251,58 @@ WantedBy=multi-user.target
|
|
|
226
251
|
return 0
|
|
227
252
|
|
|
228
253
|
|
|
254
|
+
def cmd_tokens(args) -> int:
|
|
255
|
+
"""New identity tokens from the deployment's existing identity key: the
|
|
256
|
+
client's shared token (unless the client signs in with Entra, whose
|
|
257
|
+
token renews itself) and every file-key approver's. Nothing else
|
|
258
|
+
changes, so clients keep their keys and the audit its trust."""
|
|
259
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
260
|
+
from gargoyle.jws import encode
|
|
261
|
+
out = Path(args.out).resolve()
|
|
262
|
+
issuer_env = out / "issuer.env"
|
|
263
|
+
if not issuer_env.exists():
|
|
264
|
+
print("%s holds no deployment (no issuer.env)" % out, file=sys.stderr)
|
|
265
|
+
return 2
|
|
266
|
+
env = _read_env(issuer_env)
|
|
267
|
+
idp_hex = env.get("GARGOYLE_IDP_KEY_HEX")
|
|
268
|
+
if not idp_hex:
|
|
269
|
+
print("issuer.env keeps no identity key (a deployment older than 0.9); make a fresh one with `gargoyle init` to a new --out", file=sys.stderr)
|
|
270
|
+
return 2
|
|
271
|
+
idp = Ed25519PrivateKey.from_private_bytes(bytes.fromhex(idp_hex))
|
|
272
|
+
kid = env.get("GARGOYLE_IDP_KEY_ID", "idp-1")
|
|
273
|
+
exp = int((datetime.now(timezone.utc) + timedelta(days=args.token_days)).timestamp())
|
|
274
|
+
mw = out / "middleware.env"
|
|
275
|
+
if mw.exists():
|
|
276
|
+
menv = _read_env(mw)
|
|
277
|
+
if "GARGOYLE_MCP_CLIENT_TOKEN" in menv:
|
|
278
|
+
menv["GARGOYLE_MCP_CLIENT_TOKEN"] = encode({"sub": menv.get("GARGOYLE_MCP_PRINCIPAL", PRINCIPAL), "aud": "gargoyle-issuer", "exp": exp}, idp, kid)
|
|
279
|
+
_write(mw, _env_lines(menv), private=True)
|
|
280
|
+
mcp = out / "mcp-config.json"
|
|
281
|
+
if mcp.exists():
|
|
282
|
+
doc = json.loads(mcp.read_text())
|
|
283
|
+
for server in doc.get("mcpServers", {}).values():
|
|
284
|
+
if "GARGOYLE_MCP_CLIENT_TOKEN" in server.get("env", {}):
|
|
285
|
+
server["env"]["GARGOYLE_MCP_CLIENT_TOKEN"] = menv["GARGOYLE_MCP_CLIENT_TOKEN"]
|
|
286
|
+
_write(mcp, json.dumps(doc, indent=2) + "\n", private=True)
|
|
287
|
+
else:
|
|
288
|
+
print("middleware.env signs in with Entra; that token renews itself and was left alone")
|
|
289
|
+
for env_path in sorted(out.glob("approver*.env")):
|
|
290
|
+
aenv = _read_env(env_path)
|
|
291
|
+
akid = aenv.get("GARGOYLE_APPROVER_KEY_ID")
|
|
292
|
+
if not akid:
|
|
293
|
+
continue
|
|
294
|
+
tok = encode({"sub": akid, "roles": [DEFAULT_APPROVER_ROLE], "aud": "gargoyle-issuer", "exp": exp}, idp, kid)
|
|
295
|
+
aenv["GARGOYLE_APPROVER_TOKEN"] = tok
|
|
296
|
+
_write(env_path, _env_lines(aenv), private=True)
|
|
297
|
+
web = out / env_path.name.replace("approver", "approver-web", 1).replace(".env", ".json")
|
|
298
|
+
if web.exists():
|
|
299
|
+
doc = json.loads(web.read_text())
|
|
300
|
+
doc["token"] = tok
|
|
301
|
+
_write(web, json.dumps(doc, indent=2) + "\n", private=True)
|
|
302
|
+
print("new tokens, valid %d days; copy middleware.env (and approver-web*.json) to the machines that use them and restart their MCP clients" % args.token_days)
|
|
303
|
+
return 0
|
|
304
|
+
|
|
305
|
+
|
|
229
306
|
def cmd_login(args) -> int:
|
|
230
307
|
"""Device-code sign-in; then middleware.env (and mcp-config.json when
|
|
231
308
|
present) switch from the shared init token to this user's Entra token,
|
|
@@ -523,7 +600,13 @@ def main(argv=None) -> int:
|
|
|
523
600
|
help="the filesystem MCP server behind the gate: the official one via npx, or gargoyle.fs_server")
|
|
524
601
|
p.add_argument("--entra-tenant", help="trust this Entra ID tenant's tokens at the issuer (users then run `gargoyle login`)")
|
|
525
602
|
p.add_argument("--entra-audience", help="the issuer API app's client id or app id URI, required in Entra tokens")
|
|
603
|
+
p.add_argument("--force", action="store_true",
|
|
604
|
+
help="regenerate keys and tokens in an existing --out (every client and approver must be re-provisioned)")
|
|
526
605
|
p.set_defaults(fn=cmd_init)
|
|
606
|
+
p = sub.add_parser("tokens", help="mint new client and approver tokens from a deployment's existing keys")
|
|
607
|
+
p.add_argument("--out", default="gargoyle-deploy")
|
|
608
|
+
p.add_argument("--token-days", type=int, default=30)
|
|
609
|
+
p.set_defaults(fn=cmd_tokens)
|
|
527
610
|
p = sub.add_parser("login", help="sign this workstation's user in with Entra ID (device code)")
|
|
528
611
|
p.add_argument("--tenant", required=True, help="tenant id or domain")
|
|
529
612
|
p.add_argument("--client-id", required=True, help="the public client app registration")
|
|
@@ -129,38 +129,77 @@ def build_entry(*, seq: int, prev_sha256: str, kind: str, tool: str, args: Any,
|
|
|
129
129
|
|
|
130
130
|
class EvidenceLog:
|
|
131
131
|
"""Appends entries to a JSON Lines file, continuing the chain found at
|
|
132
|
-
its tail.
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
its tail. Several gate processes may share one log (a Claude Code and
|
|
133
|
+
a Claude Desktop session on the same folder): each append takes an
|
|
134
|
+
exclusive lock on `<log>.lock`, re-reads the tail when the file grew
|
|
135
|
+
since this process last wrote, then writes, so the chain never forks.
|
|
136
|
+
The lock is advisory and per host, as for any append-only log on a
|
|
137
|
+
local disk."""
|
|
135
138
|
|
|
136
139
|
def __init__(self, path: str) -> None:
|
|
137
140
|
self.path = Path(path)
|
|
138
141
|
self._seq: Optional[int] = None
|
|
139
142
|
self._tail: str = GENESIS
|
|
143
|
+
self._size: int = -1 # the file size after our last write; another writer changes it
|
|
140
144
|
|
|
141
145
|
def _load_tail(self) -> None:
|
|
142
146
|
self._seq, self._tail = 0, GENESIS
|
|
143
147
|
if not self.path.exists():
|
|
148
|
+
self._size = 0
|
|
144
149
|
return
|
|
145
|
-
|
|
150
|
+
text = self.path.read_text(encoding="utf-8")
|
|
151
|
+
self._size = self.path.stat().st_size
|
|
152
|
+
for line in text.splitlines():
|
|
146
153
|
if line.strip():
|
|
147
154
|
last = json.loads(line)
|
|
148
155
|
if last.get("evidence_version") in EVIDENCE_VERSIONS:
|
|
149
156
|
self._seq, self._tail = int(last["seq"]), str(last["entry_sha256"])
|
|
150
157
|
|
|
158
|
+
def _locked(self):
|
|
159
|
+
"""An exclusive, blocking lock on the log's lock file (POSIX flock,
|
|
160
|
+
Windows byte-range lock), released on exit."""
|
|
161
|
+
import contextlib
|
|
162
|
+
|
|
163
|
+
@contextlib.contextmanager
|
|
164
|
+
def hold():
|
|
165
|
+
lock_path = self.path.with_name(self.path.name + ".lock")
|
|
166
|
+
with open(lock_path, "a+b") as lock:
|
|
167
|
+
try:
|
|
168
|
+
import fcntl
|
|
169
|
+
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
|
170
|
+
unlock = lambda: fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
|
|
171
|
+
except ImportError: # Windows
|
|
172
|
+
import msvcrt
|
|
173
|
+
lock.seek(0)
|
|
174
|
+
while True:
|
|
175
|
+
try:
|
|
176
|
+
msvcrt.locking(lock.fileno(), msvcrt.LK_LOCK, 1) # retries about ten seconds, then raises
|
|
177
|
+
break
|
|
178
|
+
except OSError:
|
|
179
|
+
continue
|
|
180
|
+
unlock = lambda: (lock.seek(0), msvcrt.locking(lock.fileno(), msvcrt.LK_UNLCK, 1))
|
|
181
|
+
try:
|
|
182
|
+
yield
|
|
183
|
+
finally:
|
|
184
|
+
unlock()
|
|
185
|
+
return hold()
|
|
186
|
+
|
|
151
187
|
def append(self, kind: str, tool: str, args: Any, principal: str, reason: str, *,
|
|
152
188
|
receipt: Any = None, grant: Any = None, execution: Any = None,
|
|
153
189
|
client: Optional[str] = None, ref: Optional[str] = None) -> Dict[str, Any]:
|
|
154
190
|
"""Write one entry and return it. Raises OSError if the file cannot
|
|
155
191
|
be written; the caller decides whether that stops the call."""
|
|
156
|
-
|
|
157
|
-
self.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
192
|
+
with self._locked():
|
|
193
|
+
current = self.path.stat().st_size if self.path.exists() else 0
|
|
194
|
+
if self._seq is None or current != self._size:
|
|
195
|
+
self._load_tail() # first write, or another process wrote since: continue its chain
|
|
196
|
+
entry = build_entry(seq=self._seq + 1, prev_sha256=self._tail, kind=kind, tool=tool, args=args,
|
|
197
|
+
principal=principal, reason=reason, receipt=receipt, grant=grant, execution=execution,
|
|
198
|
+
client=client, ref=ref)
|
|
199
|
+
with open(self.path, "a", encoding="utf-8") as f:
|
|
200
|
+
f.write(json.dumps(entry, sort_keys=True) + "\n")
|
|
201
|
+
self._seq, self._tail = entry["seq"], entry["entry_sha256"]
|
|
202
|
+
self._size = self.path.stat().st_size
|
|
164
203
|
return entry
|
|
165
204
|
|
|
166
205
|
|
|
@@ -63,6 +63,7 @@ from gargoyle.approvers import (DEFAULT_APPROVER_ROLE, ENV_APPROVER_GROUPS, ENV_
|
|
|
63
63
|
ApproverRegistry, RevokedKeyError, may_enroll, valid_public_key_hex)
|
|
64
64
|
from gargoyle.identity import entra_keys, entra_tenant_id, principal_from_token
|
|
65
65
|
from gargoyle.jws import REF_JWS_KEY_UNKNOWN
|
|
66
|
+
from gargoyle.published_record_source import sign_published_record
|
|
66
67
|
from gargoyle.issuer import DEFAULT_TTL_SECONDS, issue
|
|
67
68
|
from gargoyle.policy import Policy
|
|
68
69
|
|
|
@@ -683,9 +684,21 @@ def build_issuer_app(
|
|
|
683
684
|
async def approve_script():
|
|
684
685
|
return Response((STATIC / "approve.js").read_bytes(), media_type="text/javascript; charset=utf-8")
|
|
685
686
|
|
|
687
|
+
app.state.record_pin = None
|
|
688
|
+
app.state.record_serial = 0
|
|
689
|
+
|
|
686
690
|
@app.get("/record")
|
|
687
691
|
async def record():
|
|
688
|
-
|
|
692
|
+
"""The live policy pin in the signed published-record form (SPEC 9),
|
|
693
|
+
so a middleware on another host can follow rule changes without a
|
|
694
|
+
file copy: it verifies the issuer's signature with the key it
|
|
695
|
+
already pins. The serial rises whenever the pin changes, so a
|
|
696
|
+
rolled-back record is refused as stale."""
|
|
697
|
+
pin = policy.pin()
|
|
698
|
+
if pin != app.state.record_pin:
|
|
699
|
+
app.state.record_pin = pin
|
|
700
|
+
app.state.record_serial = max(int(now().timestamp()), app.state.record_serial + 1)
|
|
701
|
+
return sign_published_record(pin, signing_key, key_id, serial=app.state.record_serial, now=now(), ttl_seconds=600)
|
|
689
702
|
|
|
690
703
|
@app.get("/keys")
|
|
691
704
|
async def keys():
|
|
@@ -51,6 +51,7 @@ from gargoyle.consumer import REF_TARGET_NOT_CONFIGURED, Consumer
|
|
|
51
51
|
from gargoyle.evidence import HONORED, REFUSED, EvidenceLog, TOOL_ERROR, UPSTREAM_ERROR, UPSTREAM_UNAVAILABLE
|
|
52
52
|
from gargoyle.envelope import valid_principal
|
|
53
53
|
from gargoyle.policy import valid_pin
|
|
54
|
+
from gargoyle.verifier import REF_VERIFY_REASSERT_RE_EVALUATE_REQUIRED
|
|
54
55
|
from gargoyle.replay_cache import InMemoryReplayCache, ReplayCache, SqliteReplayCache
|
|
55
56
|
|
|
56
57
|
# MCP protocol version this server speaks (a real MCP revision string).
|
|
@@ -88,6 +89,7 @@ ENV_GATE_KEY_ID = "GARGOYLE_MCP_GATE_KEY_ID"
|
|
|
88
89
|
ENV_GATE_PUBLIC_KEY_HEX = "GARGOYLE_MCP_GATE_PUBLIC_KEY_HEX"
|
|
89
90
|
ENV_TARGET_ID = "GARGOYLE_MCP_TARGET_ID"
|
|
90
91
|
ENV_RECORD_PATH = "GARGOYLE_MCP_RECORD_PATH"
|
|
92
|
+
ENV_RECORD_URL = "GARGOYLE_MCP_RECORD_URL" # follow the issuer's signed record instead of a file (SPEC 9)
|
|
91
93
|
ENV_PRINCIPAL = "GARGOYLE_MCP_PRINCIPAL"
|
|
92
94
|
ENV_APPROVER_KEY_ID = "GARGOYLE_MCP_APPROVER_KEY_ID"
|
|
93
95
|
ENV_APPROVER_PUBLIC_KEY_HEX = "GARGOYLE_MCP_APPROVER_PUBLIC_KEY_HEX"
|
|
@@ -108,6 +110,7 @@ REF_TARGET_LOGIN_REQUIRED = "REF_TARGET_LOGIN_REQUIRED" # Entra login mode and
|
|
|
108
110
|
REF_TARGET_ISSUER_UNAVAILABLE = "REF_TARGET_ISSUER_UNAVAILABLE"
|
|
109
111
|
REF_TARGET_APPROVAL_TIMEOUT = "REF_TARGET_APPROVAL_TIMEOUT"
|
|
110
112
|
REF_TARGET_APPROVAL_DECLINED = "REF_TARGET_APPROVAL_DECLINED"
|
|
113
|
+
REF_TARGET_GATE_FILES = "REF_TARGET_GATE_FILES" # the call would touch the gate's own folder (config, keys, evidence, snapshots)
|
|
111
114
|
|
|
112
115
|
|
|
113
116
|
class IssuerClient:
|
|
@@ -194,6 +197,15 @@ class IssuerClient:
|
|
|
194
197
|
except Exception:
|
|
195
198
|
return {}
|
|
196
199
|
|
|
200
|
+
def decline(self, approval_request_id: str) -> bool:
|
|
201
|
+
"""Withdraw a hold this gate asked for (the hold's own principal may):
|
|
202
|
+
when the gate stops waiting, the card must not stay approvable."""
|
|
203
|
+
try:
|
|
204
|
+
r = self._post("/decline", {"approval_request_id": approval_request_id})
|
|
205
|
+
return r.status_code == 200 and bool(r.json().get("declined"))
|
|
206
|
+
except Exception:
|
|
207
|
+
return False
|
|
208
|
+
|
|
197
209
|
def wait_for_grant(self, approval_request_id: str) -> Dict[str, Any]:
|
|
198
210
|
"""Poll until the hold has its grant (or, for a receipt that needs
|
|
199
211
|
several approvers, the whole set), a human declined it, the hold
|
|
@@ -326,6 +338,50 @@ def call_for(tool: str, args: Any, principal: str, state_sha256: Optional[str] =
|
|
|
326
338
|
return call
|
|
327
339
|
|
|
328
340
|
|
|
341
|
+
class RecordFollower:
|
|
342
|
+
"""The live policy pin, fetched as the issuer's signed record (GET
|
|
343
|
+
/record) and verified against the pinned issuer key, cached for a
|
|
344
|
+
minute. A rule change on the issuer then reaches every gate within
|
|
345
|
+
that minute, or at once when a receipt arrives under a pin the cache
|
|
346
|
+
does not know (the gate forgets the cache and asks again before
|
|
347
|
+
refusing). No file copy, no restart. The trust is the same as for
|
|
348
|
+
receipts: the issuer's key, pinned out of band. A deployment that wants
|
|
349
|
+
an independent copy of the pin keeps GARGOYLE_MCP_RECORD_PATH instead."""
|
|
350
|
+
|
|
351
|
+
def __init__(self, url: str, pinned_keys: Dict[str, Any], *, http: Any = None, ttl_seconds: float = 60.0,
|
|
352
|
+
request_timeout: float = 10.0) -> None:
|
|
353
|
+
import requests
|
|
354
|
+
self.url = url
|
|
355
|
+
self.pinned_keys = pinned_keys
|
|
356
|
+
self._http = http if http is not None else requests.Session()
|
|
357
|
+
self.ttl_seconds = ttl_seconds
|
|
358
|
+
self.request_timeout = request_timeout
|
|
359
|
+
self._cache: Optional[tuple] = None
|
|
360
|
+
self.fetches = 0
|
|
361
|
+
|
|
362
|
+
def forget(self) -> None:
|
|
363
|
+
self._cache = None
|
|
364
|
+
|
|
365
|
+
def __call__(self, last_seen_serial: Optional[int]) -> Dict[str, Any]:
|
|
366
|
+
from gargoyle.published_record_source import load_signed_record_from_bytes
|
|
367
|
+
from gargoyle.verifier import REF_VERIFY_PUBLISHED_RECORD_INVALID
|
|
368
|
+
cached = self._cache
|
|
369
|
+
if cached is not None and time.monotonic() - cached[0] < self.ttl_seconds:
|
|
370
|
+
return cached[1]
|
|
371
|
+
self.fetches += 1
|
|
372
|
+
try:
|
|
373
|
+
r = self._http.get(self.url, timeout=self.request_timeout)
|
|
374
|
+
if r.status_code != 200:
|
|
375
|
+
res = {"record": None, "reason": REF_VERIFY_PUBLISHED_RECORD_INVALID}
|
|
376
|
+
else:
|
|
377
|
+
res = load_signed_record_from_bytes(r.content, self.pinned_keys, last_seen_serial=last_seen_serial)
|
|
378
|
+
except Exception:
|
|
379
|
+
res = {"record": None, "reason": REF_VERIFY_PUBLISHED_RECORD_INVALID}
|
|
380
|
+
if res.get("record") is not None:
|
|
381
|
+
self._cache = (time.monotonic(), res) # a failure is not cached: the next check asks again
|
|
382
|
+
return res
|
|
383
|
+
|
|
384
|
+
|
|
329
385
|
class ServerState:
|
|
330
386
|
"""The executor's out-of-band trust material (issuer keys, the policy record,
|
|
331
387
|
target id, the principal it serves) as a Consumer, and the list of tool
|
|
@@ -357,6 +413,13 @@ class ServerState:
|
|
|
357
413
|
self.upstream = upstream
|
|
358
414
|
self.issuer_client = issuer_client
|
|
359
415
|
self.evidence: Optional[EvidenceLog] = EvidenceLog(audit_log) if audit_log else None
|
|
416
|
+
# The gate's own folder (config, keys, evidence, snapshots, replay
|
|
417
|
+
# claims) is never a target: a call whose path arguments reach into
|
|
418
|
+
# it is refused before any receipt is asked for.
|
|
419
|
+
self.gate_dir: Optional[str] = None
|
|
420
|
+
if audit_log:
|
|
421
|
+
from gargoyle.globs import normalize_path
|
|
422
|
+
self.gate_dir = normalize_path(str(Path(audit_log).resolve().parent)).rstrip("/")
|
|
360
423
|
if replay_cache is None and audit_log:
|
|
361
424
|
# Single use has to outlive this process: the claims sit in a
|
|
362
425
|
# SQLite file beside the evidence log (audit.replay.sqlite), so a
|
|
@@ -364,11 +427,16 @@ class ServerState:
|
|
|
364
427
|
# receipt that was already honored (second review, finding 4).
|
|
365
428
|
replay_cache = SqliteReplayCache(str(Path(audit_log).with_name(Path(audit_log).stem + ".replay.sqlite")))
|
|
366
429
|
self.consumer: Optional[Consumer] = None
|
|
430
|
+
self.record_follower: Optional[RecordFollower] = None
|
|
367
431
|
if config is not None and valid_principal(config.get("principal")):
|
|
432
|
+
if config.get("record_url"):
|
|
433
|
+
self.record_follower = RecordFollower(config["record_url"], config["pinned_public_keys"],
|
|
434
|
+
http=getattr(issuer_client, "_http", None))
|
|
368
435
|
self.consumer = Consumer(
|
|
369
436
|
target_id=config["target_id"],
|
|
370
437
|
pinned_public_keys=config["pinned_public_keys"],
|
|
371
|
-
record=config["record"],
|
|
438
|
+
record=None if self.record_follower is not None else config["record"],
|
|
439
|
+
record_source=self.record_follower,
|
|
372
440
|
replay_cache=replay_cache,
|
|
373
441
|
approver_public_keys=config.get("approver_public_keys"),
|
|
374
442
|
approver_key_source=(issuer_client.approver_keys if issuer_client is not None else None),
|
|
@@ -410,6 +478,8 @@ def _gate(state: ServerState, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
410
478
|
envelope = meta.get(ENVELOPE_META_KEY)
|
|
411
479
|
grant = meta.get(GRANT_META_KEY)
|
|
412
480
|
self_issued = False
|
|
481
|
+
if state.gate_dir is not None and _touches_gate_files(args, state.gate_dir):
|
|
482
|
+
return refused(REF_TARGET_GATE_FILES)
|
|
413
483
|
|
|
414
484
|
if envelope is None and state.issuer_client is not None:
|
|
415
485
|
# Self-issue: ask the issuing service, and wait for the human if it
|
|
@@ -432,8 +502,14 @@ def _gate(state: ServerState, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
432
502
|
# A scoped grant the human already signed may cover this call;
|
|
433
503
|
# otherwise wait for a card of its own.
|
|
434
504
|
if release is None:
|
|
435
|
-
|
|
505
|
+
arid = str(envelope.get("approval_request_id"))
|
|
506
|
+
waited = state.issuer_client.wait_for_grant(arid)
|
|
436
507
|
if waited["grant"] is None:
|
|
508
|
+
if waited["reason"] == REF_TARGET_APPROVAL_TIMEOUT:
|
|
509
|
+
# Nobody is waiting for this release any more: take
|
|
510
|
+
# the card down, or an approval minutes later would
|
|
511
|
+
# read as a change that happened.
|
|
512
|
+
state.issuer_client.decline(arid)
|
|
437
513
|
return refused(waited["reason"], envelope)
|
|
438
514
|
grant = waited["grant"]
|
|
439
515
|
else:
|
|
@@ -454,6 +530,14 @@ def _gate(state: ServerState, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
454
530
|
else:
|
|
455
531
|
verdict = state.consumer.check(envelope, call_for(name, args, state.config["principal"], live_state),
|
|
456
532
|
grant=grant)
|
|
533
|
+
if (not verdict.honored and verdict.reason == REF_VERIFY_REASSERT_RE_EVALUATE_REQUIRED
|
|
534
|
+
and state.record_follower is not None):
|
|
535
|
+
# The receipt names a pin the cached record does not: the rules may
|
|
536
|
+
# have just changed on the issuer. Ask once more before refusing.
|
|
537
|
+
state.record_follower.forget()
|
|
538
|
+
call = call_for(name, args, state.config["principal"], live_state)
|
|
539
|
+
verdict = (state.consumer.check(envelope, call, plan_release=grant) if is_plan_release(grant)
|
|
540
|
+
else state.consumer.check(envelope, call, grant=grant))
|
|
457
541
|
if not verdict.honored:
|
|
458
542
|
return refused(verdict.reason, envelope)
|
|
459
543
|
if is_plan_release(grant):
|
|
@@ -477,6 +561,38 @@ def _gate(state: ServerState, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
477
561
|
"self_issued": self_issued, "ref": gate_ref["ref"]}
|
|
478
562
|
|
|
479
563
|
|
|
564
|
+
_GATE_FILE_GLOBS = ("*.env", "*.json", "*.jsonl", "*.jsonl.lock", "*.sqlite", "*.sqlite-*", "snapshots")
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _touches_gate_files(args: Any, gate_dir: str) -> bool:
|
|
568
|
+
"""Does any path-like string argument (at the top level or in a list)
|
|
569
|
+
name the gate's own folder, one of its files (env, json, the evidence
|
|
570
|
+
log and its lock, the replay claims) or anything under its snapshots?
|
|
571
|
+
Deeper subfolders are left alone: a deployment folder may hold the
|
|
572
|
+
project it serves."""
|
|
573
|
+
import fnmatch
|
|
574
|
+
from gargoyle.globs import is_pathlike, normalize_path
|
|
575
|
+
values = []
|
|
576
|
+
if isinstance(args, dict):
|
|
577
|
+
for v in args.values():
|
|
578
|
+
if isinstance(v, str):
|
|
579
|
+
values.append(v)
|
|
580
|
+
elif isinstance(v, list):
|
|
581
|
+
values.extend(x for x in v if isinstance(x, str))
|
|
582
|
+
for v in values:
|
|
583
|
+
if not is_pathlike(v):
|
|
584
|
+
continue
|
|
585
|
+
n = normalize_path(v)
|
|
586
|
+
if n == gate_dir:
|
|
587
|
+
return True
|
|
588
|
+
if n.startswith(gate_dir + "/"):
|
|
589
|
+
first = n[len(gate_dir) + 1:].split("/")[0]
|
|
590
|
+
direct = "/" not in n[len(gate_dir) + 1:]
|
|
591
|
+
if first == "snapshots" or (direct and any(fnmatch.fnmatchcase(first, g) for g in _GATE_FILE_GLOBS)):
|
|
592
|
+
return True
|
|
593
|
+
return False
|
|
594
|
+
|
|
595
|
+
|
|
480
596
|
def _plan_release_for(state: ServerState, tool: str, args: Any) -> Optional[Dict[str, Any]]:
|
|
481
597
|
"""The release record for the first active plan with an unconsumed step
|
|
482
598
|
matching this call, or None."""
|
|
@@ -745,8 +861,10 @@ def config_from_env() -> Optional[Dict[str, Any]]:
|
|
|
745
861
|
GARGOYLE_MCP_TARGET_ID is the identity receipts bind to;
|
|
746
862
|
GARGOYLE_MCP_PRINCIPAL is the client this server serves (see the module
|
|
747
863
|
docstring); the published record (the live policy pin, {policy_id,
|
|
748
|
-
policy_sha256}) is read from GARGOYLE_MCP_RECORD_PATH
|
|
749
|
-
|
|
864
|
+
policy_sha256}) is read from GARGOYLE_MCP_RECORD_PATH, or followed live
|
|
865
|
+
from GARGOYLE_MCP_RECORD_URL (the issuer's signed record; a rule change
|
|
866
|
+
then needs no file copy). One of the two is required with the other
|
|
867
|
+
four settings; there is no default record. GARGOYLE_MCP_APPROVER_KEY_ID and
|
|
750
868
|
GARGOYLE_MCP_APPROVER_PUBLIC_KEY_HEX, both or neither, pin the approver
|
|
751
869
|
keys whose grants release flagged receipts (comma-separated lists for
|
|
752
870
|
several)."""
|
|
@@ -757,7 +875,8 @@ def config_from_env() -> Optional[Dict[str, Any]]:
|
|
|
757
875
|
target_id = os.environ.get(ENV_TARGET_ID)
|
|
758
876
|
principal = os.environ.get(ENV_PRINCIPAL)
|
|
759
877
|
record_path = os.environ.get(ENV_RECORD_PATH)
|
|
760
|
-
|
|
878
|
+
record_url = os.environ.get(ENV_RECORD_URL)
|
|
879
|
+
if not (key_id and key_hex and target_id and principal and (record_path or record_url)):
|
|
761
880
|
return None
|
|
762
881
|
ap_key_id = os.environ.get(ENV_APPROVER_KEY_ID)
|
|
763
882
|
ap_key_hex = os.environ.get(ENV_APPROVER_PUBLIC_KEY_HEX)
|
|
@@ -780,15 +899,18 @@ def config_from_env() -> Optional[Dict[str, Any]]:
|
|
|
780
899
|
if len(ids) != len(hexes) or not all(ids):
|
|
781
900
|
return None
|
|
782
901
|
approver_keys = {i: Ed25519PublicKey.from_public_bytes(bytes.fromhex(h)) for i, h in zip(ids, hexes)}
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
902
|
+
record = None
|
|
903
|
+
if not record_url:
|
|
904
|
+
with open(record_path, "rb") as f:
|
|
905
|
+
record = json.loads(f.read().decode("utf-8"))
|
|
906
|
+
if not valid_pin(record):
|
|
907
|
+
return None
|
|
787
908
|
except Exception:
|
|
788
909
|
return None
|
|
789
910
|
return {
|
|
790
911
|
"pinned_public_keys": {key_id: public_key},
|
|
791
912
|
"record": record,
|
|
913
|
+
"record_url": record_url or None,
|
|
792
914
|
"target_id": target_id,
|
|
793
915
|
"principal": principal,
|
|
794
916
|
"approver_public_keys": approver_keys,
|