gargoyle-receipts 0.16.3.dev0__tar.gz → 0.17.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.3.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.17.0.dev0}/PKG-INFO +19 -7
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/README.md +18 -6
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/adapters.py +4 -3
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/cli.py +59 -1
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/envelope.py +7 -3
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/evidence.py +25 -0
- gargoyle_receipts-0.17.0.dev0/gargoyle/globs.py +58 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/issuer_service.py +151 -25
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/mcp_server.py +30 -11
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/plans.py +22 -2
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/policy.py +3 -1
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/reconcile.py +8 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/replay_cache.py +42 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/rules.py +156 -1
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/state.py +17 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/static/approve.html +41 -7
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +19 -7
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +4 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/pyproject.toml +1 -1
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_approvers.py +52 -3
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_cli.py +29 -1
- gargoyle_receipts-0.17.0.dev0/tests/test_globs.py +46 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_plans.py +88 -1
- gargoyle_receipts-0.17.0.dev0/tests/test_review_fixes.py +236 -0
- gargoyle_receipts-0.17.0.dev0/tests/test_state_text.py +48 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_threshold.py +2 -2
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/__main__.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/approval.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/approvers.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/consumer.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/execution.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/holdstore.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/identity.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/published_record_source.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/remote.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/revert.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/static/approve.js +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/verifier.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/witness.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_console_js.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_entra_identity.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_evidence.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_graph.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_historical.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_issuer_service.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_mcp_self_issue.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_remote.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_revert.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_rules.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_state.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_wire_spec.py +0 -0
- {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_witness.py +0 -0
{gargoyle_receipts-0.16.3.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.17.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.17.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.17.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
|
|
@@ -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
|
|
|
@@ -278,9 +280,14 @@ chat channel can approve anything.
|
|
|
278
280
|
|
|
279
281
|
pip install gargoyle-receipts
|
|
280
282
|
|
|
281
|
-
Three commands, no policy engine, no containers, no client changes
|
|
283
|
+
Three commands, no policy engine, no containers, no client changes
|
|
284
|
+
(`--observe` starts a deployment in record-only mode, every call allowed
|
|
285
|
+
including destructive ones and every call recorded; `gargoyle audit
|
|
286
|
+
--summary` then shows calls by principal and tool, the input for the
|
|
287
|
+
hold and deny rules):
|
|
282
288
|
|
|
283
289
|
gargoyle init /path/the/agent/may/touch # keys, config, rules.json
|
|
290
|
+
gargoyle init --observe /path/... # or: allow and record everything, hold nothing, tighten later
|
|
284
291
|
gargoyle issuer # the issuing service, on rules.json
|
|
285
292
|
gargoyle approve --watch 2 # where the approver sits (terminal)
|
|
286
293
|
|
|
@@ -298,7 +305,11 @@ stay on file so earlier grants still audit; new ones are refused at the
|
|
|
298
305
|
issuer and at every middleware on its next held call. Revocation retires
|
|
299
306
|
a key, not a person: while the Approver role stands, the person can sign
|
|
300
307
|
in and enroll a fresh key, so remove the role too; the revoked key itself
|
|
301
|
-
is never accepted again.
|
|
308
|
+
is never accepted again. The role is what counts at release time: a
|
|
309
|
+
grant under an enrolled key is accepted only from that account, under a
|
|
310
|
+
token that carries the role now, so a role removed in the directory ends
|
|
311
|
+
the power at once. One key per account: a second browser does not enroll
|
|
312
|
+
until the first key consents or the operator revokes it. Every key an approver held stays in the file
|
|
302
313
|
(`history`), so an audit of grants released under an earlier key still
|
|
303
314
|
verifies: copy the issuer's current `approvers.json` beside the evidence
|
|
304
315
|
before auditing. Approvers, and
|
|
@@ -397,6 +408,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
397
408
|
| 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` |
|
|
398
409
|
| 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 |
|
|
399
410
|
| 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 |
|
|
411
|
+
| 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 |
|
|
400
412
|
| 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 |
|
|
401
413
|
| issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
|
|
402
414
|
| `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 |
|
|
@@ -405,7 +417,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
405
417
|
| `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
|
|
406
418
|
| `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
|
|
407
419
|
| `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 |
|
|
408
|
-
| `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 |
|
|
420
|
+
| `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 |
|
|
409
421
|
|
|
410
422
|
**Issuing service** (`gargoyle.issuer_service`, run with uvicorn from
|
|
411
423
|
`app_from_env()`): identity token in, receipt out, with the hold and grant
|
|
@@ -453,7 +465,7 @@ on it.
|
|
|
453
465
|
| `mcp_server` | the stdio MCP middleware (and demo executor), with self-issue mode |
|
|
454
466
|
| `fs_server` | a small Python filesystem MCP server, for clients without Node |
|
|
455
467
|
| `issuer_service` | the HTTP issuing service: identity in, receipts out, holds and grants |
|
|
456
|
-
| `replay_cache`, `pending_store` | single use for receipts and holds; in
|
|
468
|
+
| `replay_cache`, `pending_store` | single use for receipts and holds; in memory, SQLite beside the audit log (the middleware's default) or Redis |
|
|
457
469
|
| `approval`, `approver_cli`, `approver_trust` | grants, the human's tool, approver key trust |
|
|
458
470
|
| `published_record_source` | the live policy pin as a publisher-signed record with serial and expiry |
|
|
459
471
|
| `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.17.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
|
|
@@ -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
|
|
|
@@ -240,9 +242,14 @@ chat channel can approve anything.
|
|
|
240
242
|
|
|
241
243
|
pip install gargoyle-receipts
|
|
242
244
|
|
|
243
|
-
Three commands, no policy engine, no containers, no client changes
|
|
245
|
+
Three commands, no policy engine, no containers, no client changes
|
|
246
|
+
(`--observe` starts a deployment in record-only mode, every call allowed
|
|
247
|
+
including destructive ones and every call recorded; `gargoyle audit
|
|
248
|
+
--summary` then shows calls by principal and tool, the input for the
|
|
249
|
+
hold and deny rules):
|
|
244
250
|
|
|
245
251
|
gargoyle init /path/the/agent/may/touch # keys, config, rules.json
|
|
252
|
+
gargoyle init --observe /path/... # or: allow and record everything, hold nothing, tighten later
|
|
246
253
|
gargoyle issuer # the issuing service, on rules.json
|
|
247
254
|
gargoyle approve --watch 2 # where the approver sits (terminal)
|
|
248
255
|
|
|
@@ -260,7 +267,11 @@ stay on file so earlier grants still audit; new ones are refused at the
|
|
|
260
267
|
issuer and at every middleware on its next held call. Revocation retires
|
|
261
268
|
a key, not a person: while the Approver role stands, the person can sign
|
|
262
269
|
in and enroll a fresh key, so remove the role too; the revoked key itself
|
|
263
|
-
is never accepted again.
|
|
270
|
+
is never accepted again. The role is what counts at release time: a
|
|
271
|
+
grant under an enrolled key is accepted only from that account, under a
|
|
272
|
+
token that carries the role now, so a role removed in the directory ends
|
|
273
|
+
the power at once. One key per account: a second browser does not enroll
|
|
274
|
+
until the first key consents or the operator revokes it. Every key an approver held stays in the file
|
|
264
275
|
(`history`), so an audit of grants released under an earlier key still
|
|
265
276
|
verifies: copy the issuer's current `approvers.json` beside the evidence
|
|
266
277
|
before auditing. Approvers, and
|
|
@@ -359,6 +370,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
359
370
|
| 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` |
|
|
360
371
|
| 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 |
|
|
361
372
|
| 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 |
|
|
373
|
+
| 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 |
|
|
362
374
|
| 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 |
|
|
363
375
|
| issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
|
|
364
376
|
| `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 |
|
|
@@ -367,7 +379,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
367
379
|
| `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
|
|
368
380
|
| `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
|
|
369
381
|
| `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 |
|
|
370
|
-
| `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 |
|
|
382
|
+
| `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 |
|
|
371
383
|
|
|
372
384
|
**Issuing service** (`gargoyle.issuer_service`, run with uvicorn from
|
|
373
385
|
`app_from_env()`): identity token in, receipt out, with the hold and grant
|
|
@@ -415,7 +427,7 @@ on it.
|
|
|
415
427
|
| `mcp_server` | the stdio MCP middleware (and demo executor), with self-issue mode |
|
|
416
428
|
| `fs_server` | a small Python filesystem MCP server, for clients without Node |
|
|
417
429
|
| `issuer_service` | the HTTP issuing service: identity in, receipts out, holds and grants |
|
|
418
|
-
| `replay_cache`, `pending_store` | single use for receipts and holds; in
|
|
430
|
+
| `replay_cache`, `pending_store` | single use for receipts and holds; in memory, SQLite beside the audit log (the middleware's default) or Redis |
|
|
419
431
|
| `approval`, `approver_cli`, `approver_trust` | grants, the human's tool, approver key trust |
|
|
420
432
|
| `published_record_source` | the live policy pin as a publisher-signed record with serial and expiry |
|
|
421
433
|
| `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
|
|
@@ -51,6 +51,17 @@ from pathlib import Path
|
|
|
51
51
|
|
|
52
52
|
PRINCIPAL = "spiffe://gargoyle.local/agents/assistant"
|
|
53
53
|
|
|
54
|
+
OBSERVE_RULES = {
|
|
55
|
+
# Observe mode: every call allowed (deletes too), every call recorded, none held. The
|
|
56
|
+
# first week of a deployment, so the hold and deny rules are written
|
|
57
|
+
# from the audit rather than guessed. The policy pin changes when the
|
|
58
|
+
# rules do, so switching modes means restarting the issuer and copying
|
|
59
|
+
# the new record.json to each client.
|
|
60
|
+
"version": "1",
|
|
61
|
+
"default": "allow",
|
|
62
|
+
"rules": [],
|
|
63
|
+
}
|
|
64
|
+
|
|
54
65
|
DEFAULT_RULES = {
|
|
55
66
|
"version": "1",
|
|
56
67
|
"default": "deny",
|
|
@@ -118,7 +129,22 @@ def cmd_init(args) -> int:
|
|
|
118
129
|
issuer_url = args.issuer_url or "http://%s:%d" % (args.host, args.port)
|
|
119
130
|
|
|
120
131
|
rules = out / "rules.json"
|
|
121
|
-
if
|
|
132
|
+
if args.observe:
|
|
133
|
+
current = None
|
|
134
|
+
if rules.exists():
|
|
135
|
+
try:
|
|
136
|
+
current = json.loads(rules.read_text())
|
|
137
|
+
except ValueError as exc:
|
|
138
|
+
print("%s is not valid JSON (%s); fix or remove it before --observe" % (rules, exc), file=sys.stderr)
|
|
139
|
+
return 2
|
|
140
|
+
if current is None or current == DEFAULT_RULES:
|
|
141
|
+
_write(rules, json.dumps(OBSERVE_RULES, indent=2) + "\n")
|
|
142
|
+
elif current != OBSERVE_RULES:
|
|
143
|
+
print("rules.json was edited by hand and is left as it is; --observe changed nothing", file=sys.stderr)
|
|
144
|
+
print("observe mode: rules.json allows every call, destructive ones included; the gate records all of "
|
|
145
|
+
"them and holds none. Tighten rules.json from `gargoyle audit --summary`, then restart the issuer "
|
|
146
|
+
"and copy the new record.json to the clients.")
|
|
147
|
+
elif not rules.exists():
|
|
122
148
|
_write(rules, json.dumps(DEFAULT_RULES, indent=2) + "\n")
|
|
123
149
|
_write(out / "issuer.env", _env_lines({
|
|
124
150
|
"GARGOYLE_ISSUER_KEY_ID": "issuer-1", "GARGOYLE_ISSUER_KEY_HEX": issuer_hex,
|
|
@@ -360,6 +386,33 @@ def cmd_approvers(args) -> int:
|
|
|
360
386
|
return 1
|
|
361
387
|
|
|
362
388
|
|
|
389
|
+
def _audit_summary(report, raw) -> None:
|
|
390
|
+
"""Calls by principal and tool with their outcomes, then who released
|
|
391
|
+
and which clients: a week of observe mode as a table, the input for
|
|
392
|
+
writing the hold and deny rules."""
|
|
393
|
+
from collections import Counter
|
|
394
|
+
counts: Dict[tuple, Counter] = {}
|
|
395
|
+
releases: Counter = Counter()
|
|
396
|
+
clients: Counter = Counter()
|
|
397
|
+
ats = [e.get("at") for e in raw if isinstance(e, dict) and isinstance(e.get("at"), str)]
|
|
398
|
+
from gargoyle.evidence import FAILED_VERDICTS
|
|
399
|
+
for r in report:
|
|
400
|
+
kind = r["kind"] or "?"
|
|
401
|
+
if kind == "honored" and r.get("verdict") in FAILED_VERDICTS:
|
|
402
|
+
kind = "failed" # admitted, but the upstream did not do it
|
|
403
|
+
counts.setdefault((r["principal"] or "?", r["tool"] or "?"), Counter())[kind] += 1
|
|
404
|
+
for who in r.get("released_by") or []:
|
|
405
|
+
releases[who] += 1
|
|
406
|
+
clients[r.get("client") or "(client unnamed)"] += 1
|
|
407
|
+
print("summary: %d call(s)%s" % (len(report), (", %s to %s" % (min(ats)[:19], max(ats)[:19])) if ats else ""))
|
|
408
|
+
print(" %-28s %-24s %7s %7s %7s" % ("principal", "tool", "honored", "failed", "refused"))
|
|
409
|
+
for (principal, tool), c in sorted(counts.items(), key=lambda kv: (kv[0][0], -sum(kv[1].values()), kv[0][1])):
|
|
410
|
+
print(" %-28s %-24s %7d %7d %7d" % (principal[:28], tool[:24], c.get("honored", 0), c.get("failed", 0), c.get("refused", 0)))
|
|
411
|
+
if releases:
|
|
412
|
+
print(" releases: " + ", ".join("%s %d" % (w, n) for w, n in releases.most_common()))
|
|
413
|
+
print(" clients: " + ", ".join("%s %d" % (w, n) for w, n in clients.most_common()))
|
|
414
|
+
|
|
415
|
+
|
|
363
416
|
def cmd_audit(args) -> int:
|
|
364
417
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
365
418
|
from gargoyle.execution import verify_chain
|
|
@@ -406,6 +459,8 @@ def cmd_audit(args) -> int:
|
|
|
406
459
|
% (v["count"], v["honored"], v["refused"], failed, v["reason"], v["tail_sha256"][:16]))
|
|
407
460
|
rc = 0 if v["accepted"] else 1
|
|
408
461
|
entries = read_log(args.log)
|
|
462
|
+
if args.summary:
|
|
463
|
+
_audit_summary(v["entries"], entries)
|
|
409
464
|
if args.witness and not Path(args.witness).exists():
|
|
410
465
|
print("witness file not found: %s (copy the issuer's witness.jsonl here first)" % args.witness, file=sys.stderr)
|
|
411
466
|
return 2
|
|
@@ -460,6 +515,8 @@ def main(argv=None) -> int:
|
|
|
460
515
|
p.add_argument("--issuer-url", help="URL the client reaches the issuer at (default http://HOST:PORT)")
|
|
461
516
|
p.add_argument("--client-python", help="the client's Python executable for the MCP entry (default: this one)")
|
|
462
517
|
p.add_argument("--client-config-dir", help="where the client keeps middleware.env, record.json, audit.jsonl (default: --out)")
|
|
518
|
+
p.add_argument("--observe", action="store_true",
|
|
519
|
+
help="observe mode: allow and record every call, hold none; tighten rules.json later from the audit")
|
|
463
520
|
p.add_argument("--approvers", type=int, default=1, metavar="N",
|
|
464
521
|
help="how many approver keys to create (approver-1..N); a rule with \"approvals\": 2 then needs two of them")
|
|
465
522
|
p.add_argument("--upstream", choices=["npx-filesystem", "python-fs"], default="npx-filesystem",
|
|
@@ -512,6 +569,7 @@ def main(argv=None) -> int:
|
|
|
512
569
|
p.add_argument("--log", help="the middleware's audit.jsonl: verify every entry")
|
|
513
570
|
p.add_argument("--witness", help="the issuer's witness.jsonl: reconcile the evidence against it")
|
|
514
571
|
p.add_argument("--check-tree", metavar="DIR", help="verify every gated file under DIR is as the gate left it")
|
|
572
|
+
p.add_argument("--summary", action="store_true", help="after the rows: calls by principal and tool with outcomes, releases, clients")
|
|
515
573
|
p.add_argument("--out", default="gargoyle-deploy")
|
|
516
574
|
p.set_defaults(fn=cmd_audit)
|
|
517
575
|
args = parser.parse_args(argv)
|
|
@@ -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")
|
|
@@ -191,6 +202,7 @@ def verify_log(
|
|
|
191
202
|
expect_seq, prev, tail = 1, GENESIS, GENESIS
|
|
192
203
|
counts = {HONORED: 0, REFUSED: 0}
|
|
193
204
|
all_ok = True
|
|
205
|
+
claimed: Dict[str, int] = {} # decision_id / plan step -> seq of the honored entry that spent it
|
|
194
206
|
|
|
195
207
|
for raw in entries:
|
|
196
208
|
if not _well_formed(raw):
|
|
@@ -220,6 +232,19 @@ def verify_log(
|
|
|
220
232
|
if ok and (rc.get("tool") != e["tool"] or rc.get("args_sha256") != e["args_sha256"]
|
|
221
233
|
or rc.get("principal") != e["principal"]):
|
|
222
234
|
ok, reason = False, REF_EVIDENCE_RECEIPT_MISSING
|
|
235
|
+
if ok:
|
|
236
|
+
# Single use, checked after the fact: a decision id, or a
|
|
237
|
+
# plan step, honored twice in one log is a replay the
|
|
238
|
+
# executor's cache missed (a second process, a lost cache).
|
|
239
|
+
keys = [str(e["receipt"].get("decision_id"))]
|
|
240
|
+
gs = e["grant"]
|
|
241
|
+
if isinstance(gs, dict) and "plan" in gs and "step" in gs:
|
|
242
|
+
keys.append("plan:%s:%s" % ((gs["plan"] or {}).get("plan_id"), gs["step"]))
|
|
243
|
+
if any(k in claimed for k in keys):
|
|
244
|
+
ok, reason = False, REF_EVIDENCE_REPLAY
|
|
245
|
+
else:
|
|
246
|
+
for k in keys:
|
|
247
|
+
claimed[k] = e["seq"]
|
|
223
248
|
else:
|
|
224
249
|
reason = ACCEPT_ENTRY_VERIFIED
|
|
225
250
|
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)
|