gargoyle-receipts 0.16.0.dev0__tar.gz → 0.16.3.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.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.16.3.dev0}/PKG-INFO +10 -3
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/README.md +9 -2
- gargoyle_receipts-0.16.3.dev0/gargoyle/__main__.py +9 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/approval.py +19 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/approvers.py +66 -4
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/cli.py +15 -10
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/issuer_service.py +80 -6
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/static/approve.html +13 -3
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0/gargoyle_receipts.egg-info}/PKG-INFO +10 -3
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +1 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/pyproject.toml +1 -1
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_approvers.py +73 -2
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_cli.py +7 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_issuer_service.py +67 -1
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/adapters.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/consumer.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/envelope.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/evidence.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/execution.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/holdstore.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/identity.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/mcp_server.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/plans.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/policy.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/published_record_source.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/reconcile.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/remote.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/replay_cache.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/revert.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/rules.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/specs/graph-reference.json +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/state.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/static/approve.js +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/verifier.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/witness.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_console_js.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_entra_identity.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_evidence.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_graph.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_historical.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_mcp_self_issue.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_plans.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_remote.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_revert.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_rules.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_state.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_threshold.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_wire_spec.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_witness.py +0 -0
{gargoyle_receipts-0.16.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.16.3.dev0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gargoyle-receipts
|
|
3
|
-
Version: 0.16.
|
|
3
|
+
Version: 0.16.3.dev0
|
|
4
4
|
Summary: Signed, single-use, argument-bound receipts and human-approval holds for agent tool calls.
|
|
5
5
|
Author: Justin LaPorte
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -59,7 +59,7 @@ your MCP server and lets through only what both agreed to.
|
|
|
59
59
|
|
|
60
60
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
61
61
|
|
|
62
|
-
Status: pre-release (`0.16.
|
|
62
|
+
Status: pre-release (`0.16.3.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
|
|
@@ -295,7 +295,13 @@ person. An approver retires a lost or replaced key with
|
|
|
295
295
|
anyone with `gargoyle approvers revoke <upn>` on the issuer's host, and
|
|
296
296
|
the running service picks the change up without a restart. Revoked keys
|
|
297
297
|
stay on file so earlier grants still audit; new ones are refused at the
|
|
298
|
-
issuer and at every middleware on its next held call.
|
|
298
|
+
issuer and at every middleware on its next held call. Revocation retires
|
|
299
|
+
a key, not a person: while the Approver role stands, the person can sign
|
|
300
|
+
in and enroll a fresh key, so remove the role too; the revoked key itself
|
|
301
|
+
is never accepted again. Every key an approver held stays in the file
|
|
302
|
+
(`history`), so an audit of grants released under an earlier key still
|
|
303
|
+
verifies: copy the issuer's current `approvers.json` beside the evidence
|
|
304
|
+
before auditing. Approvers, and
|
|
299
305
|
only approvers, see the whole queue and the witness; everyone else sees
|
|
300
306
|
their own holds. Then paste the server entry from
|
|
301
307
|
`gargoyle-deploy/mcp-config.json` into your MCP client.
|
|
@@ -390,6 +396,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
390
396
|
| issuer `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys, with a `revoked` list; `gargoyle approvers list|revoke`) and which Entra app role (default `Approver`) or groups may enroll, see every hold and the witness |
|
|
391
397
|
| 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` |
|
|
392
398
|
| 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
|
+
| 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 |
|
|
393
400
|
| 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 |
|
|
394
401
|
| issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
|
|
395
402
|
| `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 |
|
|
@@ -21,7 +21,7 @@ your MCP server and lets through only what both agreed to.
|
|
|
21
21
|
|
|
22
22
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
23
23
|
|
|
24
|
-
Status: pre-release (`0.16.
|
|
24
|
+
Status: pre-release (`0.16.3.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
|
|
@@ -257,7 +257,13 @@ person. An approver retires a lost or replaced key with
|
|
|
257
257
|
anyone with `gargoyle approvers revoke <upn>` on the issuer's host, and
|
|
258
258
|
the running service picks the change up without a restart. Revoked keys
|
|
259
259
|
stay on file so earlier grants still audit; new ones are refused at the
|
|
260
|
-
issuer and at every middleware on its next held call.
|
|
260
|
+
issuer and at every middleware on its next held call. Revocation retires
|
|
261
|
+
a key, not a person: while the Approver role stands, the person can sign
|
|
262
|
+
in and enroll a fresh key, so remove the role too; the revoked key itself
|
|
263
|
+
is never accepted again. Every key an approver held stays in the file
|
|
264
|
+
(`history`), so an audit of grants released under an earlier key still
|
|
265
|
+
verifies: copy the issuer's current `approvers.json` beside the evidence
|
|
266
|
+
before auditing. Approvers, and
|
|
261
267
|
only approvers, see the whole queue and the witness; everyone else sees
|
|
262
268
|
their own holds. Then paste the server entry from
|
|
263
269
|
`gargoyle-deploy/mcp-config.json` into your MCP client.
|
|
@@ -352,6 +358,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
352
358
|
| issuer `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys, with a `revoked` list; `gargoyle approvers list|revoke`) and which Entra app role (default `Approver`) or groups may enroll, see every hold and the witness |
|
|
353
359
|
| 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` |
|
|
354
360
|
| 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
|
+
| 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 |
|
|
355
362
|
| 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 |
|
|
356
363
|
| issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
|
|
357
364
|
| `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 |
|
|
@@ -32,6 +32,21 @@ _SIGNATURE_EXCLUDED_KEYS = ("approver_signature",)
|
|
|
32
32
|
|
|
33
33
|
# Refusal codes.
|
|
34
34
|
REF_APPROVAL_MALFORMED = "REF_APPROVAL_MALFORMED"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _AnyOf:
|
|
38
|
+
"""A verifying key made of several: the signature is valid if any verifies it."""
|
|
39
|
+
def __init__(self, keys: list) -> None:
|
|
40
|
+
self.keys = keys
|
|
41
|
+
|
|
42
|
+
def verify(self, signature: bytes, message: bytes) -> None:
|
|
43
|
+
for key in self.keys[:-1]:
|
|
44
|
+
try:
|
|
45
|
+
key.verify(signature, message)
|
|
46
|
+
return
|
|
47
|
+
except Exception:
|
|
48
|
+
continue
|
|
49
|
+
self.keys[-1].verify(signature, message)
|
|
35
50
|
REF_APPROVAL_MISSING_GRANT_ID = "REF_APPROVAL_MISSING_GRANT_ID"
|
|
36
51
|
REF_APPROVAL_SIGNATURE_INVALID = "REF_APPROVAL_SIGNATURE_INVALID"
|
|
37
52
|
REF_APPROVAL_KEY_UNKNOWN = "REF_APPROVAL_KEY_UNKNOWN"
|
|
@@ -178,6 +193,10 @@ def verify_grant(
|
|
|
178
193
|
|
|
179
194
|
# 4. known approver key
|
|
180
195
|
public_key = approver_public_keys.get(key_id)
|
|
196
|
+
if isinstance(public_key, (list, tuple)):
|
|
197
|
+
# Several keys for one approver id: an audit of grants from before the
|
|
198
|
+
# approver's browser key was replaced or revoked. Any of them may verify.
|
|
199
|
+
public_key = _AnyOf(list(public_key)) if public_key else None
|
|
181
200
|
if public_key is None:
|
|
182
201
|
return _reject(REF_APPROVAL_KEY_UNKNOWN)
|
|
183
202
|
|
|
@@ -26,7 +26,7 @@ import stat
|
|
|
26
26
|
import threading
|
|
27
27
|
from datetime import datetime, timezone
|
|
28
28
|
from pathlib import Path
|
|
29
|
-
from typing import Any, Dict, Optional
|
|
29
|
+
from typing import List, Any, Dict, Optional
|
|
30
30
|
|
|
31
31
|
ENV_APPROVERS_PATH = "GARGOYLE_APPROVERS_PATH"
|
|
32
32
|
ENV_APPROVER_ROLE = "GARGOYLE_APPROVER_ROLE" # Entra app role an enrolling token must carry (default Approver)
|
|
@@ -35,6 +35,11 @@ ENV_APPROVER_GROUPS = "GARGOYLE_APPROVER_GROUPS" # or: comma-separated group
|
|
|
35
35
|
DEFAULT_APPROVER_ROLE = "Approver"
|
|
36
36
|
|
|
37
37
|
|
|
38
|
+
class RevokedKeyError(ValueError):
|
|
39
|
+
"""The public key offered for enrollment was revoked before: a revoked
|
|
40
|
+
key stays revoked, the browser must generate a new one."""
|
|
41
|
+
|
|
42
|
+
|
|
38
43
|
def valid_public_key_hex(value: Any) -> bool:
|
|
39
44
|
return isinstance(value, str) and len(value) == 64 and all(c in "0123456789abcdef" for c in value)
|
|
40
45
|
|
|
@@ -48,6 +53,7 @@ class ApproverRegistry:
|
|
|
48
53
|
self._lock = threading.Lock()
|
|
49
54
|
self._approvers: Dict[str, Dict[str, Any]] = {}
|
|
50
55
|
self._revoked: Dict[str, Dict[str, Any]] = {} # former approvers, kept so old grants still verify in audits
|
|
56
|
+
self._history: Dict[str, List[Dict[str, Any]]] = {} # every key each UPN held and how it ended (replaced, revoked)
|
|
51
57
|
self._mtime: Optional[float] = None
|
|
52
58
|
self._load()
|
|
53
59
|
|
|
@@ -62,7 +68,16 @@ class ApproverRegistry:
|
|
|
62
68
|
for upn, entry in (doc.get("revoked") or {}).items():
|
|
63
69
|
if isinstance(entry, dict) and valid_public_key_hex(entry.get("public_key_hex")):
|
|
64
70
|
revoked[upn] = dict(entry)
|
|
65
|
-
|
|
71
|
+
history: Dict[str, List[Dict[str, Any]]] = {}
|
|
72
|
+
for upn, items in (doc.get("history") or {}).items():
|
|
73
|
+
if isinstance(items, list):
|
|
74
|
+
history[upn] = [dict(h) for h in items if isinstance(h, dict) and valid_public_key_hex(h.get("public_key_hex"))]
|
|
75
|
+
for upn, e in revoked.items():
|
|
76
|
+
# A file from before history existed: its revoked record is the one former key known.
|
|
77
|
+
if not any(h.get("public_key_hex") == e["public_key_hex"] for h in history.get(upn, [])):
|
|
78
|
+
history.setdefault(upn, []).append({**{k: v for k, v in e.items() if k not in ("revoked_at", "revoked_by")},
|
|
79
|
+
"ended_at": e.get("revoked_at"), "ended_by": e.get("revoked_by"), "reason": "revoked"})
|
|
80
|
+
self._approvers, self._revoked, self._history = approvers, revoked, history
|
|
66
81
|
self._mtime = self.path.stat().st_mtime
|
|
67
82
|
|
|
68
83
|
def _refresh(self) -> None:
|
|
@@ -84,6 +99,8 @@ class ApproverRegistry:
|
|
|
84
99
|
doc = {"approvers": self._approvers}
|
|
85
100
|
if self._revoked:
|
|
86
101
|
doc["revoked"] = self._revoked
|
|
102
|
+
if self._history:
|
|
103
|
+
doc["history"] = self._history
|
|
87
104
|
self.path.write_text(json.dumps(doc, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
88
105
|
try:
|
|
89
106
|
os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
|
|
@@ -97,7 +114,11 @@ class ApproverRegistry:
|
|
|
97
114
|
UPN (one key per approver; a new browser re-enrolls). The directory's
|
|
98
115
|
immutable object id and tenant id are kept beside the UPN when the
|
|
99
116
|
token carried them: the UPN is the name people read, the oid is what
|
|
100
|
-
survives a rename.
|
|
117
|
+
survives a rename. A key on the revoked list is never enrolled again,
|
|
118
|
+
for anyone (RevokedKeyError): the live exercise of 2026-10-20 showed a
|
|
119
|
+
browser re-enrolling the very key the operator had just revoked. The
|
|
120
|
+
revoked record stays when a fresh key enrolls, so grants signed under
|
|
121
|
+
the old key still audit."""
|
|
101
122
|
if not isinstance(upn, str) or not upn:
|
|
102
123
|
raise ValueError("upn must be a non-empty string")
|
|
103
124
|
if not valid_public_key_hex(public_key_hex):
|
|
@@ -111,12 +132,45 @@ class ApproverRegistry:
|
|
|
111
132
|
entry["tid"] = tid
|
|
112
133
|
with self._lock:
|
|
113
134
|
self._refresh()
|
|
135
|
+
if public_key_hex in self._revoked_keys():
|
|
136
|
+
raise RevokedKeyError("that key was revoked; enroll a new one")
|
|
114
137
|
replaced = upn in self._approvers
|
|
138
|
+
if replaced and self._approvers[upn].get("public_key_hex") != public_key_hex:
|
|
139
|
+
# The superseded key is kept: grants signed under it still audit.
|
|
140
|
+
self._remember(upn, self._approvers[upn], "replaced", enrolled_by or upn, now)
|
|
115
141
|
self._approvers[upn] = entry
|
|
116
|
-
self._revoked.pop(upn, None) # enrolling again after a revocation is a fresh key
|
|
117
142
|
self._save()
|
|
118
143
|
return {"upn": upn, "replaced": replaced, **entry}
|
|
119
144
|
|
|
145
|
+
def _remember(self, upn: str, entry: Dict[str, Any], reason: str, ended_by: str, now: Optional[datetime]) -> None:
|
|
146
|
+
hist = self._history.setdefault(upn, [])
|
|
147
|
+
if not any(h.get("public_key_hex") == entry.get("public_key_hex") for h in hist):
|
|
148
|
+
hist.append({**{k: v for k, v in entry.items() if k not in ("revoked_at", "revoked_by")},
|
|
149
|
+
"ended_at": (now or datetime.now(timezone.utc)).isoformat(), "ended_by": ended_by, "reason": reason})
|
|
150
|
+
|
|
151
|
+
def _revoked_keys(self) -> set:
|
|
152
|
+
"""Every public key ever revoked, under any UPN: none may enroll again."""
|
|
153
|
+
return ({e.get("public_key_hex") for e in self._revoked.values()}
|
|
154
|
+
| {h.get("public_key_hex") for hist in self._history.values() for h in hist if h.get("reason") == "revoked"})
|
|
155
|
+
|
|
156
|
+
def audit_keys_hex(self) -> Dict[str, List[str]]:
|
|
157
|
+
"""{upn: [public_key_hex, ...]}: the current key first, then every key
|
|
158
|
+
the UPN held before. An audit verifies a grant with any of them; the
|
|
159
|
+
live service and middleware trust only the current one."""
|
|
160
|
+
with self._lock:
|
|
161
|
+
self._refresh()
|
|
162
|
+
out: Dict[str, List[str]] = {}
|
|
163
|
+
for upn, e in self._approvers.items():
|
|
164
|
+
out[upn] = [e["public_key_hex"]]
|
|
165
|
+
for upn, hist in self._history.items():
|
|
166
|
+
for h in hist:
|
|
167
|
+
if h["public_key_hex"] not in out.setdefault(upn, []):
|
|
168
|
+
out[upn].append(h["public_key_hex"])
|
|
169
|
+
for upn, e in self._revoked.items():
|
|
170
|
+
if e["public_key_hex"] not in out.setdefault(upn, []):
|
|
171
|
+
out[upn].append(e["public_key_hex"])
|
|
172
|
+
return out
|
|
173
|
+
|
|
120
174
|
def revoke(self, upn: str, *, revoked_by: Optional[str] = None, now: Optional[datetime] = None) -> bool:
|
|
121
175
|
"""Stop trusting `upn`'s key from now on. The entry moves to the
|
|
122
176
|
revoked list with when and by whom, so an audit of earlier evidence
|
|
@@ -129,6 +183,7 @@ class ApproverRegistry:
|
|
|
129
183
|
return False
|
|
130
184
|
self._revoked[upn] = {**entry, "revoked_at": (now or datetime.now(timezone.utc)).isoformat(),
|
|
131
185
|
"revoked_by": revoked_by or upn}
|
|
186
|
+
self._remember(upn, entry, "revoked", revoked_by or upn, now)
|
|
132
187
|
self._save()
|
|
133
188
|
return True
|
|
134
189
|
|
|
@@ -146,6 +201,13 @@ class ApproverRegistry:
|
|
|
146
201
|
self._refresh()
|
|
147
202
|
return {upn: e["public_key_hex"] for upn, e in self._approvers.items()}
|
|
148
203
|
|
|
204
|
+
def revoked_entries(self) -> Dict[str, Dict[str, Any]]:
|
|
205
|
+
"""The revoked list with when and by whom, re-read from the file: the
|
|
206
|
+
service notices an operator's `gargoyle approvers revoke` here."""
|
|
207
|
+
with self._lock:
|
|
208
|
+
self._refresh()
|
|
209
|
+
return {upn: dict(e) for upn, e in self._revoked.items()}
|
|
210
|
+
|
|
149
211
|
def revoked_hex(self) -> Dict[str, str]:
|
|
150
212
|
"""{upn: public_key_hex} of revoked approvers: for verifying the
|
|
151
213
|
grants they signed while trusted, never for accepting new ones."""
|
|
@@ -39,6 +39,7 @@ the response metadata; `gargoyle audit` verifies them later, offline.
|
|
|
39
39
|
|
|
40
40
|
import argparse
|
|
41
41
|
import json
|
|
42
|
+
from typing import Any, Dict
|
|
42
43
|
import os
|
|
43
44
|
import re
|
|
44
45
|
import stat
|
|
@@ -293,7 +294,7 @@ def cmd_remote(args) -> int:
|
|
|
293
294
|
|
|
294
295
|
def cmd_issuer(args) -> int:
|
|
295
296
|
import uvicorn
|
|
296
|
-
from gargoyle.issuer_service import app_from_env
|
|
297
|
+
from gargoyle.issuer_service import app_from_env, redact_access_log
|
|
297
298
|
|
|
298
299
|
out = Path(args.out).resolve()
|
|
299
300
|
os.environ.update(_read_env(out / "issuer.env"))
|
|
@@ -302,6 +303,7 @@ def cmd_issuer(args) -> int:
|
|
|
302
303
|
os.environ.setdefault("GARGOYLE_WITNESS_PATH", str(out / "witness.jsonl")) # the issuer's signed record, for reconciliation
|
|
303
304
|
os.environ.setdefault("GARGOYLE_LOG_JSON", "1")
|
|
304
305
|
app = app_from_env()
|
|
306
|
+
redact_access_log() # no sign-in codes in the access log
|
|
305
307
|
# The middleware reads the policy pin from a file; write it next to the
|
|
306
308
|
# config so the client-side entry finds it at the configured path.
|
|
307
309
|
from gargoyle.rules import RulesPolicy
|
|
@@ -345,9 +347,10 @@ def cmd_approvers(args) -> int:
|
|
|
345
347
|
for upn, e in sorted(entries.items()):
|
|
346
348
|
print("%s enrolled %s key %s%s" % (upn, e.get("enrolled_at", "?"), e["public_key_hex"][:16],
|
|
347
349
|
(" oid " + e["oid"]) if e.get("oid") else ""))
|
|
348
|
-
revoked = reg.
|
|
350
|
+
revoked = reg.revoked_entries()
|
|
349
351
|
if revoked:
|
|
350
|
-
print("revoked: " + ", ".join(
|
|
352
|
+
print("revoked: " + ", ".join("%s (key %s, %s by %s)" % (u, e["public_key_hex"][:16], e.get("revoked_at", "?")[:19], e.get("revoked_by", "?"))
|
|
353
|
+
for u, e in sorted(revoked.items())))
|
|
351
354
|
return 0
|
|
352
355
|
import getpass
|
|
353
356
|
if reg.revoke(args.upn, revoked_by="operator:" + getpass.getuser()):
|
|
@@ -362,15 +365,17 @@ def cmd_audit(args) -> int:
|
|
|
362
365
|
from gargoyle.execution import verify_chain
|
|
363
366
|
|
|
364
367
|
keys = json.loads((Path(args.out).resolve() / "public-keys.json").read_text())
|
|
365
|
-
|
|
366
|
-
|
|
368
|
+
one = lambda h: Ed25519PublicKey.from_public_bytes(bytes.fromhex(h))
|
|
369
|
+
load = lambda m: {k: ([one(x) for x in v] if isinstance(v, list) else one(v)) for k, v in m.items()}
|
|
370
|
+
approvers: Dict[str, Any] = dict(keys["approver"])
|
|
367
371
|
enrolled = Path(args.out).resolve() / "approvers.json" # approvers who enrolled through Entra, by UPN
|
|
368
372
|
if enrolled.exists():
|
|
369
|
-
|
|
370
|
-
#
|
|
371
|
-
#
|
|
372
|
-
|
|
373
|
-
|
|
373
|
+
from gargoyle.approvers import ApproverRegistry
|
|
374
|
+
# Every key each approver ever held, current first: grants signed
|
|
375
|
+
# under a key since replaced in the browser or revoked still have to
|
|
376
|
+
# verify. Only the live service and middleware are limited to the
|
|
377
|
+
# current key. Copy the issuer's file beside the evidence first.
|
|
378
|
+
approvers.update(ApproverRegistry(str(enrolled)).audit_keys_hex())
|
|
374
379
|
trust = dict(issuer_public_keys=load(keys["issuer"]), executor_public_keys=load(keys["executor"]),
|
|
375
380
|
approver_public_keys=load(approvers))
|
|
376
381
|
if args.log:
|
|
@@ -60,7 +60,7 @@ from gargoyle.plans import (REF_PLAN_MALFORMED, REF_PLAN_STEP_NOT_PLANNABLE, REF
|
|
|
60
60
|
sign_plan, valid_step)
|
|
61
61
|
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
|
-
ApproverRegistry, may_enroll, valid_public_key_hex)
|
|
63
|
+
ApproverRegistry, RevokedKeyError, may_enroll, valid_public_key_hex)
|
|
64
64
|
from gargoyle.identity import entra_keys, entra_tenant_id, principal_from_token
|
|
65
65
|
from gargoyle.issuer import DEFAULT_TTL_SECONDS, issue
|
|
66
66
|
from gargoyle.policy import Policy
|
|
@@ -79,6 +79,8 @@ ENV_APPROVER_PUBLIC_KEY_HEX = "GARGOYLE_APPROVER_PUBLIC_KEY_HEX"
|
|
|
79
79
|
ENV_RECEIPT_TTL = "GARGOYLE_RECEIPT_TTL_SECONDS"
|
|
80
80
|
ENV_CONSOLE_CLIENT_ID = "GARGOYLE_ENTRA_CLIENT_ID" # the public client app the console signs in with (User-Client-Flow)
|
|
81
81
|
ENV_CONSOLE_SCOPE = "GARGOYLE_ENTRA_SCOPE" # default api://<last audience>/access
|
|
82
|
+
ENV_MAX_OPEN_HOLDS = "GARGOYLE_MAX_OPEN_HOLDS" # holds one principal may have waiting at once
|
|
83
|
+
DEFAULT_MAX_OPEN_HOLDS = 20
|
|
82
84
|
|
|
83
85
|
REF_ISSUER_UNAUTHENTICATED = "REF_ISSUER_UNAUTHENTICATED"
|
|
84
86
|
REF_ISSUER_BAD_REQUEST = "REF_ISSUER_BAD_REQUEST"
|
|
@@ -87,6 +89,8 @@ REF_ISSUER_UNKNOWN_HOLD = "REF_ISSUER_UNKNOWN_HOLD"
|
|
|
87
89
|
REF_ISSUER_NO_APPROVER_TRUST = "REF_ISSUER_NO_APPROVER_TRUST"
|
|
88
90
|
REF_ISSUER_NOT_AN_APPROVER = "REF_ISSUER_NOT_AN_APPROVER" # enrollment without the approver role or group
|
|
89
91
|
REF_ISSUER_FORBIDDEN = "REF_ISSUER_FORBIDDEN" # authenticated, but not an approver and not the hold's own principal
|
|
92
|
+
REF_ISSUER_TOO_MANY_HOLDS = "REF_ISSUER_TOO_MANY_HOLDS" # the principal already has the cap's worth of holds waiting
|
|
93
|
+
REF_ISSUER_KEY_REVOKED = "REF_ISSUER_KEY_REVOKED" # enrollment offered a key that was revoked; a new key is needed
|
|
90
94
|
|
|
91
95
|
|
|
92
96
|
def build_issuer_app(
|
|
@@ -109,6 +113,7 @@ def build_issuer_app(
|
|
|
109
113
|
witness: Optional[WitnessLog] = None,
|
|
110
114
|
tenant: Optional[str] = None,
|
|
111
115
|
tenant_key_ids: Optional[set] = None,
|
|
116
|
+
max_open_holds: int = DEFAULT_MAX_OPEN_HOLDS,
|
|
112
117
|
) -> FastAPI:
|
|
113
118
|
"""`on_hold(envelope, args)` is called once for every new hold (gargoyle.notify
|
|
114
119
|
posts it to a webhook); it is notice, not admission, and its failure
|
|
@@ -146,7 +151,20 @@ def build_issuer_app(
|
|
|
146
151
|
registry = approver_registry if approver_registry is not None else ApproverRegistry(None)
|
|
147
152
|
static_approvers = dict(approver_public_keys or {})
|
|
148
153
|
|
|
154
|
+
app.state.revoked_seen = set(registry.revoked_entries())
|
|
155
|
+
|
|
156
|
+
def _notice_revocations() -> None:
|
|
157
|
+
"""An operator's `gargoyle approvers revoke` edits the file on the
|
|
158
|
+
host; the first read after it puts a `revoked` entry in the witness,
|
|
159
|
+
so the record shows who was trusted when. Revocations the service
|
|
160
|
+
made itself were witnessed as they happened."""
|
|
161
|
+
current = registry.revoked_entries()
|
|
162
|
+
for upn in sorted(set(current) - app.state.revoked_seen):
|
|
163
|
+
event("revoked", approver=upn, by=current[upn].get("revoked_by"), revoked_at=current[upn].get("revoked_at"))
|
|
164
|
+
app.state.revoked_seen = set(current)
|
|
165
|
+
|
|
149
166
|
def approver_keys() -> Dict[str, Any]:
|
|
167
|
+
_notice_revocations()
|
|
150
168
|
"""Static keys from configuration plus every enrolled approver, by UPN."""
|
|
151
169
|
return {**static_approvers, **registry.keys()}
|
|
152
170
|
|
|
@@ -164,6 +182,21 @@ def build_issuer_app(
|
|
|
164
182
|
plan = app.state.pending_plans.get(arid)
|
|
165
183
|
return plan.get("principal") if plan is not None else None
|
|
166
184
|
|
|
185
|
+
def _open_holds(principal: str) -> int:
|
|
186
|
+
"""Held receipts and plans of `principal` still waiting on a release:
|
|
187
|
+
not expired, not complete. A declined hold is already gone. One runaway
|
|
188
|
+
client must not fill every approver's queue and the hold store."""
|
|
189
|
+
current = now()
|
|
190
|
+
n = 0
|
|
191
|
+
for arid, env in app.state.pending.items():
|
|
192
|
+
if (env.get("request_context", {}).get("principal") == principal
|
|
193
|
+
and datetime.fromisoformat(env["not_after"]) > current and not _complete(arid)):
|
|
194
|
+
n += 1
|
|
195
|
+
for arid, plan in app.state.pending_plans.items():
|
|
196
|
+
if plan.get("principal") == principal and datetime.fromisoformat(plan["not_after"]) > current and not _complete(arid):
|
|
197
|
+
n += 1
|
|
198
|
+
return n
|
|
199
|
+
|
|
167
200
|
def _refuse(status: int, reason: str) -> HTTPException:
|
|
168
201
|
return HTTPException(status_code=status, detail={"reason": reason})
|
|
169
202
|
|
|
@@ -228,6 +261,11 @@ def build_issuer_app(
|
|
|
228
261
|
if plan["plan_id"] == plan_id and _complete(arid_p) and match_step(plan, body["tool"], body["args"]) is not None:
|
|
229
262
|
covered = True
|
|
230
263
|
break
|
|
264
|
+
if result.envelope["requires_approval"] and not covered and _open_holds(who["principal"]) >= max_open_holds:
|
|
265
|
+
# A service limit, not a policy decision: nothing is stored, the
|
|
266
|
+
# signed envelope is dropped, the client reports the reason.
|
|
267
|
+
event("refused", principal=who["principal"], tool=body["tool"], reason=REF_ISSUER_TOO_MANY_HOLDS)
|
|
268
|
+
return {"issued": False, "reason": REF_ISSUER_TOO_MANY_HOLDS}
|
|
231
269
|
event("issued", principal=who["principal"], tool=body["tool"], held=bool(result.envelope["requires_approval"]),
|
|
232
270
|
**({"oid": who["oid"]} if who.get("oid") else {}),
|
|
233
271
|
covered=covered, decision_id=result.envelope.get("decision_id"), decision_sha256=result.envelope["decision_sha256"],
|
|
@@ -287,6 +325,9 @@ def build_issuer_app(
|
|
|
287
325
|
if d.approvers:
|
|
288
326
|
approvers.update(d.approvers)
|
|
289
327
|
norm.append(step)
|
|
328
|
+
if held and _open_holds(who["principal"]) >= max_open_holds:
|
|
329
|
+
event("refused", principal=who["principal"], tool="plan", reason=REF_ISSUER_TOO_MANY_HOLDS)
|
|
330
|
+
return {"issued": False, "reason": REF_ISSUER_TOO_MANY_HOLDS}
|
|
290
331
|
import uuid
|
|
291
332
|
approval = None
|
|
292
333
|
if required > 1 or approvers:
|
|
@@ -357,21 +398,27 @@ def build_issuer_app(
|
|
|
357
398
|
plan = app.state.pending_plans.get(arid) if isinstance(arid, str) else None
|
|
358
399
|
if env is None and plan is None:
|
|
359
400
|
return {"accepted": False, "reason": REF_ISSUER_UNKNOWN_HOLD}
|
|
401
|
+
def refused(reason: str) -> Dict[str, Any]:
|
|
402
|
+
# A release that did not happen is a decision too: a revoked or
|
|
403
|
+
# unknown key trying to release a hold belongs in the witness.
|
|
404
|
+
kid = grant.get("approver_key_id")
|
|
405
|
+
event("grant_refused", approval_request_id=arid, reason=reason, **({"approver": kid} if isinstance(kid, str) else {}))
|
|
406
|
+
return {"accepted": False, "reason": reason}
|
|
360
407
|
trusted = approver_keys()
|
|
361
408
|
if not trusted:
|
|
362
|
-
return
|
|
409
|
+
return refused(REF_ISSUER_NO_APPROVER_TRUST)
|
|
363
410
|
g = verify_grant(grant, expected_decision_sha256=env["decision_sha256"] if env else plan["plan_sha256"],
|
|
364
411
|
expected_approval_request_id=arid, approver_public_keys=trusted,
|
|
365
412
|
gate_key_id=key_id, now=now())
|
|
366
413
|
if not g["accepted"]:
|
|
367
|
-
return
|
|
414
|
+
return refused(g["reason"])
|
|
368
415
|
# The receipt's (or plan's) own terms: distinct approvers, from the named set if any.
|
|
369
416
|
terms = approval_terms(env) if env else _plan_terms(plan)
|
|
370
417
|
have = list(app.state.grants.get(arid, []))
|
|
371
418
|
if any(h["approver_key_id"] == grant["approver_key_id"] for h in have):
|
|
372
|
-
return
|
|
419
|
+
return refused(REF_APPROVAL_DUPLICATE_APPROVER)
|
|
373
420
|
if terms["approvers"] is not None and grant["approver_key_id"] not in terms["approvers"]:
|
|
374
|
-
return
|
|
421
|
+
return refused(REF_APPROVAL_APPROVER_NOT_ELIGIBLE)
|
|
375
422
|
have.append(grant)
|
|
376
423
|
app.state.grants[arid] = have # written back: the store persists on assignment
|
|
377
424
|
event("granted", approval_request_id=arid, approver=grant["approver_key_id"], collected=len(have), required=terms["required"],
|
|
@@ -419,6 +466,7 @@ def build_issuer_app(
|
|
|
419
466
|
"""Public, like /record: the enrolled approvers' public keys by UPN,
|
|
420
467
|
for the middleware and for audits. Static configured keys are not
|
|
421
468
|
listed; they travel in config."""
|
|
469
|
+
_notice_revocations()
|
|
422
470
|
return {"approvers": registry.public_hex()}
|
|
423
471
|
|
|
424
472
|
@app.post("/approvers/enroll")
|
|
@@ -435,7 +483,13 @@ def build_issuer_app(
|
|
|
435
483
|
raise _refuse(400, REF_ISSUER_BAD_REQUEST)
|
|
436
484
|
if not isinstance(body, dict) or not valid_public_key_hex(body.get("public_key_hex")):
|
|
437
485
|
raise _refuse(400, REF_ISSUER_BAD_REQUEST)
|
|
438
|
-
|
|
486
|
+
try:
|
|
487
|
+
result = registry.enroll(who["principal"], body["public_key_hex"], now=now(), oid=who.get("oid"), tid=who.get("tid"))
|
|
488
|
+
except RevokedKeyError:
|
|
489
|
+
# A revoked key stays revoked, whoever offers it; the console
|
|
490
|
+
# generates a fresh one on this answer.
|
|
491
|
+
event("enroll_refused", approver=who["principal"], reason=REF_ISSUER_KEY_REVOKED, oid=who.get("oid"))
|
|
492
|
+
raise _refuse(409, REF_ISSUER_KEY_REVOKED)
|
|
439
493
|
event("enrolled", approver=who["principal"], replaced=result["replaced"], oid=who.get("oid"))
|
|
440
494
|
return {"enrolled": True, "key_id": who["principal"], "replaced": result["replaced"]}
|
|
441
495
|
|
|
@@ -459,6 +513,7 @@ def build_issuer_app(
|
|
|
459
513
|
gone = registry.revoke(upn, revoked_by=who["principal"], now=now())
|
|
460
514
|
if gone:
|
|
461
515
|
event("revoked", approver=upn, by=who["principal"])
|
|
516
|
+
app.state.revoked_seen.add(upn)
|
|
462
517
|
return {"revoked": gone}
|
|
463
518
|
|
|
464
519
|
@app.get("/me")
|
|
@@ -468,6 +523,7 @@ def build_issuer_app(
|
|
|
468
523
|
its own key is the one on record)."""
|
|
469
524
|
who = authenticate(authorization)
|
|
470
525
|
upn = who["principal"]
|
|
526
|
+
_notice_revocations()
|
|
471
527
|
return {"principal": upn, "may_approve": may_enroll(who, role=approver_role, groups=approver_groups) or upn in static_approvers,
|
|
472
528
|
"enrolled_public_key_hex": registry.public_hex().get(upn)}
|
|
473
529
|
|
|
@@ -485,6 +541,7 @@ def build_issuer_app(
|
|
|
485
541
|
how much is waiting, and since when. No secrets."""
|
|
486
542
|
return {"ok": True, "policy": policy.pin(), "issuer_key_id": key_id,
|
|
487
543
|
"pending_receipts": len(app.state.pending), "pending_plans": len(app.state.pending_plans),
|
|
544
|
+
"max_open_holds": max_open_holds,
|
|
488
545
|
"enrolled_approvers": len(registry.public_hex()), "static_approvers": len(static_approvers),
|
|
489
546
|
"entra": bool(console_entra), "persistent": store.path != ":memory:", "started_at": app.state.started_at}
|
|
490
547
|
|
|
@@ -514,6 +571,22 @@ def build_issuer_app(
|
|
|
514
571
|
return app
|
|
515
572
|
|
|
516
573
|
|
|
574
|
+
def redact_access_log(logger_name: str = "uvicorn.access") -> None:
|
|
575
|
+
"""The console returns from Microsoft as GET /approve?code=...; the code
|
|
576
|
+
is single-use and bound to the browser's PKCE verifier, and it still has
|
|
577
|
+
no place in an access log. Replace query strings on logged paths."""
|
|
578
|
+
import logging
|
|
579
|
+
import re
|
|
580
|
+
|
|
581
|
+
class _Redact(logging.Filter):
|
|
582
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
583
|
+
if isinstance(record.args, tuple):
|
|
584
|
+
record.args = tuple(re.sub(r"\?.*", "?<redacted>", a) if isinstance(a, str) and a.startswith("/") and "?" in a else a
|
|
585
|
+
for a in record.args)
|
|
586
|
+
return True
|
|
587
|
+
logging.getLogger(logger_name).addFilter(_Redact())
|
|
588
|
+
|
|
589
|
+
|
|
517
590
|
def app_from_env() -> FastAPI:
|
|
518
591
|
"""The deployable app: OPA policy, issuer key, trusted identity-provider
|
|
519
592
|
key and optional approver key from the environment. Missing required
|
|
@@ -570,6 +643,7 @@ def app_from_env() -> FastAPI:
|
|
|
570
643
|
ttl_seconds=int(os.environ.get(ENV_RECEIPT_TTL, DEFAULT_TTL_SECONDS)),
|
|
571
644
|
on_hold=notifier_from_env(os.environ),
|
|
572
645
|
hold_store=HoldStore(os.environ.get(ENV_HOLDS_PATH) or None),
|
|
646
|
+
max_open_holds=int(os.environ.get(ENV_MAX_OPEN_HOLDS, DEFAULT_MAX_OPEN_HOLDS)),
|
|
573
647
|
witness=(WitnessLog(os.environ["GARGOYLE_WITNESS_PATH"], Ed25519PrivateKey.from_private_bytes(bytes.fromhex(required[ENV_ISSUER_KEY_HEX])),
|
|
574
648
|
required[ENV_ISSUER_KEY_ID]) if os.environ.get("GARGOYLE_WITNESS_PATH") else None),
|
|
575
649
|
log_events=(lambda e: sys.stderr.write(json.dumps(e, sort_keys=True) + "\n")) if os.environ.get("GARGOYLE_LOG_JSON") else None,
|
{gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/static/approve.html
RENAMED
|
@@ -94,8 +94,18 @@ async function entraReady() {
|
|
|
94
94
|
const mine = await publicKeyHex(keyPair);
|
|
95
95
|
if (me.enrolled_public_key_hex !== mine) {
|
|
96
96
|
// First time in this browser (or a new browser): enroll this key under the UPN.
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
let res;
|
|
98
|
+
try {
|
|
99
|
+
res = await api("approvers/enroll", { method: "POST", body: JSON.stringify({ public_key_hex: mine }) });
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (err.status !== 409) throw err;
|
|
102
|
+
// This browser's key was revoked. A revoked key stays revoked: make a new one and enroll that.
|
|
103
|
+
keyPair = await generateApproverKey(); await storeKey(me.principal, keyPair);
|
|
104
|
+
res = await api("approvers/enroll", { method: "POST", body: JSON.stringify({ public_key_hex: await publicKeyHex(keyPair) }) });
|
|
105
|
+
log("this browser's previous key was revoked; a new key enrolled");
|
|
106
|
+
res = null;
|
|
107
|
+
}
|
|
108
|
+
if (res) log(res.replaced ? "this browser's key replaced your previous one" : "this browser's key enrolled");
|
|
99
109
|
}
|
|
100
110
|
state.key = keyPair.privateKey; state.keyId = me.principal;
|
|
101
111
|
$("entra").style.display = "none"; $("setup").style.display = "none";
|
|
@@ -117,7 +127,7 @@ $("file").addEventListener("change", async (e) => {
|
|
|
117
127
|
|
|
118
128
|
async function api(path, opts = {}) {
|
|
119
129
|
const r = await fetch(path, { ...opts, headers: { "Authorization": "Bearer " + state.token, "content-type": "application/json", ...(opts.headers || {}) } });
|
|
120
|
-
if (!r.ok)
|
|
130
|
+
if (!r.ok) { const e = new Error(path + " -> " + r.status); e.status = r.status; throw e; }
|
|
121
131
|
return r.json();
|
|
122
132
|
}
|
|
123
133
|
|
{gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0/gargoyle_receipts.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gargoyle-receipts
|
|
3
|
-
Version: 0.16.
|
|
3
|
+
Version: 0.16.3.dev0
|
|
4
4
|
Summary: Signed, single-use, argument-bound receipts and human-approval holds for agent tool calls.
|
|
5
5
|
Author: Justin LaPorte
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -59,7 +59,7 @@ your MCP server and lets through only what both agreed to.
|
|
|
59
59
|
|
|
60
60
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
61
61
|
|
|
62
|
-
Status: pre-release (`0.16.
|
|
62
|
+
Status: pre-release (`0.16.3.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
|
|
@@ -295,7 +295,13 @@ person. An approver retires a lost or replaced key with
|
|
|
295
295
|
anyone with `gargoyle approvers revoke <upn>` on the issuer's host, and
|
|
296
296
|
the running service picks the change up without a restart. Revoked keys
|
|
297
297
|
stay on file so earlier grants still audit; new ones are refused at the
|
|
298
|
-
issuer and at every middleware on its next held call.
|
|
298
|
+
issuer and at every middleware on its next held call. Revocation retires
|
|
299
|
+
a key, not a person: while the Approver role stands, the person can sign
|
|
300
|
+
in and enroll a fresh key, so remove the role too; the revoked key itself
|
|
301
|
+
is never accepted again. Every key an approver held stays in the file
|
|
302
|
+
(`history`), so an audit of grants released under an earlier key still
|
|
303
|
+
verifies: copy the issuer's current `approvers.json` beside the evidence
|
|
304
|
+
before auditing. Approvers, and
|
|
299
305
|
only approvers, see the whole queue and the witness; everyone else sees
|
|
300
306
|
their own holds. Then paste the server entry from
|
|
301
307
|
`gargoyle-deploy/mcp-config.json` into your MCP client.
|
|
@@ -390,6 +396,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
390
396
|
| issuer `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys, with a `revoked` list; `gargoyle approvers list|revoke`) and which Entra app role (default `Approver`) or groups may enroll, see every hold and the witness |
|
|
391
397
|
| 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` |
|
|
392
398
|
| 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
|
+
| 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 |
|
|
393
400
|
| 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 |
|
|
394
401
|
| issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
|
|
395
402
|
| `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 |
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gargoyle-receipts"
|
|
7
|
-
version = "0.16.
|
|
7
|
+
version = "0.16.3.dev0"
|
|
8
8
|
description = "Signed, single-use, argument-bound receipts and human-approval holds for agent tool calls."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -151,10 +151,17 @@ def test_a_revoked_approver_is_refused_at_once_and_still_verifies_in_the_past(tm
|
|
|
151
151
|
doc = json.loads((tmp_path / "approvers.json").read_text())
|
|
152
152
|
assert "dana@contoso.com" not in doc["approvers"] and doc["revoked"]["dana@contoso.com"]["public_key_hex"] == pub_hex(dana)
|
|
153
153
|
assert doc["revoked"]["dana@contoso.com"]["revoked_by"] == "dana@contoso.com"
|
|
154
|
-
#
|
|
154
|
+
# The revoked key is never enrolled again, by Dana or anyone (a browser
|
|
155
|
+
# that kept its key pair would otherwise re-enroll it on the next sign-in).
|
|
156
|
+
from gargoyle.issuer_service import REF_ISSUER_KEY_REVOKED
|
|
157
|
+
r = c.post("/approvers/enroll", json={"public_key_hex": pub_hex(dana)}, headers=h(token("dana@contoso.com", roles=["Approver"])))
|
|
158
|
+
assert r.status_code == 409 and r.json()["detail"]["reason"] == REF_ISSUER_KEY_REVOKED
|
|
159
|
+
r = c.post("/approvers/enroll", json={"public_key_hex": pub_hex(dana)}, headers=h(token("lee@contoso.com", roles=["Approver"])))
|
|
160
|
+
assert r.status_code == 409
|
|
161
|
+
# A fresh key enrolls, and the revoked record stays so Dana's earlier grants still audit.
|
|
155
162
|
dana2 = Ed25519PrivateKey.generate()
|
|
156
163
|
assert c.post("/approvers/enroll", json={"public_key_hex": pub_hex(dana2)}, headers=h(token("dana@contoso.com", roles=["Approver"]))).json()["enrolled"]
|
|
157
|
-
assert reg.revoked_hex() == {} and reg.public_hex() == {"dana@contoso.com": pub_hex(dana2)}
|
|
164
|
+
assert reg.revoked_hex() == {"dana@contoso.com": pub_hex(dana)} and reg.public_hex() == {"dana@contoso.com": pub_hex(dana2)}
|
|
158
165
|
|
|
159
166
|
|
|
160
167
|
def test_the_registry_picks_up_a_file_another_process_wrote(tmp_path):
|
|
@@ -169,3 +176,67 @@ def test_the_registry_picks_up_a_file_another_process_wrote(tmp_path):
|
|
|
169
176
|
assert operator.revoke("dana@contoso.com", revoked_by="operator:root")
|
|
170
177
|
os.utime(path, (time.time() + 2, time.time() + 2)) # make the change visible even on a coarse filesystem clock
|
|
171
178
|
assert "dana@contoso.com" not in live and live.public_hex() == {} and live.revoked_hex() == {"dana@contoso.com": pub_hex(key)}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_an_operators_revocation_on_the_host_reaches_the_witness(tmp_path):
|
|
182
|
+
"""`gargoyle approvers revoke` edits the file; the service witnesses the
|
|
183
|
+
revocation (who, when) the first time it reads the file afterwards, and
|
|
184
|
+
only once. Its own revocations are witnessed as they happen, not again."""
|
|
185
|
+
import os
|
|
186
|
+
events = []
|
|
187
|
+
reg = ApproverRegistry(str(tmp_path / "approvers.json"))
|
|
188
|
+
app = build_issuer_app(policy=Hold(), signing_key=issuer, key_id="issuer-1", idp_public_keys={"idp-1": idp.public_key()},
|
|
189
|
+
approver_registry=reg, log_events=events.append)
|
|
190
|
+
c = TestClient(app)
|
|
191
|
+
h = lambda t: {"Authorization": "Bearer " + t}
|
|
192
|
+
for who in ("dana@contoso.com", "lee@contoso.com"):
|
|
193
|
+
assert c.post("/approvers/enroll", json={"public_key_hex": pub_hex(Ed25519PrivateKey.generate())}, headers=h(token(who, roles=["Approver"]))).json()["enrolled"]
|
|
194
|
+
# The operator, in another process.
|
|
195
|
+
other = ApproverRegistry(str(tmp_path / "approvers.json"))
|
|
196
|
+
assert other.revoke("dana@contoso.com", revoked_by="operator:root")
|
|
197
|
+
st = os.stat(tmp_path / "approvers.json"); os.utime(tmp_path / "approvers.json", (st.st_atime, st.st_mtime + 2))
|
|
198
|
+
assert c.get("/approvers").json()["approvers"].keys() == {"lee@contoso.com"}
|
|
199
|
+
c.get("/approvers")
|
|
200
|
+
revoked = [e for e in events if e["event"] == "revoked"]
|
|
201
|
+
assert len(revoked) == 1 and revoked[0]["approver"] == "dana@contoso.com" and revoked[0]["by"] == "operator:root" and revoked[0]["revoked_at"]
|
|
202
|
+
# Lee revokes their own key through the service: one entry, by Lee.
|
|
203
|
+
assert c.post("/approvers/revoke", json={"upn": "lee@contoso.com"}, headers=h(token("lee@contoso.com", roles=["Approver"]))).json() == {"revoked": True}
|
|
204
|
+
c.get("/approvers")
|
|
205
|
+
revoked = [e for e in events if e["event"] == "revoked"]
|
|
206
|
+
assert [(e["approver"], e["by"]) for e in revoked] == [("dana@contoso.com", "operator:root"), ("lee@contoso.com", "lee@contoso.com")]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_every_key_an_approver_held_stays_for_audits(tmp_path):
|
|
210
|
+
"""The client audit of 2026-10-20 failed a row released under a key that a
|
|
211
|
+
later browser enrollment had replaced: the registry had dropped it. Now
|
|
212
|
+
every key a UPN held is kept (history: replaced or revoked, when, by whom),
|
|
213
|
+
the audit map carries them all, and a grant verifies with any of them."""
|
|
214
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
215
|
+
from gargoyle.approval import ACCEPT_GRANT_VALID, build_grant, sign_grant, verify_grant
|
|
216
|
+
from gargoyle.approvers import RevokedKeyError
|
|
217
|
+
path = tmp_path / "a.json"
|
|
218
|
+
reg = ApproverRegistry(str(path))
|
|
219
|
+
k1, k2, k3 = (Ed25519PrivateKey.generate() for _ in range(3))
|
|
220
|
+
reg.enroll("dana@contoso.com", pub_hex(k1))
|
|
221
|
+
assert reg.enroll("dana@contoso.com", pub_hex(k2))["replaced"] is True # a new browser
|
|
222
|
+
assert reg.revoke("dana@contoso.com", revoked_by="operator:root")
|
|
223
|
+
reg.enroll("dana@contoso.com", pub_hex(k3))
|
|
224
|
+
assert reg.audit_keys_hex() == {"dana@contoso.com": [pub_hex(k3), pub_hex(k1), pub_hex(k2)]}
|
|
225
|
+
doc = json.loads(path.read_text())
|
|
226
|
+
assert [(h["reason"], h["ended_by"]) for h in doc["history"]["dana@contoso.com"]] == [("replaced", "dana@contoso.com"), ("revoked", "operator:root")]
|
|
227
|
+
now = datetime.now(timezone.utc)
|
|
228
|
+
keys = {u: [Ed25519PublicKey.from_public_bytes(bytes.fromhex(x)) for x in hs] for u, hs in reg.audit_keys_hex().items()}
|
|
229
|
+
for k in (k1, k2, k3):
|
|
230
|
+
g = sign_grant(build_grant(decision_sha256="a" * 64, approval_request_id="r", grant_id="g", not_after=now + timedelta(minutes=5)), k, "dana@contoso.com")
|
|
231
|
+
v = verify_grant(g, expected_decision_sha256="a" * 64, expected_approval_request_id="r", approver_public_keys=keys, gate_key_id="issuer-1", now=now)
|
|
232
|
+
assert v["accepted"] is True and v["reason"] == ACCEPT_GRANT_VALID
|
|
233
|
+
stranger = sign_grant(build_grant(decision_sha256="a" * 64, approval_request_id="r", grant_id="g", not_after=now + timedelta(minutes=5)),
|
|
234
|
+
Ed25519PrivateKey.generate(), "dana@contoso.com")
|
|
235
|
+
assert verify_grant(stranger, expected_decision_sha256="a" * 64, expected_approval_request_id="r", approver_public_keys=keys, gate_key_id="issuer-1", now=now)["accepted"] is False
|
|
236
|
+
# The revoked key k2 is refused for anyone, from history, even after Dana's revoked record is gone.
|
|
237
|
+
with pytest.raises(RevokedKeyError):
|
|
238
|
+
reg.enroll("lee@contoso.com", pub_hex(k2))
|
|
239
|
+
# A file from before history existed: its revoked record seeds the history.
|
|
240
|
+
old = tmp_path / "old.json"
|
|
241
|
+
old.write_text(json.dumps({"approvers": {}, "revoked": {"x@contoso.com": {"public_key_hex": pub_hex(k1), "revoked_at": "t", "revoked_by": "b"}}}))
|
|
242
|
+
assert ApproverRegistry(str(old)).audit_keys_hex() == {"x@contoso.com": [pub_hex(k1)]}
|
|
@@ -136,3 +136,10 @@ def test_approvers_list_and_revoke_on_the_issuer_host(tmp_path, capsys):
|
|
|
136
136
|
assert doc["approvers"] == {} and doc["revoked"]["dana@contoso.com"]["revoked_by"].startswith("operator:")
|
|
137
137
|
assert main(["approvers", "list", "--out", str(out)]) == 0
|
|
138
138
|
assert "revoked: dana@contoso.com" in capsys.readouterr().out
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_the_package_runs_as_a_module():
|
|
142
|
+
"""`python -m gargoyle --help` works where gargoyle.exe is not on PATH."""
|
|
143
|
+
import subprocess, sys
|
|
144
|
+
r = subprocess.run([sys.executable, "-m", "gargoyle", "--help"], capture_output=True, text=True, timeout=60)
|
|
145
|
+
assert r.returncode == 0 and "audit" in r.stdout
|
{gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_issuer_service.py
RENAMED
|
@@ -9,7 +9,8 @@ from fastapi.testclient import TestClient
|
|
|
9
9
|
from gargoyle.approval import REF_APPROVAL_SOD, build_grant, sign_grant
|
|
10
10
|
from gargoyle.consumer import Consumer
|
|
11
11
|
from gargoyle.issuer_service import (
|
|
12
|
-
REF_ISSUER_BAD_REQUEST, REF_ISSUER_NO_APPROVER_TRUST,
|
|
12
|
+
REF_ISSUER_BAD_REQUEST, REF_ISSUER_NO_APPROVER_TRUST, REF_ISSUER_TOO_MANY_HOLDS, REF_ISSUER_UNAUTHENTICATED,
|
|
13
|
+
REF_ISSUER_UNKNOWN_HOLD,
|
|
13
14
|
build_issuer_app,
|
|
14
15
|
)
|
|
15
16
|
from gargoyle.jws import REF_JWS_EXPIRED, encode
|
|
@@ -227,3 +228,68 @@ def test_the_approver_role_in_an_identity_token_also_sees_every_hold():
|
|
|
227
228
|
c.post("/issue", json={"target_url": TARGET, "tool": "write_file", "args": {"path": "x"}}, headers=auth(token(sub="carol@contoso.com", aud="gargoyle-issuer")))
|
|
228
229
|
assert c.get("/pending", headers=auth(token(sub="dana@contoso.com", aud="gargoyle-issuer", roles=["Approver"]))).json()["pending"][0]["args"] == {"path": "x"}
|
|
229
230
|
assert c.get("/pending", headers=auth(token(sub="erin@contoso.com", aud="gargoyle-issuer", roles=["Reader"]))).json()["pending"] == []
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_open_holds_per_principal_are_capped():
|
|
234
|
+
"""One runaway client cannot fill the approvers' queue: the cap counts a
|
|
235
|
+
principal's held receipts and plans that are neither expired nor released;
|
|
236
|
+
reads, other principals, declines and expiry are unaffected or free a slot."""
|
|
237
|
+
clock = {"now": NOW}
|
|
238
|
+
app = build_issuer_app(policy=ToolPolicy(), signing_key=issuer, key_id="issuer-1",
|
|
239
|
+
idp_public_keys={"idp-1": idp.public_key()},
|
|
240
|
+
approver_public_keys={"approver-alice": approver.public_key()},
|
|
241
|
+
audience="gargoyle-issuer", now=lambda: clock["now"], max_open_holds=2)
|
|
242
|
+
c = TestClient(app)
|
|
243
|
+
|
|
244
|
+
def write(i, tok=None):
|
|
245
|
+
return c.post("/issue", json={"target_url": TARGET, "tool": "write_file", "args": {"path": "%d.txt" % i, "content": "x"}},
|
|
246
|
+
headers=auth(tok)).json()
|
|
247
|
+
|
|
248
|
+
assert write(1)["issued"] is True and write(2)["issued"] is True
|
|
249
|
+
assert write(3) == {"issued": False, "reason": REF_ISSUER_TOO_MANY_HOLDS}
|
|
250
|
+
assert c.get("/health").json()["pending_receipts"] == 2 # the refused call stored nothing
|
|
251
|
+
assert c.post("/issue", json={"target_url": TARGET, "tool": "read_file", "args": {"path": "a"}}, headers=auth()).json()["issued"] is True
|
|
252
|
+
assert write(4, token(sub="spiffe://example.org/agents/other", aud="gargoyle-issuer"))["issued"] is True
|
|
253
|
+
plan = c.post("/plans", json={"target_url": TARGET, "steps": [{"tool": "write_file", "args": {"path": "p.txt", "content": "y"}}], "note": "n"},
|
|
254
|
+
headers=auth()).json()
|
|
255
|
+
assert plan == {"issued": False, "reason": REF_ISSUER_TOO_MANY_HOLDS}
|
|
256
|
+
mine = [h for h in c.get("/pending", headers=auth()).json()["pending"]]
|
|
257
|
+
assert len(mine) == 2 # the requester sees only their own
|
|
258
|
+
arid = mine[0]["envelope"]["approval_request_id"]
|
|
259
|
+
assert c.post("/decline", json={"approval_request_id": arid}, headers=auth()).json()["declined"] is True
|
|
260
|
+
assert write(5)["issued"] is True # the decline freed a slot
|
|
261
|
+
assert write(6)["issued"] is False
|
|
262
|
+
clock["now"] = NOW + timedelta(minutes=10) # expiry frees the rest
|
|
263
|
+
assert write(7, token(exp=NOW + timedelta(minutes=20), aud="gargoyle-issuer"))["issued"] is True
|
|
264
|
+
assert c.get("/health").json()["max_open_holds"] == 2
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_a_refused_release_is_an_event_and_sign_in_codes_leave_the_access_log():
|
|
268
|
+
"""A grant under an unknown or revoked key is refused and the refusal is
|
|
269
|
+
recorded (log and witness) with the key id and the reason; the live
|
|
270
|
+
revocation exercise of 2026-10-20 found it left no trace. And the Entra
|
|
271
|
+
redirect's one-time code is cut from uvicorn's access log."""
|
|
272
|
+
import logging
|
|
273
|
+
from gargoyle.approval import REF_APPROVAL_KEY_UNKNOWN
|
|
274
|
+
from gargoyle.issuer_service import redact_access_log
|
|
275
|
+
events = []
|
|
276
|
+
app = build_issuer_app(policy=ToolPolicy(), signing_key=issuer, key_id="issuer-1", idp_public_keys={"idp-1": idp.public_key()},
|
|
277
|
+
approver_public_keys={"approver-alice": approver.public_key()}, audience="gargoyle-issuer",
|
|
278
|
+
log_events=events.append, now=lambda: NOW)
|
|
279
|
+
c = TestClient(app)
|
|
280
|
+
env = c.post("/issue", json={"target_url": TARGET, "tool": "write_file", "args": {"path": "a.txt", "content": "x"}}, headers=auth()).json()["envelope"]
|
|
281
|
+
arid = env["approval_request_id"]
|
|
282
|
+
g = sign_grant(build_grant(decision_sha256=env["decision_sha256"], approval_request_id=arid, grant_id="g-9", not_after=NOW + timedelta(minutes=5)),
|
|
283
|
+
Ed25519PrivateKey.generate(), "mallory@example.org")
|
|
284
|
+
assert c.post("/grants", json=g, headers=auth()).json() == {"accepted": False, "reason": REF_APPROVAL_KEY_UNKNOWN}
|
|
285
|
+
refused = [e for e in events if e["event"] == "grant_refused"]
|
|
286
|
+
assert refused == [{"event": "grant_refused", "at": NOW.isoformat(), "approval_request_id": arid,
|
|
287
|
+
"approver": "mallory@example.org", "reason": REF_APPROVAL_KEY_UNKNOWN}]
|
|
288
|
+
assert c.get("/pending", headers=auth()).json()["pending"][0]["envelope"]["approval_request_id"] == arid # the hold stands
|
|
289
|
+
|
|
290
|
+
redact_access_log("test.access")
|
|
291
|
+
rec = logging.LogRecord("test.access", logging.INFO, "", 0, '%s - "%s %s HTTP/%s" %d',
|
|
292
|
+
("1.2.3.4:1", "GET", "/approve?code=SECRET&state=s", "1.1", 200), None)
|
|
293
|
+
for f in logging.getLogger("test.access").filters:
|
|
294
|
+
f.filter(rec)
|
|
295
|
+
assert rec.args[2] == "/approve?<redacted>" and rec.args[1] == "GET"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/key_record_source.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/published_record_source.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/gargoyle/specs/graph-reference.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_entra_identity.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_mcp_self_issue.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_published_record.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_stdio_encoding.py
RENAMED
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.3.dev0}/tests/test_typescript_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|