gargoyle-receipts 0.16.0.dev0__tar.gz → 0.16.2.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.2.dev0}/PKG-INFO +7 -3
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/README.md +6 -2
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/approvers.py +19 -2
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/cli.py +5 -3
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/issuer_service.py +80 -6
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/static/approve.html +13 -3
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0/gargoyle_receipts.egg-info}/PKG-INFO +7 -3
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/pyproject.toml +1 -1
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_approvers.py +37 -2
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_issuer_service.py +67 -1
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/adapters.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/approval.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/consumer.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/envelope.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/evidence.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/execution.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/holdstore.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/identity.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/mcp_server.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/plans.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/policy.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/published_record_source.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/reconcile.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/remote.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/replay_cache.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/revert.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/rules.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/specs/graph-reference.json +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/state.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/static/approve.js +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/verifier.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle/witness.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_cli.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_console_js.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_entra_identity.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_evidence.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_graph.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_historical.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_mcp_self_issue.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_plans.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_remote.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_revert.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_rules.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_state.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_threshold.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_wire_spec.py +0 -0
- {gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_witness.py +0 -0
{gargoyle_receipts-0.16.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.16.2.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.2.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.2.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,10 @@ 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. Approvers, and
|
|
299
302
|
only approvers, see the whole queue and the witness; everyone else sees
|
|
300
303
|
their own holds. Then paste the server entry from
|
|
301
304
|
`gargoyle-deploy/mcp-config.json` into your MCP client.
|
|
@@ -390,6 +393,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
390
393
|
| 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
394
|
| 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
395
|
| 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 |
|
|
396
|
+
| 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
397
|
| 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
398
|
| issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
|
|
395
399
|
| `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.2.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,10 @@ 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. Approvers, and
|
|
261
264
|
only approvers, see the whole queue and the witness; everyone else sees
|
|
262
265
|
their own holds. Then paste the server entry from
|
|
263
266
|
`gargoyle-deploy/mcp-config.json` into your MCP client.
|
|
@@ -352,6 +355,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
352
355
|
| 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
356
|
| 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
357
|
| 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 |
|
|
358
|
+
| 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
359
|
| 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
360
|
| issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
|
|
357
361
|
| `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 |
|
|
@@ -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
|
|
|
@@ -97,7 +102,11 @@ class ApproverRegistry:
|
|
|
97
102
|
UPN (one key per approver; a new browser re-enrolls). The directory's
|
|
98
103
|
immutable object id and tenant id are kept beside the UPN when the
|
|
99
104
|
token carried them: the UPN is the name people read, the oid is what
|
|
100
|
-
survives a rename.
|
|
105
|
+
survives a rename. A key on the revoked list is never enrolled again,
|
|
106
|
+
for anyone (RevokedKeyError): the live exercise of 2026-10-20 showed a
|
|
107
|
+
browser re-enrolling the very key the operator had just revoked. The
|
|
108
|
+
revoked record stays when a fresh key enrolls, so grants signed under
|
|
109
|
+
the old key still audit."""
|
|
101
110
|
if not isinstance(upn, str) or not upn:
|
|
102
111
|
raise ValueError("upn must be a non-empty string")
|
|
103
112
|
if not valid_public_key_hex(public_key_hex):
|
|
@@ -111,9 +120,10 @@ class ApproverRegistry:
|
|
|
111
120
|
entry["tid"] = tid
|
|
112
121
|
with self._lock:
|
|
113
122
|
self._refresh()
|
|
123
|
+
if any(e.get("public_key_hex") == public_key_hex for e in self._revoked.values()):
|
|
124
|
+
raise RevokedKeyError("that key was revoked; enroll a new one")
|
|
114
125
|
replaced = upn in self._approvers
|
|
115
126
|
self._approvers[upn] = entry
|
|
116
|
-
self._revoked.pop(upn, None) # enrolling again after a revocation is a fresh key
|
|
117
127
|
self._save()
|
|
118
128
|
return {"upn": upn, "replaced": replaced, **entry}
|
|
119
129
|
|
|
@@ -146,6 +156,13 @@ class ApproverRegistry:
|
|
|
146
156
|
self._refresh()
|
|
147
157
|
return {upn: e["public_key_hex"] for upn, e in self._approvers.items()}
|
|
148
158
|
|
|
159
|
+
def revoked_entries(self) -> Dict[str, Dict[str, Any]]:
|
|
160
|
+
"""The revoked list with when and by whom, re-read from the file: the
|
|
161
|
+
service notices an operator's `gargoyle approvers revoke` here."""
|
|
162
|
+
with self._lock:
|
|
163
|
+
self._refresh()
|
|
164
|
+
return {upn: dict(e) for upn, e in self._revoked.items()}
|
|
165
|
+
|
|
149
166
|
def revoked_hex(self) -> Dict[str, str]:
|
|
150
167
|
"""{upn: public_key_hex} of revoked approvers: for verifying the
|
|
151
168
|
grants they signed while trusted, never for accepting new ones."""
|
|
@@ -293,7 +293,7 @@ def cmd_remote(args) -> int:
|
|
|
293
293
|
|
|
294
294
|
def cmd_issuer(args) -> int:
|
|
295
295
|
import uvicorn
|
|
296
|
-
from gargoyle.issuer_service import app_from_env
|
|
296
|
+
from gargoyle.issuer_service import app_from_env, redact_access_log
|
|
297
297
|
|
|
298
298
|
out = Path(args.out).resolve()
|
|
299
299
|
os.environ.update(_read_env(out / "issuer.env"))
|
|
@@ -302,6 +302,7 @@ def cmd_issuer(args) -> int:
|
|
|
302
302
|
os.environ.setdefault("GARGOYLE_WITNESS_PATH", str(out / "witness.jsonl")) # the issuer's signed record, for reconciliation
|
|
303
303
|
os.environ.setdefault("GARGOYLE_LOG_JSON", "1")
|
|
304
304
|
app = app_from_env()
|
|
305
|
+
redact_access_log() # no sign-in codes in the access log
|
|
305
306
|
# The middleware reads the policy pin from a file; write it next to the
|
|
306
307
|
# config so the client-side entry finds it at the configured path.
|
|
307
308
|
from gargoyle.rules import RulesPolicy
|
|
@@ -345,9 +346,10 @@ def cmd_approvers(args) -> int:
|
|
|
345
346
|
for upn, e in sorted(entries.items()):
|
|
346
347
|
print("%s enrolled %s key %s%s" % (upn, e.get("enrolled_at", "?"), e["public_key_hex"][:16],
|
|
347
348
|
(" oid " + e["oid"]) if e.get("oid") else ""))
|
|
348
|
-
revoked = reg.
|
|
349
|
+
revoked = reg.revoked_entries()
|
|
349
350
|
if revoked:
|
|
350
|
-
print("revoked: " + ", ".join(
|
|
351
|
+
print("revoked: " + ", ".join("%s (key %s, %s by %s)" % (u, e["public_key_hex"][:16], e.get("revoked_at", "?")[:19], e.get("revoked_by", "?"))
|
|
352
|
+
for u, e in sorted(revoked.items())))
|
|
351
353
|
return 0
|
|
352
354
|
import getpass
|
|
353
355
|
if reg.revoke(args.upn, revoked_by="operator:" + getpass.getuser()):
|
|
@@ -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.2.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.2.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.2.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.2.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,10 @@ 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. Approvers, and
|
|
299
302
|
only approvers, see the whole queue and the witness; everyone else sees
|
|
300
303
|
their own holds. Then paste the server entry from
|
|
301
304
|
`gargoyle-deploy/mcp-config.json` into your MCP client.
|
|
@@ -390,6 +393,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
390
393
|
| 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
394
|
| 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
395
|
| 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 |
|
|
396
|
+
| 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
397
|
| 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
398
|
| issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
|
|
395
399
|
| `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.2.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,31 @@ 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")]
|
{gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.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
|
|
File without changes
|
{gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.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.2.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.2.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
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.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.2.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.2.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.2.dev0}/tests/test_stdio_encoding.py
RENAMED
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.16.0.dev0 → gargoyle_receipts-0.16.2.dev0}/tests/test_typescript_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|