gargoyle-receipts 0.16.4.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.16.4.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.18.0.dev0}/PKG-INFO +17 -9
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/README.md +16 -8
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/adapters.py +4 -3
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/cli.py +106 -19
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/envelope.py +7 -3
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/evidence.py +76 -12
- gargoyle_receipts-0.18.0.dev0/gargoyle/globs.py +58 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/issuer_service.py +165 -26
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/mcp_server.py +153 -14
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/plans.py +22 -2
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/policy.py +3 -1
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/published_record_source.py +14 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/reconcile.py +8 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/replay_cache.py +42 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/rules.py +156 -1
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/state.py +17 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/static/approve.html +41 -7
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +17 -9
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +6 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/pyproject.toml +1 -1
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_approvers.py +52 -3
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_cli.py +2 -2
- gargoyle_receipts-0.18.0.dev0/tests/test_globs.py +46 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_issuer_service.py +2 -1
- {gargoyle_receipts-0.16.4.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.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_plans.py +88 -1
- gargoyle_receipts-0.18.0.dev0/tests/test_record_follow.py +92 -0
- gargoyle_receipts-0.18.0.dev0/tests/test_review_fixes.py +236 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_rules.py +1 -1
- gargoyle_receipts-0.18.0.dev0/tests/test_state_text.py +48 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_threshold.py +2 -2
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/__main__.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/approval.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/approvers.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/consumer.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/execution.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/holdstore.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/identity.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/remote.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/revert.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/static/approve.js +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/verifier.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle/witness.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_console_js.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_entra_identity.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_evidence.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_graph.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_historical.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_remote.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_revert.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_state.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_wire_spec.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.18.0.dev0}/tests/test_witness.py +0 -0
{gargoyle_receipts-0.16.4.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
|
|
|
@@ -161,7 +161,9 @@ two-method interface.
|
|
|
161
161
|
**No engine.** `gargoyle.rules.RulesPolicy` decides from a JSON file:
|
|
162
162
|
first matching rule wins, globs on tool name, principal, delegation,
|
|
163
163
|
the caller's groups and roles, and string arguments, actions allow, hold
|
|
164
|
-
or deny.
|
|
164
|
+
or deny. Argument globs that look like paths are matched on a normalized
|
|
165
|
+
form (separators, `..`, letter case), so a rule for one folder is not
|
|
166
|
+
slipped by traversal or spelling; `*` spans folders. The pin is the file's hash,
|
|
165
167
|
so an edit invalidates outstanding receipts exactly as an engine change
|
|
166
168
|
does. This is what `gargoyle init` sets up.
|
|
167
169
|
|
|
@@ -286,6 +288,7 @@ hold and deny rules):
|
|
|
286
288
|
|
|
287
289
|
gargoyle init /path/the/agent/may/touch # keys, config, rules.json
|
|
288
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
|
|
289
292
|
gargoyle issuer # the issuing service, on rules.json
|
|
290
293
|
gargoyle approve --watch 2 # where the approver sits (terminal)
|
|
291
294
|
|
|
@@ -303,7 +306,11 @@ stay on file so earlier grants still audit; new ones are refused at the
|
|
|
303
306
|
issuer and at every middleware on its next held call. Revocation retires
|
|
304
307
|
a key, not a person: while the Approver role stands, the person can sign
|
|
305
308
|
in and enroll a fresh key, so remove the role too; the revoked key itself
|
|
306
|
-
is never accepted again.
|
|
309
|
+
is never accepted again. The role is what counts at release time: a
|
|
310
|
+
grant under an enrolled key is accepted only from that account, under a
|
|
311
|
+
token that carries the role now, so a role removed in the directory ends
|
|
312
|
+
the power at once. One key per account: a second browser does not enroll
|
|
313
|
+
until the first key consents or the operator revokes it. Every key an approver held stays in the file
|
|
307
314
|
(`history`), so an audit of grants released under an earlier key still
|
|
308
315
|
verifies: copy the issuer's current `approvers.json` beside the evidence
|
|
309
316
|
before auditing. Approvers, and
|
|
@@ -391,7 +398,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
391
398
|
| `GARGOYLE_MCP_GATE_KEY_ID`, `GARGOYLE_MCP_GATE_PUBLIC_KEY_HEX` | the pinned issuer key |
|
|
392
399
|
| `GARGOYLE_MCP_TARGET_ID` | the target identity receipts bind to |
|
|
393
400
|
| `GARGOYLE_MCP_PRINCIPAL` | the client this server serves |
|
|
394
|
-
| `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 |
|
|
395
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) |
|
|
396
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) |
|
|
397
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 |
|
|
@@ -402,6 +409,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
402
409
|
| issuer `GARGOYLE_ENTRA_TENANT` + `GARGOYLE_ISSUER_AUDIENCE` | trust the tenant's tokens; the audience is required with a tenant (the service will not start without it), and a directory token for any other tenant is `REF_IDENTITY_WRONG_TENANT` |
|
|
403
410
|
| issuer `GARGOYLE_HOLDS_PATH` | SQLite file for holds, grants, declines and plans, so a restart loses nothing a person is acting on (`gargoyle issuer` sets `holds.sqlite` beside the keys); `GET /health` reports it |
|
|
404
411
|
| issuer `GARGOYLE_MAX_OPEN_HOLDS` | how many held receipts and plans one principal may have waiting at once (default 20); the call over the cap is `REF_ISSUER_TOO_MANY_HOLDS` and nothing is stored |
|
|
412
|
+
| issuer `GARGOYLE_ALLOW_SELF_APPROVAL` | `1` lets a person release their own call; by default the approver may be neither the hold's principal nor the party acted for (`REF_ISSUER_SELF_APPROVAL`). A grant for an expired hold is `REF_ISSUER_HOLD_EXPIRED` and the hold is dropped |
|
|
405
413
|
| 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 |
|
|
406
414
|
| issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
|
|
407
415
|
| `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 |
|
|
@@ -410,7 +418,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
410
418
|
| `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
|
|
411
419
|
| `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
|
|
412
420
|
| `GARGOYLE_MCP_SNAPSHOT_DIR` | where the bytes a held, state-bound write replaces are kept (default `snapshots/` beside the audit log; `none` disables undo); the middleware then offers a `gargoyle_revert` tool that restores an approved change through the gate, held and state-bound like any write |
|
|
413
|
-
| `GARGOYLE_MCP_AUDIT_LOG` | the evidence log: every decision, honored or refused, as one hash-chained JSON line with its receipt, grant and execution receipt; `gargoyle audit --log` verifies the file |
|
|
421
|
+
| `GARGOYLE_MCP_AUDIT_LOG` | the evidence log: every decision, honored or refused, as one hash-chained JSON line with its receipt, grant and execution receipt; `gargoyle audit --log` verifies the file; the replay claims that make a receipt single-use live beside it in `<log stem>.replay.sqlite`, so a restarted gate still refuses a receipt it honored |
|
|
414
422
|
|
|
415
423
|
**Issuing service** (`gargoyle.issuer_service`, run with uvicorn from
|
|
416
424
|
`app_from_env()`): identity token in, receipt out, with the hold and grant
|
|
@@ -452,13 +460,13 @@ on it.
|
|
|
452
460
|
| `jws` | the receipt as a JWS any JWT library verifies |
|
|
453
461
|
| `envelope` | receipt construction, hash region, signature region, currency check |
|
|
454
462
|
| `verifier` | the pure target-side check and every refusal code |
|
|
455
|
-
| `consumer` | `Consumer` (pin, keys, verify, claim, attest)
|
|
463
|
+
| `consumer` | `Consumer` (pin, keys, verify, claim, attest) |
|
|
456
464
|
| `execution` | execution receipts and the offline chain check |
|
|
457
465
|
| `evidence` | the executor's hash-chained log of every decision, and its offline verifier |
|
|
458
466
|
| `mcp_server` | the stdio MCP middleware (and demo executor), with self-issue mode |
|
|
459
467
|
| `fs_server` | a small Python filesystem MCP server, for clients without Node |
|
|
460
468
|
| `issuer_service` | the HTTP issuing service: identity in, receipts out, holds and grants |
|
|
461
|
-
| `replay_cache`, `pending_store` | single use for receipts and holds; in
|
|
469
|
+
| `replay_cache`, `pending_store` | single use for receipts and holds; in memory, SQLite beside the audit log (the middleware's default) or Redis |
|
|
462
470
|
| `approval`, `approver_cli`, `approver_trust` | grants, the human's tool, approver key trust |
|
|
463
471
|
| `published_record_source` | the live policy pin as a publisher-signed record with serial and expiry |
|
|
464
472
|
| `key_record_source` | root-signed issuer and approver keys with windows and revocation |
|
|
@@ -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
|
|
|
@@ -123,7 +123,9 @@ two-method interface.
|
|
|
123
123
|
**No engine.** `gargoyle.rules.RulesPolicy` decides from a JSON file:
|
|
124
124
|
first matching rule wins, globs on tool name, principal, delegation,
|
|
125
125
|
the caller's groups and roles, and string arguments, actions allow, hold
|
|
126
|
-
or deny.
|
|
126
|
+
or deny. Argument globs that look like paths are matched on a normalized
|
|
127
|
+
form (separators, `..`, letter case), so a rule for one folder is not
|
|
128
|
+
slipped by traversal or spelling; `*` spans folders. The pin is the file's hash,
|
|
127
129
|
so an edit invalidates outstanding receipts exactly as an engine change
|
|
128
130
|
does. This is what `gargoyle init` sets up.
|
|
129
131
|
|
|
@@ -248,6 +250,7 @@ hold and deny rules):
|
|
|
248
250
|
|
|
249
251
|
gargoyle init /path/the/agent/may/touch # keys, config, rules.json
|
|
250
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
|
|
251
254
|
gargoyle issuer # the issuing service, on rules.json
|
|
252
255
|
gargoyle approve --watch 2 # where the approver sits (terminal)
|
|
253
256
|
|
|
@@ -265,7 +268,11 @@ stay on file so earlier grants still audit; new ones are refused at the
|
|
|
265
268
|
issuer and at every middleware on its next held call. Revocation retires
|
|
266
269
|
a key, not a person: while the Approver role stands, the person can sign
|
|
267
270
|
in and enroll a fresh key, so remove the role too; the revoked key itself
|
|
268
|
-
is never accepted again.
|
|
271
|
+
is never accepted again. The role is what counts at release time: a
|
|
272
|
+
grant under an enrolled key is accepted only from that account, under a
|
|
273
|
+
token that carries the role now, so a role removed in the directory ends
|
|
274
|
+
the power at once. One key per account: a second browser does not enroll
|
|
275
|
+
until the first key consents or the operator revokes it. Every key an approver held stays in the file
|
|
269
276
|
(`history`), so an audit of grants released under an earlier key still
|
|
270
277
|
verifies: copy the issuer's current `approvers.json` beside the evidence
|
|
271
278
|
before auditing. Approvers, and
|
|
@@ -353,7 +360,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
353
360
|
| `GARGOYLE_MCP_GATE_KEY_ID`, `GARGOYLE_MCP_GATE_PUBLIC_KEY_HEX` | the pinned issuer key |
|
|
354
361
|
| `GARGOYLE_MCP_TARGET_ID` | the target identity receipts bind to |
|
|
355
362
|
| `GARGOYLE_MCP_PRINCIPAL` | the client this server serves |
|
|
356
|
-
| `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 |
|
|
357
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) |
|
|
358
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) |
|
|
359
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 |
|
|
@@ -364,6 +371,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
364
371
|
| issuer `GARGOYLE_ENTRA_TENANT` + `GARGOYLE_ISSUER_AUDIENCE` | trust the tenant's tokens; the audience is required with a tenant (the service will not start without it), and a directory token for any other tenant is `REF_IDENTITY_WRONG_TENANT` |
|
|
365
372
|
| issuer `GARGOYLE_HOLDS_PATH` | SQLite file for holds, grants, declines and plans, so a restart loses nothing a person is acting on (`gargoyle issuer` sets `holds.sqlite` beside the keys); `GET /health` reports it |
|
|
366
373
|
| issuer `GARGOYLE_MAX_OPEN_HOLDS` | how many held receipts and plans one principal may have waiting at once (default 20); the call over the cap is `REF_ISSUER_TOO_MANY_HOLDS` and nothing is stored |
|
|
374
|
+
| issuer `GARGOYLE_ALLOW_SELF_APPROVAL` | `1` lets a person release their own call; by default the approver may be neither the hold's principal nor the party acted for (`REF_ISSUER_SELF_APPROVAL`). A grant for an expired hold is `REF_ISSUER_HOLD_EXPIRED` and the hold is dropped |
|
|
367
375
|
| 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 |
|
|
368
376
|
| issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
|
|
369
377
|
| `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 |
|
|
@@ -372,7 +380,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
372
380
|
| `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
|
|
373
381
|
| `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
|
|
374
382
|
| `GARGOYLE_MCP_SNAPSHOT_DIR` | where the bytes a held, state-bound write replaces are kept (default `snapshots/` beside the audit log; `none` disables undo); the middleware then offers a `gargoyle_revert` tool that restores an approved change through the gate, held and state-bound like any write |
|
|
375
|
-
| `GARGOYLE_MCP_AUDIT_LOG` | the evidence log: every decision, honored or refused, as one hash-chained JSON line with its receipt, grant and execution receipt; `gargoyle audit --log` verifies the file |
|
|
383
|
+
| `GARGOYLE_MCP_AUDIT_LOG` | the evidence log: every decision, honored or refused, as one hash-chained JSON line with its receipt, grant and execution receipt; `gargoyle audit --log` verifies the file; the replay claims that make a receipt single-use live beside it in `<log stem>.replay.sqlite`, so a restarted gate still refuses a receipt it honored |
|
|
376
384
|
|
|
377
385
|
**Issuing service** (`gargoyle.issuer_service`, run with uvicorn from
|
|
378
386
|
`app_from_env()`): identity token in, receipt out, with the hold and grant
|
|
@@ -414,13 +422,13 @@ on it.
|
|
|
414
422
|
| `jws` | the receipt as a JWS any JWT library verifies |
|
|
415
423
|
| `envelope` | receipt construction, hash region, signature region, currency check |
|
|
416
424
|
| `verifier` | the pure target-side check and every refusal code |
|
|
417
|
-
| `consumer` | `Consumer` (pin, keys, verify, claim, attest)
|
|
425
|
+
| `consumer` | `Consumer` (pin, keys, verify, claim, attest) |
|
|
418
426
|
| `execution` | execution receipts and the offline chain check |
|
|
419
427
|
| `evidence` | the executor's hash-chained log of every decision, and its offline verifier |
|
|
420
428
|
| `mcp_server` | the stdio MCP middleware (and demo executor), with self-issue mode |
|
|
421
429
|
| `fs_server` | a small Python filesystem MCP server, for clients without Node |
|
|
422
430
|
| `issuer_service` | the HTTP issuing service: identity in, receipts out, holds and grants |
|
|
423
|
-
| `replay_cache`, `pending_store` | single use for receipts and holds; in
|
|
431
|
+
| `replay_cache`, `pending_store` | single use for receipts and holds; in memory, SQLite beside the audit log (the middleware's default) or Redis |
|
|
424
432
|
| `approval`, `approver_cli`, `approver_trust` | grants, the human's tool, approver key trust |
|
|
425
433
|
| `published_record_source` | the live policy pin as a publisher-signed record with serial and expiry |
|
|
426
434
|
| `key_record_source` | root-signed issuer and approver keys with windows and revocation |
|
|
@@ -135,9 +135,10 @@ class GraphAdapter:
|
|
|
135
135
|
@staticmethod
|
|
136
136
|
def _desc(ref: str, obj: Any, summary: str, irreversible: bool = False) -> Tuple[Dict[str, Any], Optional[bytes]]:
|
|
137
137
|
data = canonical_json(obj).encode("utf-8") if obj is not None else None
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
138
|
+
# The text is the hashed bytes themselves (canonical JSON), so the
|
|
139
|
+
# console can verify it against the bound state before showing it;
|
|
140
|
+
# it pretty-prints JSON for display.
|
|
141
|
+
desc = describe(ref, data, with_text=True)
|
|
141
142
|
desc["summary"] = summary
|
|
142
143
|
if irreversible:
|
|
143
144
|
desc["irreversible"] = True
|
|
@@ -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,
|
|
@@ -395,15 +472,19 @@ def _audit_summary(report, raw) -> None:
|
|
|
395
472
|
releases: Counter = Counter()
|
|
396
473
|
clients: Counter = Counter()
|
|
397
474
|
ats = [e.get("at") for e in raw if isinstance(e, dict) and isinstance(e.get("at"), str)]
|
|
475
|
+
from gargoyle.evidence import FAILED_VERDICTS
|
|
398
476
|
for r in report:
|
|
399
|
-
|
|
477
|
+
kind = r["kind"] or "?"
|
|
478
|
+
if kind == "honored" and r.get("verdict") in FAILED_VERDICTS:
|
|
479
|
+
kind = "failed" # admitted, but the upstream did not do it
|
|
480
|
+
counts.setdefault((r["principal"] or "?", r["tool"] or "?"), Counter())[kind] += 1
|
|
400
481
|
for who in r.get("released_by") or []:
|
|
401
482
|
releases[who] += 1
|
|
402
483
|
clients[r.get("client") or "(client unnamed)"] += 1
|
|
403
484
|
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"))
|
|
485
|
+
print(" %-28s %-24s %7s %7s %7s" % ("principal", "tool", "honored", "failed", "refused"))
|
|
405
486
|
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)))
|
|
487
|
+
print(" %-28s %-24s %7d %7d %7d" % (principal[:28], tool[:24], c.get("honored", 0), c.get("failed", 0), c.get("refused", 0)))
|
|
407
488
|
if releases:
|
|
408
489
|
print(" releases: " + ", ".join("%s %d" % (w, n) for w, n in releases.most_common()))
|
|
409
490
|
print(" clients: " + ", ".join("%s %d" % (w, n) for w, n in clients.most_common()))
|
|
@@ -519,7 +600,13 @@ def main(argv=None) -> int:
|
|
|
519
600
|
help="the filesystem MCP server behind the gate: the official one via npx, or gargoyle.fs_server")
|
|
520
601
|
p.add_argument("--entra-tenant", help="trust this Entra ID tenant's tokens at the issuer (users then run `gargoyle login`)")
|
|
521
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)")
|
|
522
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)
|
|
523
610
|
p = sub.add_parser("login", help="sign this workstation's user in with Entra ID (device code)")
|
|
524
611
|
p.add_argument("--tenant", required=True, help="tenant id or domain")
|
|
525
612
|
p.add_argument("--client-id", required=True, help="the public client app registration")
|
|
@@ -208,7 +208,9 @@ def valid_principal(principal: Any) -> bool:
|
|
|
208
208
|
def valid_approval(approval: Any) -> bool:
|
|
209
209
|
"""True iff `approval` is a well-formed receipt `approval` member:
|
|
210
210
|
{"required": int >= 1, "approvers": [non-empty unique strings] optional}."""
|
|
211
|
-
if not isinstance(approval, dict) or not set(approval) <= {"required", "approvers"} or "required" not in approval:
|
|
211
|
+
if not isinstance(approval, dict) or not set(approval) <= {"required", "approvers", "plannable"} or "required" not in approval:
|
|
212
|
+
return False
|
|
213
|
+
if "plannable" in approval and not isinstance(approval["plannable"], bool):
|
|
212
214
|
return False
|
|
213
215
|
required = approval["required"]
|
|
214
216
|
if isinstance(required, bool) or not isinstance(required, int) or required < 1:
|
|
@@ -320,6 +322,8 @@ def build_envelope(
|
|
|
320
322
|
envelope["approval"] = {"required": approval["required"]}
|
|
321
323
|
if "approvers" in approval:
|
|
322
324
|
envelope["approval"]["approvers"] = sorted(approval["approvers"])
|
|
325
|
+
if approval.get("plannable") is False:
|
|
326
|
+
envelope["approval"]["plannable"] = False
|
|
323
327
|
envelope["decision_sha256"] = decision_sha256_of(envelope)
|
|
324
328
|
return envelope
|
|
325
329
|
|
|
@@ -329,8 +333,8 @@ def approval_terms(envelope: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
329
333
|
the receipt's `approval` member, or the default of one from anyone."""
|
|
330
334
|
a = envelope.get("approval")
|
|
331
335
|
if isinstance(a, dict):
|
|
332
|
-
return {"required": a["required"], "approvers": a.get("approvers")}
|
|
333
|
-
return {"required": 1, "approvers": None}
|
|
336
|
+
return {"required": a["required"], "approvers": a.get("approvers"), "plannable": a.get("plannable", True)}
|
|
337
|
+
return {"required": 1, "approvers": None, "plannable": True}
|
|
334
338
|
|
|
335
339
|
|
|
336
340
|
def reassert(
|
|
@@ -63,6 +63,17 @@ REF_EVIDENCE_HASH_MISMATCH = "REF_EVIDENCE_HASH_MISMATCH" # entry_sha256 does n
|
|
|
63
63
|
REF_EVIDENCE_CHAIN_BROKEN = "REF_EVIDENCE_CHAIN_BROKEN" # seq or prev_sha256 does not follow
|
|
64
64
|
REF_EVIDENCE_RECEIPT_MISSING = "REF_EVIDENCE_RECEIPT_MISSING" # honored without receipt or execution
|
|
65
65
|
REF_EVIDENCE_UNCHAINED = "REF_EVIDENCE_UNCHAINED" # a pre-chain {receipt, grant, execution} line
|
|
66
|
+
REF_EVIDENCE_REPLAY = "REF_EVIDENCE_REPLAY" # a decision id (or plan step) honored twice in one log
|
|
67
|
+
|
|
68
|
+
# Verdicts an honored entry carries when the admitted call did not succeed
|
|
69
|
+
# downstream: the receipt was claimed, so the entry must exist, and the
|
|
70
|
+
# verdict says the change most likely did not happen (the execution receipt's
|
|
71
|
+
# outputs carry the error). Any other verdict on an honored entry is the
|
|
72
|
+
# verifier's accept reason.
|
|
73
|
+
TOOL_ERROR = "TOOL_ERROR" # the upstream answered with isError: true
|
|
74
|
+
UPSTREAM_ERROR = "UPSTREAM_ERROR" # the upstream answered with a JSON-RPC error
|
|
75
|
+
UPSTREAM_UNAVAILABLE = "UPSTREAM_UNAVAILABLE" # the upstream could not be reached after the claim
|
|
76
|
+
FAILED_VERDICTS = frozenset((TOOL_ERROR, UPSTREAM_ERROR, UPSTREAM_UNAVAILABLE))
|
|
66
77
|
|
|
67
78
|
_FIELDS = ("evidence_version", "seq", "prev_sha256", "at", "kind", "tool", "args_sha256",
|
|
68
79
|
"principal", "reason", "receipt", "grant", "execution", "entry_sha256")
|
|
@@ -118,38 +129,77 @@ def build_entry(*, seq: int, prev_sha256: str, kind: str, tool: str, args: Any,
|
|
|
118
129
|
|
|
119
130
|
class EvidenceLog:
|
|
120
131
|
"""Appends entries to a JSON Lines file, continuing the chain found at
|
|
121
|
-
its tail.
|
|
122
|
-
|
|
123
|
-
|
|
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."""
|
|
124
138
|
|
|
125
139
|
def __init__(self, path: str) -> None:
|
|
126
140
|
self.path = Path(path)
|
|
127
141
|
self._seq: Optional[int] = None
|
|
128
142
|
self._tail: str = GENESIS
|
|
143
|
+
self._size: int = -1 # the file size after our last write; another writer changes it
|
|
129
144
|
|
|
130
145
|
def _load_tail(self) -> None:
|
|
131
146
|
self._seq, self._tail = 0, GENESIS
|
|
132
147
|
if not self.path.exists():
|
|
148
|
+
self._size = 0
|
|
133
149
|
return
|
|
134
|
-
|
|
150
|
+
text = self.path.read_text(encoding="utf-8")
|
|
151
|
+
self._size = self.path.stat().st_size
|
|
152
|
+
for line in text.splitlines():
|
|
135
153
|
if line.strip():
|
|
136
154
|
last = json.loads(line)
|
|
137
155
|
if last.get("evidence_version") in EVIDENCE_VERSIONS:
|
|
138
156
|
self._seq, self._tail = int(last["seq"]), str(last["entry_sha256"])
|
|
139
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
|
+
|
|
140
187
|
def append(self, kind: str, tool: str, args: Any, principal: str, reason: str, *,
|
|
141
188
|
receipt: Any = None, grant: Any = None, execution: Any = None,
|
|
142
189
|
client: Optional[str] = None, ref: Optional[str] = None) -> Dict[str, Any]:
|
|
143
190
|
"""Write one entry and return it. Raises OSError if the file cannot
|
|
144
191
|
be written; the caller decides whether that stops the call."""
|
|
145
|
-
|
|
146
|
-
self.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
153
203
|
return entry
|
|
154
204
|
|
|
155
205
|
|
|
@@ -191,6 +241,7 @@ def verify_log(
|
|
|
191
241
|
expect_seq, prev, tail = 1, GENESIS, GENESIS
|
|
192
242
|
counts = {HONORED: 0, REFUSED: 0}
|
|
193
243
|
all_ok = True
|
|
244
|
+
claimed: Dict[str, int] = {} # decision_id / plan step -> seq of the honored entry that spent it
|
|
194
245
|
|
|
195
246
|
for raw in entries:
|
|
196
247
|
if not _well_formed(raw):
|
|
@@ -220,6 +271,19 @@ def verify_log(
|
|
|
220
271
|
if ok and (rc.get("tool") != e["tool"] or rc.get("args_sha256") != e["args_sha256"]
|
|
221
272
|
or rc.get("principal") != e["principal"]):
|
|
222
273
|
ok, reason = False, REF_EVIDENCE_RECEIPT_MISSING
|
|
274
|
+
if ok:
|
|
275
|
+
# Single use, checked after the fact: a decision id, or a
|
|
276
|
+
# plan step, honored twice in one log is a replay the
|
|
277
|
+
# executor's cache missed (a second process, a lost cache).
|
|
278
|
+
keys = [str(e["receipt"].get("decision_id"))]
|
|
279
|
+
gs = e["grant"]
|
|
280
|
+
if isinstance(gs, dict) and "plan" in gs and "step" in gs:
|
|
281
|
+
keys.append("plan:%s:%s" % ((gs["plan"] or {}).get("plan_id"), gs["step"]))
|
|
282
|
+
if any(k in claimed for k in keys):
|
|
283
|
+
ok, reason = False, REF_EVIDENCE_REPLAY
|
|
284
|
+
else:
|
|
285
|
+
for k in keys:
|
|
286
|
+
claimed[k] = e["seq"]
|
|
223
287
|
else:
|
|
224
288
|
reason = ACCEPT_ENTRY_VERIFIED
|
|
225
289
|
if e["receipt"] is not None:
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Argument globs, as rules (gargoyle.rules) and plan steps (gargoyle.plans)
|
|
2
|
+
use them: `fnmatch` patterns over one argument value, with paths made safe
|
|
3
|
+
to match.
|
|
4
|
+
|
|
5
|
+
A raw `fnmatch` on a path is a hole: `C:\\proj\\*` is slipped by
|
|
6
|
+
`C:\\proj\\..\\secrets\\x`, by `c:\\PROJ\\x` on a case-insensitive disk and
|
|
7
|
+
by `C:/proj/x`. So when either side looks like a path (a separator, or a
|
|
8
|
+
drive letter), both are normalized first: separators become `/`, `.` and
|
|
9
|
+
`..` segments are resolved lexically, repeated separators collapse, and
|
|
10
|
+
the whole string is lower-cased, since the gate cannot know the target
|
|
11
|
+
filesystem's case rules and a rule meant for one folder must not be
|
|
12
|
+
slipped by a letter. Values that look like nothing of the kind (a mail
|
|
13
|
+
address, an account id) are matched exactly as written, case included.
|
|
14
|
+
|
|
15
|
+
`*` spans folders: a rule for `C:\\proj\\*` covers the whole tree under it.
|
|
16
|
+
There is no segment-bounded wildcard; a rule that wants one folder level
|
|
17
|
+
names it.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import fnmatch
|
|
21
|
+
import posixpath
|
|
22
|
+
import re
|
|
23
|
+
|
|
24
|
+
_PATHLIKE = re.compile(r"[\\/]|^[A-Za-z]:")
|
|
25
|
+
_DRIVE = re.compile(r"^([A-Za-z]:)(.*)$", re.S)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_pathlike(value: str) -> bool:
|
|
29
|
+
return bool(_PATHLIKE.search(value))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def normalize_path(value: str) -> str:
|
|
33
|
+
"""The comparable form of a path or path glob (see the module note)."""
|
|
34
|
+
text = value.replace("\\", "/")
|
|
35
|
+
drive = ""
|
|
36
|
+
m = _DRIVE.match(text)
|
|
37
|
+
if m:
|
|
38
|
+
drive, text = m.group(1), m.group(2) or "/"
|
|
39
|
+
if text.startswith("/"):
|
|
40
|
+
text = "/" + text.lstrip("/") # after a drive, repeated separators are just separators
|
|
41
|
+
unc = not drive and text.startswith("//") and not text.startswith("///")
|
|
42
|
+
text = posixpath.normpath(text) if text else ""
|
|
43
|
+
if unc and not text.startswith("//"):
|
|
44
|
+
text = "/" + text
|
|
45
|
+
return (drive + text).lower()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def comparable(glob: str, value: str):
|
|
49
|
+
"""Both sides as they are compared: normalized when either is a path."""
|
|
50
|
+
if is_pathlike(value) or is_pathlike(glob):
|
|
51
|
+
return normalize_path(glob), normalize_path(value)
|
|
52
|
+
return glob, value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def glob_match(glob: str, value: str) -> bool:
|
|
56
|
+
"""Does the argument value match the glob? Paths normalized first."""
|
|
57
|
+
g, v = comparable(glob, value)
|
|
58
|
+
return fnmatch.fnmatchcase(v, g)
|