gargoyle-receipts 0.16.4.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.4.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.17.0.dev0}/PKG-INFO +13 -6
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/README.md +12 -5
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/adapters.py +4 -3
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/cli.py +7 -3
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/envelope.py +7 -3
- {gargoyle_receipts-0.16.4.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.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/issuer_service.py +151 -25
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/mcp_server.py +22 -5
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/plans.py +22 -2
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/policy.py +3 -1
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/reconcile.py +8 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/replay_cache.py +42 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/rules.py +156 -1
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/state.py +17 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/static/approve.html +41 -7
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +13 -6
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +4 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/pyproject.toml +1 -1
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_approvers.py +52 -3
- gargoyle_receipts-0.17.0.dev0/tests/test_globs.py +46 -0
- {gargoyle_receipts-0.16.4.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.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_threshold.py +2 -2
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/__main__.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/approval.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/approvers.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/consumer.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/execution.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/holdstore.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/identity.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/published_record_source.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/remote.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/revert.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/static/approve.js +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/verifier.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/witness.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_cli.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_console_js.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_entra_identity.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_evidence.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_graph.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_historical.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_issuer_service.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_mcp_self_issue.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_remote.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_revert.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_rules.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_state.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_wire_spec.py +0 -0
- {gargoyle_receipts-0.16.4.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_witness.py +0 -0
{gargoyle_receipts-0.16.4.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
|
|
|
@@ -303,7 +305,11 @@ stay on file so earlier grants still audit; new ones are refused at the
|
|
|
303
305
|
issuer and at every middleware on its next held call. Revocation retires
|
|
304
306
|
a key, not a person: while the Approver role stands, the person can sign
|
|
305
307
|
in and enroll a fresh key, so remove the role too; the revoked key itself
|
|
306
|
-
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
|
|
307
313
|
(`history`), so an audit of grants released under an earlier key still
|
|
308
314
|
verifies: copy the issuer's current `approvers.json` beside the evidence
|
|
309
315
|
before auditing. Approvers, and
|
|
@@ -402,6 +408,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
402
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` |
|
|
403
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 |
|
|
404
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 |
|
|
405
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 |
|
|
406
413
|
| issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
|
|
407
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 |
|
|
@@ -410,7 +417,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
410
417
|
| `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
|
|
411
418
|
| `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
|
|
412
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 |
|
|
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 |
|
|
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 |
|
|
414
421
|
|
|
415
422
|
**Issuing service** (`gargoyle.issuer_service`, run with uvicorn from
|
|
416
423
|
`app_from_env()`): identity token in, receipt out, with the hold and grant
|
|
@@ -458,7 +465,7 @@ on it.
|
|
|
458
465
|
| `mcp_server` | the stdio MCP middleware (and demo executor), with self-issue mode |
|
|
459
466
|
| `fs_server` | a small Python filesystem MCP server, for clients without Node |
|
|
460
467
|
| `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
|
|
468
|
+
| `replay_cache`, `pending_store` | single use for receipts and holds; in memory, SQLite beside the audit log (the middleware's default) or Redis |
|
|
462
469
|
| `approval`, `approver_cli`, `approver_trust` | grants, the human's tool, approver key trust |
|
|
463
470
|
| `published_record_source` | the live policy pin as a publisher-signed record with serial and expiry |
|
|
464
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
|
|
|
@@ -265,7 +267,11 @@ stay on file so earlier grants still audit; new ones are refused at the
|
|
|
265
267
|
issuer and at every middleware on its next held call. Revocation retires
|
|
266
268
|
a key, not a person: while the Approver role stands, the person can sign
|
|
267
269
|
in and enroll a fresh key, so remove the role too; the revoked key itself
|
|
268
|
-
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
|
|
269
275
|
(`history`), so an audit of grants released under an earlier key still
|
|
270
276
|
verifies: copy the issuer's current `approvers.json` beside the evidence
|
|
271
277
|
before auditing. Approvers, and
|
|
@@ -364,6 +370,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
364
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` |
|
|
365
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 |
|
|
366
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 |
|
|
367
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 |
|
|
368
375
|
| issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
|
|
369
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 |
|
|
@@ -372,7 +379,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
372
379
|
| `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
|
|
373
380
|
| `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
|
|
374
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 |
|
|
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 |
|
|
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 |
|
|
376
383
|
|
|
377
384
|
**Issuing service** (`gargoyle.issuer_service`, run with uvicorn from
|
|
378
385
|
`app_from_env()`): identity token in, receipt out, with the hold and grant
|
|
@@ -420,7 +427,7 @@ on it.
|
|
|
420
427
|
| `mcp_server` | the stdio MCP middleware (and demo executor), with self-issue mode |
|
|
421
428
|
| `fs_server` | a small Python filesystem MCP server, for clients without Node |
|
|
422
429
|
| `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
|
|
430
|
+
| `replay_cache`, `pending_store` | single use for receipts and holds; in memory, SQLite beside the audit log (the middleware's default) or Redis |
|
|
424
431
|
| `approval`, `approver_cli`, `approver_trust` | grants, the human's tool, approver key trust |
|
|
425
432
|
| `published_record_source` | the live policy pin as a publisher-signed record with serial and expiry |
|
|
426
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
|
|
@@ -395,15 +395,19 @@ def _audit_summary(report, raw) -> None:
|
|
|
395
395
|
releases: Counter = Counter()
|
|
396
396
|
clients: Counter = Counter()
|
|
397
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
|
|
398
399
|
for r in report:
|
|
399
|
-
|
|
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
|
|
400
404
|
for who in r.get("released_by") or []:
|
|
401
405
|
releases[who] += 1
|
|
402
406
|
clients[r.get("client") or "(client unnamed)"] += 1
|
|
403
407
|
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"))
|
|
408
|
+
print(" %-28s %-24s %7s %7s %7s" % ("principal", "tool", "honored", "failed", "refused"))
|
|
405
409
|
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)))
|
|
410
|
+
print(" %-28s %-24s %7d %7d %7d" % (principal[:28], tool[:24], c.get("honored", 0), c.get("failed", 0), c.get("refused", 0)))
|
|
407
411
|
if releases:
|
|
408
412
|
print(" releases: " + ", ".join("%s %d" % (w, n) for w, n in releases.most_common()))
|
|
409
413
|
print(" clients: " + ", ".join("%s %d" % (w, n) for w, n in clients.most_common()))
|
|
@@ -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)
|
|
@@ -53,7 +53,7 @@ STATIC = Path(__file__).resolve().parent / "static"
|
|
|
53
53
|
from gargoyle.approval import (REF_APPROVAL_APPROVER_NOT_ELIGIBLE, REF_APPROVAL_DUPLICATE_APPROVER,
|
|
54
54
|
verify_grant)
|
|
55
55
|
from gargoyle.envelope import approval_terms
|
|
56
|
-
from gargoyle.state import state_sha256, valid_description
|
|
56
|
+
from gargoyle.state import state_sha256, text_consistent, valid_description
|
|
57
57
|
from gargoyle.holdstore import ENV_HOLDS_PATH, HoldStore
|
|
58
58
|
from gargoyle.witness import WitnessLog
|
|
59
59
|
from gargoyle.plans import (REF_PLAN_MALFORMED, REF_PLAN_STEP_NOT_PLANNABLE, REF_PLAN_STEP_REFUSED, build_plan,
|
|
@@ -62,6 +62,7 @@ from gargoyle.notify import notifier_from_env
|
|
|
62
62
|
from gargoyle.approvers import (DEFAULT_APPROVER_ROLE, ENV_APPROVER_GROUPS, ENV_APPROVER_ROLE, ENV_APPROVERS_PATH,
|
|
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
|
+
from gargoyle.jws import REF_JWS_KEY_UNKNOWN
|
|
65
66
|
from gargoyle.issuer import DEFAULT_TTL_SECONDS, issue
|
|
66
67
|
from gargoyle.policy import Policy
|
|
67
68
|
|
|
@@ -80,6 +81,7 @@ ENV_RECEIPT_TTL = "GARGOYLE_RECEIPT_TTL_SECONDS"
|
|
|
80
81
|
ENV_CONSOLE_CLIENT_ID = "GARGOYLE_ENTRA_CLIENT_ID" # the public client app the console signs in with (User-Client-Flow)
|
|
81
82
|
ENV_CONSOLE_SCOPE = "GARGOYLE_ENTRA_SCOPE" # default api://<last audience>/access
|
|
82
83
|
ENV_MAX_OPEN_HOLDS = "GARGOYLE_MAX_OPEN_HOLDS" # holds one principal may have waiting at once
|
|
84
|
+
ENV_ALLOW_SELF_APPROVAL = "GARGOYLE_ALLOW_SELF_APPROVAL" # "1": a person may release their own call (default: refused)
|
|
83
85
|
DEFAULT_MAX_OPEN_HOLDS = 20
|
|
84
86
|
|
|
85
87
|
REF_ISSUER_UNAUTHENTICATED = "REF_ISSUER_UNAUTHENTICATED"
|
|
@@ -91,6 +93,31 @@ REF_ISSUER_NOT_AN_APPROVER = "REF_ISSUER_NOT_AN_APPROVER" # enrollment without
|
|
|
91
93
|
REF_ISSUER_FORBIDDEN = "REF_ISSUER_FORBIDDEN" # authenticated, but not an approver and not the hold's own principal
|
|
92
94
|
REF_ISSUER_TOO_MANY_HOLDS = "REF_ISSUER_TOO_MANY_HOLDS" # the principal already has the cap's worth of holds waiting
|
|
93
95
|
REF_ISSUER_KEY_REVOKED = "REF_ISSUER_KEY_REVOKED" # enrollment offered a key that was revoked; a new key is needed
|
|
96
|
+
REF_ISSUER_KEY_ENROLLED = "REF_ISSUER_KEY_ENROLLED" # another key is enrolled for this account; it must consent, or the operator revoke it
|
|
97
|
+
REF_ISSUER_HOLD_EXPIRED = "REF_ISSUER_HOLD_EXPIRED" # the hold's receipt or plan expired; nothing can release it now
|
|
98
|
+
REF_ISSUER_SELF_APPROVAL = "REF_ISSUER_SELF_APPROVAL" # the approver is the hold's requester (or the party acted for)
|
|
99
|
+
REF_ISSUER_STATE_TEXT_MISMATCH = "REF_ISSUER_STATE_TEXT_MISMATCH" # the state text is not the bytes the state hash names
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
SUPERSEDE_PREFIX = b"gargoyle-enroll/1\n"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def supersede_message(new_public_key_hex: str) -> bytes:
|
|
106
|
+
"""What an enrolled key signs to hand its account to a new key."""
|
|
107
|
+
return SUPERSEDE_PREFIX + new_public_key_hex.encode("ascii")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _supersedes(current_hex: str, signature_hex: Any, new_hex: str) -> bool:
|
|
111
|
+
"""Does `signature_hex` (Ed25519, by the key `current_hex`) consent to `new_hex`?"""
|
|
112
|
+
from cryptography.exceptions import InvalidSignature
|
|
113
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
114
|
+
if not isinstance(signature_hex, str) or len(signature_hex) != 128:
|
|
115
|
+
return False
|
|
116
|
+
try:
|
|
117
|
+
Ed25519PublicKey.from_public_bytes(bytes.fromhex(current_hex)).verify(bytes.fromhex(signature_hex), supersede_message(new_hex))
|
|
118
|
+
return True
|
|
119
|
+
except (InvalidSignature, ValueError):
|
|
120
|
+
return False
|
|
94
121
|
|
|
95
122
|
|
|
96
123
|
def build_issuer_app(
|
|
@@ -114,10 +141,19 @@ def build_issuer_app(
|
|
|
114
141
|
tenant: Optional[str] = None,
|
|
115
142
|
tenant_key_ids: Optional[set] = None,
|
|
116
143
|
max_open_holds: int = DEFAULT_MAX_OPEN_HOLDS,
|
|
144
|
+
idp_key_refresh: Optional[Callable[[], Dict[str, Any]]] = None,
|
|
145
|
+
allow_self_approval: bool = False,
|
|
117
146
|
) -> FastAPI:
|
|
118
147
|
"""`on_hold(envelope, args)` is called once for every new hold (gargoyle.notify
|
|
119
148
|
posts it to a webhook); it is notice, not admission, and its failure
|
|
120
|
-
never affects the hold.
|
|
149
|
+
never affects the hold. `idp_key_refresh()` returns the identity
|
|
150
|
+
provider's current signing keys {kid: key}; it is called, at most once a
|
|
151
|
+
minute, when a token names a kid the service does not know, so a key the
|
|
152
|
+
directory rolled after startup is picked up without a restart."""
|
|
153
|
+
# The trusted identity keys are held in a dict this app owns: a refresh adds to them.
|
|
154
|
+
idp_public_keys = dict(idp_public_keys)
|
|
155
|
+
tenant_key_ids = set(tenant_key_ids) if tenant_key_ids is not None else None
|
|
156
|
+
key_refreshed_at: list = [None]
|
|
121
157
|
app = FastAPI(title="Gargoyle issuing service")
|
|
122
158
|
# Everything a human may be in the middle of acting on lives in the hold
|
|
123
159
|
# store (gargoyle.holdstore): SQLite on disk in a deployment, memory in tests.
|
|
@@ -170,9 +206,21 @@ def build_issuer_app(
|
|
|
170
206
|
|
|
171
207
|
def is_approver(who: Dict[str, Any]) -> bool:
|
|
172
208
|
"""May this identity see every hold and the witness: the approver role
|
|
173
|
-
or group, a configured approver key id
|
|
209
|
+
or group in today's token, or a configured approver key id. An
|
|
210
|
+
enrolled key alone does not count: the directory decides who is an
|
|
211
|
+
approver now, the registry only remembers which key is theirs."""
|
|
174
212
|
upn = who["principal"]
|
|
175
|
-
return may_enroll(who, role=approver_role, groups=approver_groups) or upn in static_approvers
|
|
213
|
+
return may_enroll(who, role=approver_role, groups=approver_groups) or upn in static_approvers
|
|
214
|
+
|
|
215
|
+
def _enrolled_speaks_for(who: Dict[str, Any], approver_key_id: str) -> bool:
|
|
216
|
+
"""May a grant under this key id be posted by `who`? A configured
|
|
217
|
+
(static) key is trusted by configuration. An enrolled key releases a
|
|
218
|
+
hold only when posted by its own account, under a token that still
|
|
219
|
+
carries the approver role or group: losing the role ends the power
|
|
220
|
+
at once, whatever key record remains (second review, finding 2)."""
|
|
221
|
+
if approver_key_id in static_approvers or approver_key_id not in registry:
|
|
222
|
+
return True
|
|
223
|
+
return who["principal"] == approver_key_id and may_enroll(who, role=approver_role, groups=approver_groups)
|
|
176
224
|
|
|
177
225
|
def hold_owner(arid: str) -> Optional[str]:
|
|
178
226
|
"""The principal whose call is held under `arid` (a hold or a plan), or None."""
|
|
@@ -200,11 +248,35 @@ def build_issuer_app(
|
|
|
200
248
|
def _refuse(status: int, reason: str) -> HTTPException:
|
|
201
249
|
return HTTPException(status_code=status, detail={"reason": reason})
|
|
202
250
|
|
|
251
|
+
def _refresh_idp_keys() -> bool:
|
|
252
|
+
"""Fetch the identity provider's keys again (an unknown kid is how a
|
|
253
|
+
key roll shows up), at most once a minute. True when new keys came."""
|
|
254
|
+
if idp_key_refresh is None:
|
|
255
|
+
return False
|
|
256
|
+
current = now()
|
|
257
|
+
last = key_refreshed_at[0]
|
|
258
|
+
if last is not None and (current - last).total_seconds() < 60:
|
|
259
|
+
return False
|
|
260
|
+
key_refreshed_at[0] = current
|
|
261
|
+
try:
|
|
262
|
+
fresh = idp_key_refresh()
|
|
263
|
+
except Exception as exc:
|
|
264
|
+
import sys
|
|
265
|
+
sys.stderr.write("gargoyle: identity key refresh failed: %s\n" % exc)
|
|
266
|
+
return False
|
|
267
|
+
new = {k: v for k, v in fresh.items() if k not in idp_public_keys}
|
|
268
|
+
idp_public_keys.update(fresh)
|
|
269
|
+
if tenant_key_ids is not None:
|
|
270
|
+
tenant_key_ids.update(fresh)
|
|
271
|
+
return bool(new)
|
|
272
|
+
|
|
203
273
|
def authenticate(authorization: Optional[str]) -> Dict[str, Any]:
|
|
204
274
|
if not authorization or not authorization.startswith("Bearer "):
|
|
205
275
|
raise _refuse(401, REF_ISSUER_UNAUTHENTICATED)
|
|
206
|
-
|
|
207
|
-
|
|
276
|
+
token = authorization[7:].strip()
|
|
277
|
+
who = principal_from_token(token, idp_public_keys, audience=audience, now=now(), tenant=tenant, tenant_key_ids=tenant_key_ids)
|
|
278
|
+
if who["reason"] == REF_JWS_KEY_UNKNOWN and _refresh_idp_keys():
|
|
279
|
+
who = principal_from_token(token, idp_public_keys, audience=audience, now=now(), tenant=tenant, tenant_key_ids=tenant_key_ids)
|
|
208
280
|
if who["reason"] is not None:
|
|
209
281
|
raise _refuse(401, who["reason"])
|
|
210
282
|
return who
|
|
@@ -227,11 +299,17 @@ def build_issuer_app(
|
|
|
227
299
|
json.dumps(body["args"], ensure_ascii=False).encode("utf-8")
|
|
228
300
|
except UnicodeEncodeError:
|
|
229
301
|
raise _refuse(400, REF_ISSUER_ARGS_NOT_TEXT)
|
|
230
|
-
# The policy sees the identity provider's groups and roles
|
|
231
|
-
#
|
|
232
|
-
|
|
302
|
+
# The policy sees the identity provider's groups and roles; the receipt
|
|
303
|
+
# binds only the call and the principal (and the token's delegation).
|
|
304
|
+
# Anything the caller asserts in `claims` is kept apart under
|
|
305
|
+
# interaction["claims"]: it never sets principal, on_behalf_of,
|
|
306
|
+
# groups, roles or the state hash, which come from the token and the
|
|
307
|
+
# described state alone (second review, finding 3).
|
|
308
|
+
interaction = {"tool": body["tool"], "args": body["args"],
|
|
233
309
|
"principal": who["principal"], "groups": who.get("groups", []),
|
|
234
310
|
"roles": who.get("roles", [])}
|
|
311
|
+
if body.get("claims"):
|
|
312
|
+
interaction["claims"] = dict(body["claims"])
|
|
235
313
|
state = body.get("state")
|
|
236
314
|
if state is not None:
|
|
237
315
|
# The executor's description of the target before the call: its
|
|
@@ -239,6 +317,9 @@ def build_issuer_app(
|
|
|
239
317
|
# the approver and seen by the policy.
|
|
240
318
|
if not valid_description(state):
|
|
241
319
|
raise _refuse(400, REF_ISSUER_BAD_REQUEST)
|
|
320
|
+
if not text_consistent(state):
|
|
321
|
+
# The text shown to the approver must hash to the bound state.
|
|
322
|
+
raise _refuse(400, REF_ISSUER_STATE_TEXT_MISMATCH)
|
|
242
323
|
interaction["state_sha256"] = state_sha256(state)
|
|
243
324
|
interaction["state"] = {k: v for k, v in state.items() if k != "text"}
|
|
244
325
|
if who["on_behalf_of"]:
|
|
@@ -250,15 +331,18 @@ def build_issuer_app(
|
|
|
250
331
|
return {"issued": False, "reason": result.decision.reason}
|
|
251
332
|
covered = False
|
|
252
333
|
plan_id = body.get("plan_id")
|
|
253
|
-
if result.envelope["requires_approval"] and isinstance(plan_id, str):
|
|
254
|
-
# The executor says an approved plan covers this call. No card then
|
|
255
|
-
# the
|
|
256
|
-
#
|
|
257
|
-
#
|
|
258
|
-
#
|
|
259
|
-
|
|
334
|
+
if result.envelope["requires_approval"] and isinstance(plan_id, str) and result.decision.plannable:
|
|
335
|
+
# The executor says an approved plan covers this call. No card then,
|
|
336
|
+
# provided the plan's grants meet this receipt's own terms (as many
|
|
337
|
+
# signatures, from its named approvers): the executor enforces the
|
|
338
|
+
# same, so a hold here would be one nobody needs to act on. A call
|
|
339
|
+
# the policy keeps individual is never covered. A false claim costs
|
|
340
|
+
# nothing: without a valid release the executor refuses the call.
|
|
341
|
+
from gargoyle.plans import grants_satisfy, match_step
|
|
342
|
+
env_terms = approval_terms(result.envelope)
|
|
260
343
|
for arid_p, plan in app.state.pending_plans.items():
|
|
261
|
-
if plan["plan_id"] == plan_id and _complete(arid_p) and match_step(plan, body["tool"], body["args"]) is not None
|
|
344
|
+
if (plan["plan_id"] == plan_id and _complete(arid_p) and match_step(plan, body["tool"], body["args"]) is not None
|
|
345
|
+
and grants_satisfy(env_terms, [g["approver_key_id"] for g in app.state.grants.get(arid_p, [])])):
|
|
262
346
|
covered = True
|
|
263
347
|
break
|
|
264
348
|
if result.envelope["requires_approval"] and not covered and _open_holds(who["principal"]) >= max_open_holds:
|
|
@@ -312,7 +396,16 @@ def build_issuer_app(
|
|
|
312
396
|
interaction = {"tool": s["tool"], "args": judged_args, "principal": who["principal"],
|
|
313
397
|
"groups": who.get("groups", []), "roles": who.get("roles", [])}
|
|
314
398
|
try:
|
|
315
|
-
|
|
399
|
+
if "match" in step:
|
|
400
|
+
# A pattern step is judged by the strictest rule any call
|
|
401
|
+
# matching it could reach (gargoyle.rules.decide_rules_pattern).
|
|
402
|
+
# A policy that cannot judge patterns keeps them out of plans.
|
|
403
|
+
decide_pattern = getattr(policy, "decide_pattern", None)
|
|
404
|
+
if decide_pattern is None:
|
|
405
|
+
return {"issued": False, "reason": REF_PLAN_STEP_NOT_PLANNABLE, "step": len(norm)}
|
|
406
|
+
d = decide_pattern(body["target_url"], interaction)
|
|
407
|
+
else:
|
|
408
|
+
d = policy.decide(body["target_url"], interaction)
|
|
316
409
|
except Exception:
|
|
317
410
|
return {"issued": False, "reason": REF_PLAN_STEP_REFUSED}
|
|
318
411
|
if not d.eligible:
|
|
@@ -355,8 +448,7 @@ def build_issuer_app(
|
|
|
355
448
|
live = {}
|
|
356
449
|
for arid, plan in list(app.state.pending_plans.items()):
|
|
357
450
|
if datetime.fromisoformat(plan["not_after"]) <= current:
|
|
358
|
-
|
|
359
|
-
app.state.grants.pop(arid, None)
|
|
451
|
+
_forget(arid)
|
|
360
452
|
elif len(app.state.grants.get(arid, [])) < _plan_terms(plan)["required"]:
|
|
361
453
|
if not sees_all and plan.get("principal") != who["principal"]:
|
|
362
454
|
continue
|
|
@@ -365,10 +457,7 @@ def build_issuer_app(
|
|
|
365
457
|
"approvers": [g["approver_key_id"] for g in have]}
|
|
366
458
|
for arid, env in list(app.state.pending.items()):
|
|
367
459
|
if datetime.fromisoformat(env["not_after"]) <= current:
|
|
368
|
-
|
|
369
|
-
app.state.pending_args.pop(arid, None)
|
|
370
|
-
app.state.pending_state.pop(arid, None)
|
|
371
|
-
app.state.grants.pop(arid, None)
|
|
460
|
+
_forget(arid) # an expired hold can never be released
|
|
372
461
|
elif not _complete(arid):
|
|
373
462
|
if not sees_all and env.get("request_context", {}).get("principal") != who["principal"]:
|
|
374
463
|
continue
|
|
@@ -379,6 +468,14 @@ def build_issuer_app(
|
|
|
379
468
|
"approvers": [g["approver_key_id"] for g in have]}
|
|
380
469
|
return {"pending": list(live.values())}
|
|
381
470
|
|
|
471
|
+
def _forget(arid: str) -> None:
|
|
472
|
+
"""Drop a hold or plan and everything kept for it."""
|
|
473
|
+
app.state.pending.pop(arid, None)
|
|
474
|
+
app.state.pending_args.pop(arid, None)
|
|
475
|
+
app.state.pending_state.pop(arid, None)
|
|
476
|
+
app.state.pending_plans.pop(arid, None)
|
|
477
|
+
app.state.grants.pop(arid, None)
|
|
478
|
+
|
|
382
479
|
def _complete(arid: str) -> bool:
|
|
383
480
|
env = app.state.pending.get(arid)
|
|
384
481
|
if env is None:
|
|
@@ -388,7 +485,7 @@ def build_issuer_app(
|
|
|
388
485
|
|
|
389
486
|
@app.post("/grants")
|
|
390
487
|
async def post_grant(request: Request, authorization: Optional[str] = Header(default=None)):
|
|
391
|
-
authenticate(authorization)
|
|
488
|
+
who = authenticate(authorization)
|
|
392
489
|
try:
|
|
393
490
|
grant = await request.json()
|
|
394
491
|
except Exception:
|
|
@@ -404,6 +501,13 @@ def build_issuer_app(
|
|
|
404
501
|
kid = grant.get("approver_key_id")
|
|
405
502
|
event("grant_refused", approval_request_id=arid, reason=reason, **({"approver": kid} if isinstance(kid, str) else {}))
|
|
406
503
|
return {"accepted": False, "reason": reason}
|
|
504
|
+
if datetime.fromisoformat((env or plan)["not_after"]) <= now():
|
|
505
|
+
# An expired hold can never be released: the executor refuses the
|
|
506
|
+
# receipt whatever the grant says, so accepting one here would
|
|
507
|
+
# only tell the approver that something ran. Drop it now, not on
|
|
508
|
+
# the next listing (second review, finding 10).
|
|
509
|
+
_forget(arid)
|
|
510
|
+
return refused(REF_ISSUER_HOLD_EXPIRED)
|
|
407
511
|
trusted = approver_keys()
|
|
408
512
|
if not trusted:
|
|
409
513
|
return refused(REF_ISSUER_NO_APPROVER_TRUST)
|
|
@@ -412,6 +516,17 @@ def build_issuer_app(
|
|
|
412
516
|
gate_key_id=key_id, now=now())
|
|
413
517
|
if not g["accepted"]:
|
|
414
518
|
return refused(g["reason"])
|
|
519
|
+
if not _enrolled_speaks_for(who, grant["approver_key_id"]):
|
|
520
|
+
return refused(REF_ISSUER_NOT_AN_APPROVER)
|
|
521
|
+
if not allow_self_approval:
|
|
522
|
+
# Two people: the one asking and the one releasing. The approver
|
|
523
|
+
# key id (a UPN for an enrolled key) may be neither the hold's
|
|
524
|
+
# principal nor the party it acts for (second review, finding 8).
|
|
525
|
+
parties = {hold_owner(arid)}
|
|
526
|
+
if env is not None:
|
|
527
|
+
parties.add((env.get("request_context") or {}).get("on_behalf_of"))
|
|
528
|
+
if grant["approver_key_id"] in parties:
|
|
529
|
+
return refused(REF_ISSUER_SELF_APPROVAL)
|
|
415
530
|
# The receipt's (or plan's) own terms: distinct approvers, from the named set if any.
|
|
416
531
|
terms = approval_terms(env) if env else _plan_terms(plan)
|
|
417
532
|
have = list(app.state.grants.get(arid, []))
|
|
@@ -483,6 +598,15 @@ def build_issuer_app(
|
|
|
483
598
|
raise _refuse(400, REF_ISSUER_BAD_REQUEST)
|
|
484
599
|
if not isinstance(body, dict) or not valid_public_key_hex(body.get("public_key_hex")):
|
|
485
600
|
raise _refuse(400, REF_ISSUER_BAD_REQUEST)
|
|
601
|
+
current = registry.public_hex().get(who["principal"])
|
|
602
|
+
if current is not None and current != body["public_key_hex"]:
|
|
603
|
+
# A bearer token alone must not swap the key holds are released
|
|
604
|
+
# with: a second browser's key enrolls only with the first key's
|
|
605
|
+
# signature over it (`supersede_signature`), or after the operator
|
|
606
|
+
# revoked the first (`gargoyle approvers revoke <upn>`).
|
|
607
|
+
if not _supersedes(current, body.get("supersede_signature"), body["public_key_hex"]):
|
|
608
|
+
event("enroll_refused", approver=who["principal"], reason=REF_ISSUER_KEY_ENROLLED, oid=who.get("oid"))
|
|
609
|
+
raise _refuse(409, REF_ISSUER_KEY_ENROLLED)
|
|
486
610
|
try:
|
|
487
611
|
result = registry.enroll(who["principal"], body["public_key_hex"], now=now(), oid=who.get("oid"), tid=who.get("tid"))
|
|
488
612
|
except RevokedKeyError:
|
|
@@ -644,6 +768,7 @@ def app_from_env() -> FastAPI:
|
|
|
644
768
|
on_hold=notifier_from_env(os.environ),
|
|
645
769
|
hold_store=HoldStore(os.environ.get(ENV_HOLDS_PATH) or None),
|
|
646
770
|
max_open_holds=int(os.environ.get(ENV_MAX_OPEN_HOLDS, DEFAULT_MAX_OPEN_HOLDS)),
|
|
771
|
+
allow_self_approval=os.environ.get(ENV_ALLOW_SELF_APPROVAL, "").strip().lower() in ("1", "true", "yes"),
|
|
647
772
|
witness=(WitnessLog(os.environ["GARGOYLE_WITNESS_PATH"], Ed25519PrivateKey.from_private_bytes(bytes.fromhex(required[ENV_ISSUER_KEY_HEX])),
|
|
648
773
|
required[ENV_ISSUER_KEY_ID]) if os.environ.get("GARGOYLE_WITNESS_PATH") else None),
|
|
649
774
|
log_events=(lambda e: sys.stderr.write(json.dumps(e, sort_keys=True) + "\n")) if os.environ.get("GARGOYLE_LOG_JSON") else None,
|
|
@@ -652,6 +777,7 @@ def app_from_env() -> FastAPI:
|
|
|
652
777
|
approver_groups=[g.strip() for g in os.environ.get(ENV_APPROVER_GROUPS, "").split(",") if g.strip()] or None,
|
|
653
778
|
tenant=tenant_id,
|
|
654
779
|
tenant_key_ids=set(tenant_keys) if tenant else None,
|
|
780
|
+
idp_key_refresh=(lambda: entra_keys(tenant_id)) if tenant else None,
|
|
655
781
|
console_entra=({"tenant": tenant, "client_id": os.environ[ENV_CONSOLE_CLIENT_ID],
|
|
656
782
|
"scope": os.environ.get(ENV_CONSOLE_SCOPE) or "api://%s/access" % (os.environ.get(ENV_AUDIENCE) or "").split(",")[-1].strip()}
|
|
657
783
|
if tenant and os.environ.get(ENV_CONSOLE_CLIENT_ID) else None),
|