gargoyle-receipts 0.5.2.dev0__tar.gz → 0.6.0.dev0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. {gargoyle_receipts-0.5.2.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.6.0.dev0}/PKG-INFO +10 -5
  2. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/README.md +9 -4
  3. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/approval.py +8 -3
  4. gargoyle_receipts-0.6.0.dev0/gargoyle/approvers.py +116 -0
  5. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/cli.py +6 -1
  6. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/consumer.py +11 -1
  7. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/envelope.py +26 -1
  8. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/evidence.py +12 -8
  9. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/execution.py +8 -4
  10. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/issuer_service.py +64 -2
  11. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/mcp_server.py +11 -0
  12. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/static/approve.html +52 -1
  13. gargoyle_receipts-0.6.0.dev0/gargoyle/static/approve.js +121 -0
  14. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/verifier.py +12 -3
  15. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +10 -5
  16. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +3 -0
  17. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/pyproject.toml +1 -1
  18. gargoyle_receipts-0.6.0.dev0/tests/test_approvers.py +111 -0
  19. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_console_js.py +20 -0
  20. gargoyle_receipts-0.6.0.dev0/tests/test_historical.py +82 -0
  21. gargoyle_receipts-0.5.2.dev0/gargoyle/static/approve.js +0 -59
  22. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/LICENSE +0 -0
  23. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/NOTICE +0 -0
  24. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/__init__.py +0 -0
  25. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/approver_cli.py +0 -0
  26. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/approver_trust.py +0 -0
  27. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/entra_login.py +0 -0
  28. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/fs_server.py +0 -0
  29. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/identity.py +0 -0
  30. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/issuer.py +0 -0
  31. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/jws.py +0 -0
  32. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/key_record_source.py +0 -0
  33. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/notify.py +0 -0
  34. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/opa.py +0 -0
  35. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/pending_store.py +0 -0
  36. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/policy.py +0 -0
  37. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/published_record_source.py +0 -0
  38. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/replay_cache.py +0 -0
  39. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle/rules.py +0 -0
  40. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  41. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  42. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  43. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  44. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/setup.cfg +0 -0
  45. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_annotations.py +0 -0
  46. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_approver_cli.py +0 -0
  47. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_canonical.py +0 -0
  48. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_cli.py +0 -0
  49. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_consumer.py +0 -0
  50. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_entra_identity.py +0 -0
  51. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_entra_login.py +0 -0
  52. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_evidence.py +0 -0
  53. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_execution.py +0 -0
  54. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_fs_server.py +0 -0
  55. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_identity.py +0 -0
  56. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_issuer_service.py +0 -0
  57. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_jws.py +0 -0
  58. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_mcp_proxy.py +0 -0
  59. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_mcp_self_issue.py +0 -0
  60. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_mcp_server.py +0 -0
  61. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_notify.py +0 -0
  62. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_opa.py +0 -0
  63. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_policy.py +0 -0
  64. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_published_record.py +0 -0
  65. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_rules.py +0 -0
  66. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_stdio_encoding.py +0 -0
  67. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_threshold.py +0 -0
  68. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_typescript_conformance.py +0 -0
  69. {gargoyle_receipts-0.5.2.dev0 → gargoyle_receipts-0.6.0.dev0}/tests/test_wire_spec.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.5.2.dev0
3
+ Version: 0.6.0.dev0
4
4
  Summary: Signed, single-use, argument-bound receipts and human-approval holds for agent tool calls.
5
5
  Author: Justin LaPorte
6
6
  License-Expression: Apache-2.0
@@ -59,7 +59,7 @@ your MCP server and lets through only what both agreed to.
59
59
 
60
60
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
61
61
 
62
- Status: pre-release (`0.5.2.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
62
+ Status: pre-release (`0.6.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
63
63
  still change before the first release; it is specified in [SPEC.md](SPEC.md).
64
64
 
65
65
  ## What a receipt gives you
@@ -255,9 +255,12 @@ Three commands, no policy engine, no containers, no client changes:
255
255
  gargoyle approve --watch 2 # where the approver sits (terminal)
256
256
 
257
257
  or approve in a browser: open the issuer's `/approve` page and load
258
- `approver-web.json`; the page shows each held call with its full arguments,
259
- checked against the receipt, and signs the grant locally with WebCrypto.
260
- The key never leaves the browser. Then paste the server entry from
258
+ `approver-web.json`, or, with Entra configured, sign in with Microsoft;
259
+ the page shows each held call with its full arguments, checked against
260
+ the receipt, and signs the grant locally with WebCrypto. The key never
261
+ leaves the browser, and with Entra it is generated there, non-exportable,
262
+ and enrolled under the approver's UPN, so grants and audits name the
263
+ person. Then paste the server entry from
261
264
  `gargoyle-deploy/mcp-config.json` into your MCP client.
262
265
  [docs/guide-hyperv-ubuntu-apache.md](docs/guide-hyperv-ubuntu-apache.md)
263
266
  is the full single-instance build: Ubuntu VM on Hyper-V, Apache with TLS,
@@ -298,6 +301,8 @@ cannot attach receipts work unchanged. Configuration is by environment:
298
301
  | `GARGOYLE_MCP_ISSUER_URL`, `GARGOYLE_MCP_CLIENT_TOKEN` | self-issue mode: the issuing service and the identity token (whose subject is this server's principal) to obtain receipts with; `GARGOYLE_MCP_APPROVAL_TIMEOUT_SECONDS` and `GARGOYLE_MCP_POLL_SECONDS` tune the wait for a grant |
299
302
  | issuer `GARGOYLE_ISSUER_AUDIENCE` | the `aud` an identity token must carry; a comma-separated list accepts several, so init-minted tokens (`gargoyle-issuer`) and a tenant's tokens (the issuer app's client id) work side by side |
300
303
  | `GARGOYLE_MCP_ENTRA_TENANT`, `_CLIENT_ID`, `_SCOPE`, `_TOKEN_CACHE` | instead of a fixed client token: the user's own Entra ID token, written by `gargoyle login` (device code, MSAL cache, silent refresh); the principal is their UPN. No usable token refuses `REF_TARGET_LOGIN_REQUIRED` |
304
+ | issuer `GARGOYLE_ENTRA_CLIENT_ID`, `GARGOYLE_ENTRA_SCOPE` | lets the console sign approvers in with Entra (code flow with PKCE, no library); the browser generates a non-exportable key and enrolls it under the UPN |
305
+ | issuer `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys) and which Entra app role (default `Approver`) or groups may enroll |
301
306
  | `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 |
302
307
  | `GARGOYLE_MCP_AUDIT_LOG` | the evidence log: every decision, honored or refused, as one hash-chained JSON line with its receipt, grant and execution receipt; `gargoyle audit --log` verifies the file |
303
308
 
@@ -21,7 +21,7 @@ your MCP server and lets through only what both agreed to.
21
21
 
22
22
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
23
23
 
24
- Status: pre-release (`0.5.2.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
24
+ Status: pre-release (`0.6.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
25
25
  still change before the first release; it is specified in [SPEC.md](SPEC.md).
26
26
 
27
27
  ## What a receipt gives you
@@ -217,9 +217,12 @@ Three commands, no policy engine, no containers, no client changes:
217
217
  gargoyle approve --watch 2 # where the approver sits (terminal)
218
218
 
219
219
  or approve in a browser: open the issuer's `/approve` page and load
220
- `approver-web.json`; the page shows each held call with its full arguments,
221
- checked against the receipt, and signs the grant locally with WebCrypto.
222
- The key never leaves the browser. Then paste the server entry from
220
+ `approver-web.json`, or, with Entra configured, sign in with Microsoft;
221
+ the page shows each held call with its full arguments, checked against
222
+ the receipt, and signs the grant locally with WebCrypto. The key never
223
+ leaves the browser, and with Entra it is generated there, non-exportable,
224
+ and enrolled under the approver's UPN, so grants and audits name the
225
+ person. Then paste the server entry from
223
226
  `gargoyle-deploy/mcp-config.json` into your MCP client.
224
227
  [docs/guide-hyperv-ubuntu-apache.md](docs/guide-hyperv-ubuntu-apache.md)
225
228
  is the full single-instance build: Ubuntu VM on Hyper-V, Apache with TLS,
@@ -260,6 +263,8 @@ cannot attach receipts work unchanged. Configuration is by environment:
260
263
  | `GARGOYLE_MCP_ISSUER_URL`, `GARGOYLE_MCP_CLIENT_TOKEN` | self-issue mode: the issuing service and the identity token (whose subject is this server's principal) to obtain receipts with; `GARGOYLE_MCP_APPROVAL_TIMEOUT_SECONDS` and `GARGOYLE_MCP_POLL_SECONDS` tune the wait for a grant |
261
264
  | issuer `GARGOYLE_ISSUER_AUDIENCE` | the `aud` an identity token must carry; a comma-separated list accepts several, so init-minted tokens (`gargoyle-issuer`) and a tenant's tokens (the issuer app's client id) work side by side |
262
265
  | `GARGOYLE_MCP_ENTRA_TENANT`, `_CLIENT_ID`, `_SCOPE`, `_TOKEN_CACHE` | instead of a fixed client token: the user's own Entra ID token, written by `gargoyle login` (device code, MSAL cache, silent refresh); the principal is their UPN. No usable token refuses `REF_TARGET_LOGIN_REQUIRED` |
266
+ | issuer `GARGOYLE_ENTRA_CLIENT_ID`, `GARGOYLE_ENTRA_SCOPE` | lets the console sign approvers in with Entra (code flow with PKCE, no library); the browser generates a non-exportable key and enrolls it under the UPN |
267
+ | issuer `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys) and which Entra app role (default `Approver`) or groups may enroll |
263
268
  | `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 |
264
269
  | `GARGOYLE_MCP_AUDIT_LOG` | the evidence log: every decision, honored or refused, as one hash-chained JSON line with its receipt, grant and execution receipt; `gargoyle audit --log` verifies the file |
265
270
 
@@ -19,7 +19,7 @@ grant_id is present, so that claim cannot be skipped.
19
19
  from datetime import datetime, timedelta
20
20
  from typing import Any, Dict, List, Optional
21
21
 
22
- from gargoyle.envelope import approval_terms, canonical_json
22
+ from gargoyle.envelope import approval_terms, canonical_json, serializer_for
23
23
  from gargoyle.verifier import not_after_valid
24
24
 
25
25
 
@@ -129,8 +129,11 @@ def verify_grant(
129
129
  gate_key_id: str,
130
130
  now: Optional[datetime] = None,
131
131
  clock_skew: timedelta = timedelta(0),
132
+ serializer: Any = None,
132
133
  ) -> Dict[str, Any]:
133
- """Is `grant` a valid human release of the held decision?
134
+ """Is `grant` a valid human release of the held decision? `serializer`
135
+ is the canonical form the grant was signed under (default: the current
136
+ one; an audit of a historical receipt passes its format's).
134
137
 
135
138
  Returns {"accepted", "reason"}. First failure wins:
136
139
  1. structure -> REF_APPROVAL_MALFORMED
@@ -180,7 +183,7 @@ def verify_grant(
180
183
 
181
184
  # 5. signature verifies (duck-typed; covers everything but the signature)
182
185
  region = {k: v for k, v in grant.items() if k not in _SIGNATURE_EXCLUDED_KEYS}
183
- message = canonical_json(region).encode("utf-8")
186
+ message = (serializer or canonical_json)(region).encode("utf-8")
184
187
  try:
185
188
  public_key.verify(bytes.fromhex(signature_hex), message)
186
189
  except Exception:
@@ -236,6 +239,7 @@ def verify_grants(
236
239
  with the grants that were counted, so the caller can claim each one."""
237
240
  terms = approval_terms(envelope)
238
241
  items = as_grant_list(grants)
242
+ serializer = serializer_for(envelope.get("envelope_version")) or canonical_json
239
243
  seen: List[str] = []
240
244
  for grant in items:
241
245
  g = verify_grant(
@@ -246,6 +250,7 @@ def verify_grants(
246
250
  gate_key_id=envelope.get("issuer_key_id"),
247
251
  now=now,
248
252
  clock_skew=clock_skew,
253
+ serializer=serializer,
249
254
  )
250
255
  if not g["accepted"]:
251
256
  return {"accepted": False, "reason": g["reason"], "grants": []}
@@ -0,0 +1,116 @@
1
+ """Enrolled approvers: a person's Entra identity bound to a key they hold.
2
+
3
+ `gargoyle init` writes approver key files. For a team, an approver should
4
+ instead sign in with Entra ID in the console, generate a key that never
5
+ leaves their browser, and enroll its public half once. The issuer accepts
6
+ the enrollment from a token that carries the approver app role (or a
7
+ configured group), binds the key to the token's UPN, and persists it here.
8
+ From then on the UPN is the approver key id on every grant, a threshold
9
+ rule may name UPNs, and removing a line from this file (or the role in
10
+ Entra) ends that person's ability to approve.
11
+
12
+ approvers.json = {"approvers": {"<upn>": {"public_key_hex": ..., "enrolled_at": ..., "enrolled_by": ...}}}
13
+
14
+ Trust note. The issuer publishes this map (GET /approvers) and the
15
+ middleware fetches it over the issuer's TLS. That makes the issuer's
16
+ operator the authority for who may approve, which is weaker than a
17
+ root-signed key record (SPEC.md section 10) and the right fit for a site
18
+ where the same administrator runs the issuer and the tenant. Separation of
19
+ duties still holds at the key level: an approver's key is never the
20
+ issuer's, and the issuer never sees a private key.
21
+ """
22
+
23
+ import json
24
+ import os
25
+ import stat
26
+ import threading
27
+ from datetime import datetime, timezone
28
+ from pathlib import Path
29
+ from typing import Any, Dict, Optional
30
+
31
+ ENV_APPROVERS_PATH = "GARGOYLE_APPROVERS_PATH"
32
+ ENV_APPROVER_ROLE = "GARGOYLE_APPROVER_ROLE" # Entra app role an enrolling token must carry (default Approver)
33
+ ENV_APPROVER_GROUPS = "GARGOYLE_APPROVER_GROUPS" # or: comma-separated group ids, any of which qualifies
34
+
35
+ DEFAULT_APPROVER_ROLE = "Approver"
36
+
37
+
38
+ def valid_public_key_hex(value: Any) -> bool:
39
+ return isinstance(value, str) and len(value) == 64 and all(c in "0123456789abcdef" for c in value)
40
+
41
+
42
+ class ApproverRegistry:
43
+ """The enrolled approvers, persisted to a JSON file. Thread-safe for the
44
+ single process that owns the file."""
45
+
46
+ def __init__(self, path: Optional[str]) -> None:
47
+ self.path = Path(path) if path else None
48
+ self._lock = threading.Lock()
49
+ self._approvers: Dict[str, Dict[str, Any]] = {}
50
+ if self.path is not None and self.path.exists():
51
+ doc = json.loads(self.path.read_text(encoding="utf-8"))
52
+ for upn, entry in (doc.get("approvers") or {}).items():
53
+ if isinstance(entry, dict) and valid_public_key_hex(entry.get("public_key_hex")):
54
+ self._approvers[upn] = dict(entry)
55
+
56
+ def _save(self) -> None:
57
+ if self.path is None:
58
+ return
59
+ self.path.parent.mkdir(parents=True, exist_ok=True)
60
+ self.path.write_text(json.dumps({"approvers": self._approvers}, indent=2, sort_keys=True) + "\n", encoding="utf-8")
61
+ try:
62
+ os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
63
+ except OSError:
64
+ pass
65
+
66
+ def enroll(self, upn: str, public_key_hex: str, *, enrolled_by: Optional[str] = None,
67
+ now: Optional[datetime] = None) -> Dict[str, Any]:
68
+ """Bind `upn` to `public_key_hex`, replacing any earlier key for that
69
+ UPN (one key per approver; a new browser re-enrolls)."""
70
+ if not isinstance(upn, str) or not upn:
71
+ raise ValueError("upn must be a non-empty string")
72
+ if not valid_public_key_hex(public_key_hex):
73
+ raise ValueError("public_key_hex must be 64 lowercase hex characters")
74
+ entry = {"public_key_hex": public_key_hex,
75
+ "enrolled_at": (now or datetime.now(timezone.utc)).isoformat(),
76
+ "enrolled_by": enrolled_by or upn}
77
+ with self._lock:
78
+ replaced = upn in self._approvers
79
+ self._approvers[upn] = entry
80
+ self._save()
81
+ return {"upn": upn, "replaced": replaced, **entry}
82
+
83
+ def remove(self, upn: str) -> bool:
84
+ with self._lock:
85
+ gone = self._approvers.pop(upn, None) is not None
86
+ if gone:
87
+ self._save()
88
+ return gone
89
+
90
+ def public_hex(self) -> Dict[str, str]:
91
+ """{upn: public_key_hex}, what GET /approvers publishes."""
92
+ with self._lock:
93
+ return {upn: e["public_key_hex"] for upn, e in self._approvers.items()}
94
+
95
+ def keys(self) -> Dict[str, Any]:
96
+ """{upn: Ed25519PublicKey}, the map a verifier uses."""
97
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
98
+ return {upn: Ed25519PublicKey.from_public_bytes(bytes.fromhex(h)) for upn, h in self.public_hex().items()}
99
+
100
+ def __contains__(self, upn: str) -> bool:
101
+ return upn in self._approvers
102
+
103
+
104
+ def may_enroll(who: Dict[str, Any], *, role: str = DEFAULT_APPROVER_ROLE, groups: Optional[list] = None) -> bool:
105
+ """Does an authenticated identity (identity.principal_from_token's
106
+ result) qualify as an approver: the app role, or any configured group."""
107
+ if role and role in (who.get("roles") or []):
108
+ return True
109
+ if groups and any(g in (who.get("groups") or []) for g in groups):
110
+ return True
111
+ return False
112
+
113
+
114
+ def keys_from_hex(mapping: Dict[str, str]) -> Dict[str, Any]:
115
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
116
+ return {k: Ed25519PublicKey.from_public_bytes(bytes.fromhex(v)) for k, v in mapping.items() if valid_public_key_hex(v)}
@@ -194,6 +194,7 @@ def cmd_issuer(args) -> int:
194
194
 
195
195
  out = Path(args.out).resolve()
196
196
  os.environ.update(_read_env(out / "issuer.env"))
197
+ os.environ.setdefault("GARGOYLE_APPROVERS_PATH", str(out / "approvers.json")) # enrolled approvers live beside the keys
197
198
  app = app_from_env()
198
199
  # The middleware reads the policy pin from a file; write it next to the
199
200
  # config so the client-side entry finds it at the configured path.
@@ -229,8 +230,12 @@ def cmd_audit(args) -> int:
229
230
 
230
231
  keys = json.loads((Path(args.out).resolve() / "public-keys.json").read_text())
231
232
  load = lambda m: {k: Ed25519PublicKey.from_public_bytes(bytes.fromhex(v)) for k, v in m.items()}
233
+ approvers = dict(keys["approver"])
234
+ enrolled = Path(args.out).resolve() / "approvers.json" # approvers who enrolled through Entra, by UPN
235
+ if enrolled.exists():
236
+ approvers.update({u: e["public_key_hex"] for u, e in json.loads(enrolled.read_text()).get("approvers", {}).items()})
232
237
  trust = dict(issuer_public_keys=load(keys["issuer"]), executor_public_keys=load(keys["executor"]),
233
- approver_public_keys=load(keys["approver"]))
238
+ approver_public_keys=load(approvers))
234
239
  if args.log:
235
240
  # The middleware's evidence log: every decision, honored or refused,
236
241
  # hash-chained. Each honored entry is a full receipt chain.
@@ -152,6 +152,7 @@ class Consumer:
152
152
  clock_skew: timedelta = timedelta(0),
153
153
  high_water: Optional[HighWater] = None,
154
154
  approver_public_keys: Optional[Dict[str, Any]] = None,
155
+ approver_key_source: Optional[Callable[[], Dict[str, Any]]] = None,
155
156
  executor_signing_key: Any = None,
156
157
  executor_key_id: Optional[str] = None,
157
158
  ) -> None:
@@ -195,6 +196,9 @@ class Consumer:
195
196
  self.clock_skew = clock_skew
196
197
  self.high_water = high_water if high_water is not None else HighWater()
197
198
  self.approver_public_keys = approver_public_keys
199
+ # Enrolled approvers (gargoyle.approvers), fetched live by the caller's
200
+ # function and merged over the static map at each flagged check.
201
+ self.approver_key_source = approver_key_source
198
202
 
199
203
  # ----- the live policy pin -----
200
204
 
@@ -253,7 +257,13 @@ class Consumer:
253
257
  if key_record_view is not None:
254
258
  return resolve_approver_keys(key_record_view, gate_key_id=gate_key_id,
255
259
  now=now, clock_skew=self.clock_skew)
256
- return dict(self.approver_public_keys or {})
260
+ keys = dict(self.approver_public_keys or {})
261
+ if self.approver_key_source is not None:
262
+ try:
263
+ keys.update({k: v for k, v in (self.approver_key_source() or {}).items() if k != gate_key_id})
264
+ except Exception:
265
+ pass # an unreachable source adds nothing; the static map stands
266
+ return keys
257
267
 
258
268
  def check(
259
269
  self,
@@ -88,6 +88,28 @@ def canonical_json(data: Any) -> str:
88
88
  return "".join(out)
89
89
 
90
90
 
91
+ def canonical_json_legacy(data: Any) -> str:
92
+ """The gargoyle/2 canonical form (Python json.dumps with sort_keys,
93
+ compact separators and ensure_ascii). Kept so that receipts, grants,
94
+ execution receipts and evidence entries written under gargoyle/2 can
95
+ still be verified in an audit. Nothing new is ever signed with it."""
96
+ return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
97
+
98
+
99
+ # Formats an auditor may still verify, each with the serializer its bytes
100
+ # were signed under. Admission (Consumer.check) accepts only ENVELOPE_VERSION.
101
+ HISTORICAL_VERSIONS = {"gargoyle/2": canonical_json_legacy}
102
+
103
+
104
+ def serializer_for(version: Any):
105
+ """The canonical serializer for a receipt format version: the current
106
+ one for ENVELOPE_VERSION, the legacy one for a historical version, None
107
+ for anything else."""
108
+ if version == ENVELOPE_VERSION:
109
+ return canonical_json
110
+ return HISTORICAL_VERSIONS.get(version)
111
+
112
+
91
113
  def _jcs(value: Any, out: list) -> None:
92
114
  if value is None:
93
115
  out.append("null")
@@ -200,8 +222,11 @@ def valid_approval(approval: Any) -> bool:
200
222
 
201
223
 
202
224
  def decision_sha256_of(envelope: Dict[str, Any]) -> str:
225
+ """The hash-region digest, under the serializer of the receipt's own
226
+ format version (a historical receipt hashes as it was issued)."""
203
227
  hashable = {k: v for k, v in envelope.items() if k not in _HASH_EXCLUDED_KEYS}
204
- return _sha256_text(canonical_json(hashable))
228
+ serializer = serializer_for(envelope.get("envelope_version")) or canonical_json
229
+ return _sha256_text(serializer(hashable))
205
230
 
206
231
 
207
232
  def live_pin(
@@ -7,7 +7,7 @@ executor's record of every decision it made, honored or refused, each entry
7
7
  hashed over the one before it:
8
8
 
9
9
  entry = {
10
- "evidence_version": "gargoyle-evidence/1",
10
+ "evidence_version": "gargoyle-evidence/2",
11
11
  "seq": 1, 2, 3 ... (dense, from 1)
12
12
  "prev_sha256": <entry_sha256 of seq-1; 64 zeros for seq 1>,
13
13
  "at": <when the executor decided, ISO 8601, UTC>,
@@ -44,11 +44,12 @@ from datetime import datetime, timezone
44
44
  from pathlib import Path
45
45
  from typing import Any, Dict, Iterable, List, Optional
46
46
 
47
- from gargoyle.envelope import args_sha256, canonical_json
47
+ from gargoyle.envelope import args_sha256, canonical_json, canonical_json_legacy
48
48
  from gargoyle.execution import verify_chain
49
49
  from gargoyle.verifier import verify_provenance
50
50
 
51
- EVIDENCE_VERSION = "gargoyle-evidence/1"
51
+ EVIDENCE_VERSION = "gargoyle-evidence/2" # entry hashes over RFC 8785 canonical JSON
52
+ EVIDENCE_VERSIONS = {"gargoyle-evidence/1": canonical_json_legacy, EVIDENCE_VERSION: canonical_json}
52
53
  GENESIS = "0" * 64
53
54
 
54
55
  HONORED = "honored"
@@ -67,9 +68,12 @@ _FIELDS = ("evidence_version", "seq", "prev_sha256", "at", "kind", "tool", "args
67
68
 
68
69
 
69
70
  def entry_sha256(entry: Dict[str, Any]) -> str:
70
- """The hash an entry carries: over its canonical JSON without the hash itself."""
71
+ """The hash an entry carries: over its canonical JSON without the hash
72
+ itself, under the serializer of the entry's own evidence_version, so a
73
+ log that spans a format change still verifies end to end."""
71
74
  body = {k: v for k, v in entry.items() if k != "entry_sha256"}
72
- return hashlib.sha256(canonical_json(body).encode("utf-8")).hexdigest()
75
+ serialize = EVIDENCE_VERSIONS.get(entry.get("evidence_version"), canonical_json)
76
+ return hashlib.sha256(serialize(body).encode("utf-8")).hexdigest()
73
77
 
74
78
 
75
79
  def build_entry(*, seq: int, prev_sha256: str, kind: str, tool: str, args: Any, principal: str,
@@ -113,7 +117,7 @@ class EvidenceLog:
113
117
  for line in self.path.read_text(encoding="utf-8").splitlines():
114
118
  if line.strip():
115
119
  last = json.loads(line)
116
- if last.get("evidence_version") == EVIDENCE_VERSION:
120
+ if last.get("evidence_version") in EVIDENCE_VERSIONS:
117
121
  self._seq, self._tail = int(last["seq"]), str(last["entry_sha256"])
118
122
 
119
123
  def append(self, kind: str, tool: str, args: Any, principal: str, reason: str, *,
@@ -132,7 +136,7 @@ class EvidenceLog:
132
136
 
133
137
  def _well_formed(entry: Any) -> bool:
134
138
  return (isinstance(entry, dict) and set(entry) == set(_FIELDS)
135
- and entry["evidence_version"] == EVIDENCE_VERSION
139
+ and entry["evidence_version"] in EVIDENCE_VERSIONS
136
140
  and isinstance(entry["seq"], int) and entry["seq"] >= 1
137
141
  and isinstance(entry["prev_sha256"], str) and len(entry["prev_sha256"]) == 64
138
142
  and isinstance(entry["at"], str) and entry["kind"] in (HONORED, REFUSED)
@@ -196,7 +200,7 @@ def verify_log(
196
200
  reason = ACCEPT_ENTRY_VERIFIED
197
201
  if e["receipt"] is not None:
198
202
  p = verify_provenance(e["receipt"], pinned_public_keys=issuer_public_keys,
199
- now=_parse_at(e["at"]))
203
+ now=_parse_at(e["at"]), historical=True)
200
204
  ok, reason = p["accepted"], (ACCEPT_ENTRY_VERIFIED if p["accepted"] else p["reason"])
201
205
  counts[e["kind"]] += 1
202
206
  report.append({"seq": e["seq"], "kind": e["kind"], "tool": e["tool"], "principal": e["principal"],
@@ -40,7 +40,7 @@ from datetime import datetime, timezone
40
40
  from typing import Any, Dict, Optional
41
41
 
42
42
  from gargoyle.approval import as_grant_list, verify_grants
43
- from gargoyle.envelope import args_sha256, canonical_json
43
+ from gargoyle.envelope import args_sha256, canonical_json, serializer_for
44
44
  from gargoyle.verifier import verify_provenance
45
45
 
46
46
  EXECUTION_VERSION = "gargoyle-execution/1"
@@ -145,13 +145,16 @@ def verify_execution_receipt(
145
145
  public_key = executor_public_keys.get(key_id)
146
146
  if public_key is None:
147
147
  return _reject(REF_EXEC_KEY_UNKNOWN)
148
+ # The execution receipt was signed under the canonical form of the
149
+ # receipt's format version; an audit of a historical chain uses that.
150
+ serialize = serializer_for(envelope.get("envelope_version")) or canonical_json
148
151
  region = {k: v for k, v in execution.items() if k not in _SIGNATURE_EXCLUDED_KEYS}
149
152
  try:
150
- public_key.verify(bytes.fromhex(signature_hex), canonical_json(region).encode("utf-8"))
153
+ public_key.verify(bytes.fromhex(signature_hex), serialize(region).encode("utf-8"))
151
154
  except Exception:
152
155
  return _reject(REF_EXEC_SIGNATURE_INVALID)
153
156
  for key in _BOUND_FROM_RECEIPT:
154
- if key not in envelope or canonical_json(execution[key]) != canonical_json(envelope[key]):
157
+ if key not in envelope or serialize(execution[key]) != serialize(envelope[key]):
155
158
  return _reject(REF_EXEC_BINDING_MISMATCH)
156
159
  if check_outputs:
157
160
  try:
@@ -190,7 +193,8 @@ def verify_chain(
190
193
  except (ValueError, TypeError):
191
194
  return {"accepted": False, "reason": REF_EXEC_MALFORMED, "steps": steps}
192
195
 
193
- provenance = verify_provenance(envelope, pinned_public_keys=issuer_public_keys, now=executed_at)
196
+ # An audit verifies historical formats; admission (the consumer) does not.
197
+ provenance = verify_provenance(envelope, pinned_public_keys=issuer_public_keys, now=executed_at, historical=True)
194
198
  steps.append(("authorization", provenance["reason"]))
195
199
  if not provenance["accepted"]:
196
200
  return {"accepted": False, "reason": provenance["reason"], "steps": steps}
@@ -53,6 +53,8 @@ from gargoyle.approval import (REF_APPROVAL_APPROVER_NOT_ELIGIBLE, REF_APPROVAL_
53
53
  verify_grant)
54
54
  from gargoyle.envelope import approval_terms
55
55
  from gargoyle.notify import notifier_from_env
56
+ from gargoyle.approvers import (DEFAULT_APPROVER_ROLE, ENV_APPROVER_GROUPS, ENV_APPROVER_ROLE, ENV_APPROVERS_PATH,
57
+ ApproverRegistry, may_enroll, valid_public_key_hex)
56
58
  from gargoyle.identity import entra_keys, principal_from_token
57
59
  from gargoyle.issuer import DEFAULT_TTL_SECONDS, issue
58
60
  from gargoyle.policy import Policy
@@ -69,12 +71,15 @@ ENV_AUDIENCE = "GARGOYLE_ISSUER_AUDIENCE"
69
71
  ENV_APPROVER_KEY_ID = "GARGOYLE_APPROVER_KEY_ID"
70
72
  ENV_APPROVER_PUBLIC_KEY_HEX = "GARGOYLE_APPROVER_PUBLIC_KEY_HEX"
71
73
  ENV_RECEIPT_TTL = "GARGOYLE_RECEIPT_TTL_SECONDS"
74
+ ENV_CONSOLE_CLIENT_ID = "GARGOYLE_ENTRA_CLIENT_ID" # the public client app the console signs in with (User-Client-Flow)
75
+ ENV_CONSOLE_SCOPE = "GARGOYLE_ENTRA_SCOPE" # default api://<last audience>/access
72
76
 
73
77
  REF_ISSUER_UNAUTHENTICATED = "REF_ISSUER_UNAUTHENTICATED"
74
78
  REF_ISSUER_BAD_REQUEST = "REF_ISSUER_BAD_REQUEST"
75
79
  REF_ISSUER_ARGS_NOT_TEXT = "REF_ISSUER_ARGS_NOT_TEXT" # arguments carry characters no UTF-8 text has
76
80
  REF_ISSUER_UNKNOWN_HOLD = "REF_ISSUER_UNKNOWN_HOLD"
77
81
  REF_ISSUER_NO_APPROVER_TRUST = "REF_ISSUER_NO_APPROVER_TRUST"
82
+ REF_ISSUER_NOT_AN_APPROVER = "REF_ISSUER_NOT_AN_APPROVER" # enrollment without the approver role or group
78
83
 
79
84
 
80
85
  def build_issuer_app(
@@ -88,6 +93,10 @@ def build_issuer_app(
88
93
  ttl_seconds: int = DEFAULT_TTL_SECONDS,
89
94
  now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
90
95
  on_hold: Optional[Callable[[Dict[str, Any], Any], None]] = None,
96
+ approver_registry: Optional[ApproverRegistry] = None,
97
+ approver_role: str = DEFAULT_APPROVER_ROLE,
98
+ approver_groups: Optional[list] = None,
99
+ console_entra: Optional[Dict[str, str]] = None,
91
100
  ) -> FastAPI:
92
101
  """`on_hold(envelope, args)` is called once for every new hold (gargoyle.notify
93
102
  posts it to a webhook); it is notice, not admission, and its failure
@@ -97,6 +106,12 @@ def build_issuer_app(
97
106
  app.state.pending_args: Dict[str, Any] = {} # approval_request_id -> the call's arguments
98
107
  app.state.grants: Dict[str, Dict[str, Any]] = {} # approval_request_id -> grant
99
108
  app.state.declined: set = set() # approval_request_ids a human refused
109
+ registry = approver_registry if approver_registry is not None else ApproverRegistry(None)
110
+ static_approvers = dict(approver_public_keys or {})
111
+
112
+ def approver_keys() -> Dict[str, Any]:
113
+ """Static keys from configuration plus every enrolled approver, by UPN."""
114
+ return {**static_approvers, **registry.keys()}
100
115
 
101
116
  def _refuse(status: int, reason: str) -> HTTPException:
102
117
  return HTTPException(status_code=status, detail={"reason": reason})
@@ -182,10 +197,11 @@ def build_issuer_app(
182
197
  env = app.state.pending.get(arid) if isinstance(arid, str) else None
183
198
  if env is None:
184
199
  return {"accepted": False, "reason": REF_ISSUER_UNKNOWN_HOLD}
185
- if not approver_public_keys:
200
+ trusted = approver_keys()
201
+ if not trusted:
186
202
  return {"accepted": False, "reason": REF_ISSUER_NO_APPROVER_TRUST}
187
203
  g = verify_grant(grant, expected_decision_sha256=env["decision_sha256"],
188
- expected_approval_request_id=arid, approver_public_keys=approver_public_keys,
204
+ expected_approval_request_id=arid, approver_public_keys=trusted,
189
205
  gate_key_id=key_id, now=now())
190
206
  if not g["accepted"]:
191
207
  return {"accepted": False, "reason": g["reason"]}
@@ -230,6 +246,46 @@ def build_issuer_app(
230
246
  "collected": len(have), "required": approval_terms(env)["required"] if env else None,
231
247
  "declined": approval_request_id in app.state.declined}
232
248
 
249
+ @app.get("/approvers")
250
+ async def approvers():
251
+ """Public, like /record: the enrolled approvers' public keys by UPN,
252
+ for the middleware and for audits. Static configured keys are not
253
+ listed; they travel in config."""
254
+ return {"approvers": registry.public_hex()}
255
+
256
+ @app.post("/approvers/enroll")
257
+ async def enroll(request: Request, authorization: Optional[str] = Header(default=None)):
258
+ """Bind the caller's UPN to the public key their browser generated.
259
+ Only a token carrying the approver role (or a configured group) may;
260
+ the UPN comes from the token, never from the body."""
261
+ who = authenticate(authorization)
262
+ if not may_enroll(who, role=approver_role, groups=approver_groups):
263
+ raise _refuse(403, REF_ISSUER_NOT_AN_APPROVER)
264
+ try:
265
+ body = await request.json()
266
+ except Exception:
267
+ raise _refuse(400, REF_ISSUER_BAD_REQUEST)
268
+ if not isinstance(body, dict) or not valid_public_key_hex(body.get("public_key_hex")):
269
+ raise _refuse(400, REF_ISSUER_BAD_REQUEST)
270
+ result = registry.enroll(who["principal"], body["public_key_hex"], now=now())
271
+ return {"enrolled": True, "key_id": who["principal"], "replaced": result["replaced"]}
272
+
273
+ @app.get("/me")
274
+ async def me(authorization: Optional[str] = Header(default=None)):
275
+ """Who the console is signed in as, whether they may approve, and
276
+ the public key enrolled for them (so the browser can tell whether
277
+ its own key is the one on record)."""
278
+ who = authenticate(authorization)
279
+ upn = who["principal"]
280
+ return {"principal": upn, "may_approve": may_enroll(who, role=approver_role, groups=approver_groups) or upn in static_approvers,
281
+ "enrolled_public_key_hex": registry.public_hex().get(upn)}
282
+
283
+ @app.get("/approve-config")
284
+ async def approve_config():
285
+ """Public: how the console signs in. `entra` is null when the site
286
+ uses approver files instead."""
287
+ return {"entra": console_entra}
288
+
233
289
  @app.get("/approve")
234
290
  async def approve_page():
235
291
  return Response((STATIC / "approve.html").read_bytes(), media_type="text/html; charset=utf-8")
@@ -298,4 +354,10 @@ def app_from_env() -> FastAPI:
298
354
  audience=os.environ.get(ENV_AUDIENCE) or None,
299
355
  ttl_seconds=int(os.environ.get(ENV_RECEIPT_TTL, DEFAULT_TTL_SECONDS)),
300
356
  on_hold=notifier_from_env(os.environ),
357
+ approver_registry=ApproverRegistry(os.environ.get(ENV_APPROVERS_PATH)),
358
+ approver_role=os.environ.get(ENV_APPROVER_ROLE, DEFAULT_APPROVER_ROLE),
359
+ approver_groups=[g.strip() for g in os.environ.get(ENV_APPROVER_GROUPS, "").split(",") if g.strip()] or None,
360
+ console_entra=({"tenant": tenant, "client_id": os.environ[ENV_CONSOLE_CLIENT_ID],
361
+ "scope": os.environ.get(ENV_CONSOLE_SCOPE) or "api://%s/access" % (os.environ.get(ENV_AUDIENCE) or "").split(",")[-1].strip()}
362
+ if tenant and os.environ.get(ENV_CONSOLE_CLIENT_ID) else None),
301
363
  )
@@ -160,6 +160,16 @@ class IssuerClient:
160
160
  return {"envelope": None, "reason": body.get("reason") or REF_TARGET_ISSUER_UNAVAILABLE}
161
161
  return {"envelope": body.get("envelope"), "reason": None}
162
162
 
163
+ def approver_keys(self) -> Dict[str, Any]:
164
+ """The issuer's enrolled approvers as {upn: public key}; {} when the
165
+ issuer cannot be reached (the static map then stands alone)."""
166
+ from gargoyle.approvers import keys_from_hex
167
+ try:
168
+ r = self._get("/approvers")
169
+ return keys_from_hex(r.json().get("approvers", {})) if r.status_code == 200 else {}
170
+ except Exception:
171
+ return {}
172
+
163
173
  def wait_for_grant(self, approval_request_id: str) -> Dict[str, Any]:
164
174
  """Poll until the hold has its grant (or, for a receipt that needs
165
175
  several approvers, the whole set), a human declined it, the hold
@@ -284,6 +294,7 @@ class ServerState:
284
294
  record=config["record"],
285
295
  replay_cache=replay_cache,
286
296
  approver_public_keys=config.get("approver_public_keys"),
297
+ approver_key_source=(issuer_client.approver_keys if issuer_client is not None else None),
287
298
  executor_signing_key=config.get("executor_signing_key"),
288
299
  executor_key_id=config.get("executor_key_id"),
289
300
  )