gargoyle-receipts 0.16.2.dev0__tar.gz → 0.16.4.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 (84) hide show
  1. {gargoyle_receipts-0.16.2.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.16.4.dev0}/PKG-INFO +12 -4
  2. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/README.md +11 -3
  3. gargoyle_receipts-0.16.4.dev0/gargoyle/__main__.py +9 -0
  4. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/approval.py +19 -0
  5. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/approvers.py +48 -3
  6. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/cli.py +65 -8
  7. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/mcp_server.py +8 -6
  8. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0/gargoyle_receipts.egg-info}/PKG-INFO +12 -4
  9. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +1 -0
  10. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/pyproject.toml +1 -1
  11. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_approvers.py +36 -0
  12. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_cli.py +36 -1
  13. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/LICENSE +0 -0
  14. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/NOTICE +0 -0
  15. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/__init__.py +0 -0
  16. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/adapters.py +0 -0
  17. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/approver_cli.py +0 -0
  18. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/approver_trust.py +0 -0
  19. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/consumer.py +0 -0
  20. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/entra_login.py +0 -0
  21. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/envelope.py +0 -0
  22. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/evidence.py +0 -0
  23. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/execution.py +0 -0
  24. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/fs_server.py +0 -0
  25. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/holdstore.py +0 -0
  26. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/identity.py +0 -0
  27. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/issuer.py +0 -0
  28. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/issuer_service.py +0 -0
  29. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/jws.py +0 -0
  30. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/key_record_source.py +0 -0
  31. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/notify.py +0 -0
  32. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/opa.py +0 -0
  33. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/pending_store.py +0 -0
  34. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/plans.py +0 -0
  35. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/policy.py +0 -0
  36. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/published_record_source.py +0 -0
  37. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/reconcile.py +0 -0
  38. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/remote.py +0 -0
  39. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/replay_cache.py +0 -0
  40. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/revert.py +0 -0
  41. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/rules.py +0 -0
  42. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/specs/graph-reference.json +0 -0
  43. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/state.py +0 -0
  44. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/static/approve.html +0 -0
  45. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/static/approve.js +0 -0
  46. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/verifier.py +0 -0
  47. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle/witness.py +0 -0
  48. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  49. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  50. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  51. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  52. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/setup.cfg +0 -0
  53. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_annotations.py +0 -0
  54. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_approver_cli.py +0 -0
  55. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_canonical.py +0 -0
  56. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_console_js.py +0 -0
  57. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_consumer.py +0 -0
  58. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_entra_identity.py +0 -0
  59. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_entra_login.py +0 -0
  60. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_evidence.py +0 -0
  61. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_execution.py +0 -0
  62. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_fs_server.py +0 -0
  63. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_graph.py +0 -0
  64. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_historical.py +0 -0
  65. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_identity.py +0 -0
  66. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_issuer_service.py +0 -0
  67. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_jws.py +0 -0
  68. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_mcp_proxy.py +0 -0
  69. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_mcp_self_issue.py +0 -0
  70. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_mcp_server.py +0 -0
  71. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_notify.py +0 -0
  72. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_opa.py +0 -0
  73. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_plans.py +0 -0
  74. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_policy.py +0 -0
  75. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_published_record.py +0 -0
  76. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_remote.py +0 -0
  77. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_revert.py +0 -0
  78. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_rules.py +0 -0
  79. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_state.py +0 -0
  80. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_stdio_encoding.py +0 -0
  81. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_threshold.py +0 -0
  82. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_typescript_conformance.py +0 -0
  83. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_wire_spec.py +0 -0
  84. {gargoyle_receipts-0.16.2.dev0 → gargoyle_receipts-0.16.4.dev0}/tests/test_witness.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.16.2.dev0
3
+ Version: 0.16.4.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.16.2.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
62
+ Status: pre-release (`0.16.4.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
@@ -278,9 +278,14 @@ chat channel can approve anything.
278
278
 
279
279
  pip install gargoyle-receipts
280
280
 
281
- Three commands, no policy engine, no containers, no client changes:
281
+ Three commands, no policy engine, no containers, no client changes
282
+ (`--observe` starts a deployment in record-only mode, every call allowed
283
+ including destructive ones and every call recorded; `gargoyle audit
284
+ --summary` then shows calls by principal and tool, the input for the
285
+ hold and deny rules):
282
286
 
283
287
  gargoyle init /path/the/agent/may/touch # keys, config, rules.json
288
+ gargoyle init --observe /path/... # or: allow and record everything, hold nothing, tighten later
284
289
  gargoyle issuer # the issuing service, on rules.json
285
290
  gargoyle approve --watch 2 # where the approver sits (terminal)
286
291
 
@@ -298,7 +303,10 @@ stay on file so earlier grants still audit; new ones are refused at the
298
303
  issuer and at every middleware on its next held call. Revocation retires
299
304
  a key, not a person: while the Approver role stands, the person can sign
300
305
  in and enroll a fresh key, so remove the role too; the revoked key itself
301
- is never accepted again. Approvers, and
306
+ is never accepted again. Every key an approver held stays in the file
307
+ (`history`), so an audit of grants released under an earlier key still
308
+ verifies: copy the issuer's current `approvers.json` beside the evidence
309
+ before auditing. Approvers, and
302
310
  only approvers, see the whole queue and the witness; everyone else sees
303
311
  their own holds. Then paste the server entry from
304
312
  `gargoyle-deploy/mcp-config.json` into your MCP client.
@@ -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.16.2.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
24
+ Status: pre-release (`0.16.4.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
@@ -240,9 +240,14 @@ chat channel can approve anything.
240
240
 
241
241
  pip install gargoyle-receipts
242
242
 
243
- Three commands, no policy engine, no containers, no client changes:
243
+ Three commands, no policy engine, no containers, no client changes
244
+ (`--observe` starts a deployment in record-only mode, every call allowed
245
+ including destructive ones and every call recorded; `gargoyle audit
246
+ --summary` then shows calls by principal and tool, the input for the
247
+ hold and deny rules):
244
248
 
245
249
  gargoyle init /path/the/agent/may/touch # keys, config, rules.json
250
+ gargoyle init --observe /path/... # or: allow and record everything, hold nothing, tighten later
246
251
  gargoyle issuer # the issuing service, on rules.json
247
252
  gargoyle approve --watch 2 # where the approver sits (terminal)
248
253
 
@@ -260,7 +265,10 @@ stay on file so earlier grants still audit; new ones are refused at the
260
265
  issuer and at every middleware on its next held call. Revocation retires
261
266
  a key, not a person: while the Approver role stands, the person can sign
262
267
  in and enroll a fresh key, so remove the role too; the revoked key itself
263
- is never accepted again. Approvers, and
268
+ is never accepted again. Every key an approver held stays in the file
269
+ (`history`), so an audit of grants released under an earlier key still
270
+ verifies: copy the issuer's current `approvers.json` beside the evidence
271
+ before auditing. Approvers, and
264
272
  only approvers, see the whole queue and the witness; everyone else sees
265
273
  their own holds. Then paste the server entry from
266
274
  `gargoyle-deploy/mcp-config.json` into your MCP client.
@@ -0,0 +1,9 @@
1
+ """`python -m gargoyle` is the CLI: on a Windows Store Python the Scripts folder
2
+ that holds gargoyle.exe is usually not on PATH."""
3
+
4
+ import sys
5
+
6
+ from gargoyle.cli import main
7
+
8
+ if __name__ == "__main__":
9
+ sys.exit(main())
@@ -32,6 +32,21 @@ _SIGNATURE_EXCLUDED_KEYS = ("approver_signature",)
32
32
 
33
33
  # Refusal codes.
34
34
  REF_APPROVAL_MALFORMED = "REF_APPROVAL_MALFORMED"
35
+
36
+
37
+ class _AnyOf:
38
+ """A verifying key made of several: the signature is valid if any verifies it."""
39
+ def __init__(self, keys: list) -> None:
40
+ self.keys = keys
41
+
42
+ def verify(self, signature: bytes, message: bytes) -> None:
43
+ for key in self.keys[:-1]:
44
+ try:
45
+ key.verify(signature, message)
46
+ return
47
+ except Exception:
48
+ continue
49
+ self.keys[-1].verify(signature, message)
35
50
  REF_APPROVAL_MISSING_GRANT_ID = "REF_APPROVAL_MISSING_GRANT_ID"
36
51
  REF_APPROVAL_SIGNATURE_INVALID = "REF_APPROVAL_SIGNATURE_INVALID"
37
52
  REF_APPROVAL_KEY_UNKNOWN = "REF_APPROVAL_KEY_UNKNOWN"
@@ -178,6 +193,10 @@ def verify_grant(
178
193
 
179
194
  # 4. known approver key
180
195
  public_key = approver_public_keys.get(key_id)
196
+ if isinstance(public_key, (list, tuple)):
197
+ # Several keys for one approver id: an audit of grants from before the
198
+ # approver's browser key was replaced or revoked. Any of them may verify.
199
+ public_key = _AnyOf(list(public_key)) if public_key else None
181
200
  if public_key is None:
182
201
  return _reject(REF_APPROVAL_KEY_UNKNOWN)
183
202
 
@@ -26,7 +26,7 @@ import stat
26
26
  import threading
27
27
  from datetime import datetime, timezone
28
28
  from pathlib import Path
29
- from typing import Any, Dict, Optional
29
+ from typing import List, Any, Dict, Optional
30
30
 
31
31
  ENV_APPROVERS_PATH = "GARGOYLE_APPROVERS_PATH"
32
32
  ENV_APPROVER_ROLE = "GARGOYLE_APPROVER_ROLE" # Entra app role an enrolling token must carry (default Approver)
@@ -53,6 +53,7 @@ class ApproverRegistry:
53
53
  self._lock = threading.Lock()
54
54
  self._approvers: Dict[str, Dict[str, Any]] = {}
55
55
  self._revoked: Dict[str, Dict[str, Any]] = {} # former approvers, kept so old grants still verify in audits
56
+ self._history: Dict[str, List[Dict[str, Any]]] = {} # every key each UPN held and how it ended (replaced, revoked)
56
57
  self._mtime: Optional[float] = None
57
58
  self._load()
58
59
 
@@ -67,7 +68,16 @@ class ApproverRegistry:
67
68
  for upn, entry in (doc.get("revoked") or {}).items():
68
69
  if isinstance(entry, dict) and valid_public_key_hex(entry.get("public_key_hex")):
69
70
  revoked[upn] = dict(entry)
70
- self._approvers, self._revoked = approvers, revoked
71
+ history: Dict[str, List[Dict[str, Any]]] = {}
72
+ for upn, items in (doc.get("history") or {}).items():
73
+ if isinstance(items, list):
74
+ history[upn] = [dict(h) for h in items if isinstance(h, dict) and valid_public_key_hex(h.get("public_key_hex"))]
75
+ for upn, e in revoked.items():
76
+ # A file from before history existed: its revoked record is the one former key known.
77
+ if not any(h.get("public_key_hex") == e["public_key_hex"] for h in history.get(upn, [])):
78
+ history.setdefault(upn, []).append({**{k: v for k, v in e.items() if k not in ("revoked_at", "revoked_by")},
79
+ "ended_at": e.get("revoked_at"), "ended_by": e.get("revoked_by"), "reason": "revoked"})
80
+ self._approvers, self._revoked, self._history = approvers, revoked, history
71
81
  self._mtime = self.path.stat().st_mtime
72
82
 
73
83
  def _refresh(self) -> None:
@@ -89,6 +99,8 @@ class ApproverRegistry:
89
99
  doc = {"approvers": self._approvers}
90
100
  if self._revoked:
91
101
  doc["revoked"] = self._revoked
102
+ if self._history:
103
+ doc["history"] = self._history
92
104
  self.path.write_text(json.dumps(doc, indent=2, sort_keys=True) + "\n", encoding="utf-8")
93
105
  try:
94
106
  os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
@@ -120,13 +132,45 @@ class ApproverRegistry:
120
132
  entry["tid"] = tid
121
133
  with self._lock:
122
134
  self._refresh()
123
- if any(e.get("public_key_hex") == public_key_hex for e in self._revoked.values()):
135
+ if public_key_hex in self._revoked_keys():
124
136
  raise RevokedKeyError("that key was revoked; enroll a new one")
125
137
  replaced = upn in self._approvers
138
+ if replaced and self._approvers[upn].get("public_key_hex") != public_key_hex:
139
+ # The superseded key is kept: grants signed under it still audit.
140
+ self._remember(upn, self._approvers[upn], "replaced", enrolled_by or upn, now)
126
141
  self._approvers[upn] = entry
127
142
  self._save()
128
143
  return {"upn": upn, "replaced": replaced, **entry}
129
144
 
145
+ def _remember(self, upn: str, entry: Dict[str, Any], reason: str, ended_by: str, now: Optional[datetime]) -> None:
146
+ hist = self._history.setdefault(upn, [])
147
+ if not any(h.get("public_key_hex") == entry.get("public_key_hex") for h in hist):
148
+ hist.append({**{k: v for k, v in entry.items() if k not in ("revoked_at", "revoked_by")},
149
+ "ended_at": (now or datetime.now(timezone.utc)).isoformat(), "ended_by": ended_by, "reason": reason})
150
+
151
+ def _revoked_keys(self) -> set:
152
+ """Every public key ever revoked, under any UPN: none may enroll again."""
153
+ return ({e.get("public_key_hex") for e in self._revoked.values()}
154
+ | {h.get("public_key_hex") for hist in self._history.values() for h in hist if h.get("reason") == "revoked"})
155
+
156
+ def audit_keys_hex(self) -> Dict[str, List[str]]:
157
+ """{upn: [public_key_hex, ...]}: the current key first, then every key
158
+ the UPN held before. An audit verifies a grant with any of them; the
159
+ live service and middleware trust only the current one."""
160
+ with self._lock:
161
+ self._refresh()
162
+ out: Dict[str, List[str]] = {}
163
+ for upn, e in self._approvers.items():
164
+ out[upn] = [e["public_key_hex"]]
165
+ for upn, hist in self._history.items():
166
+ for h in hist:
167
+ if h["public_key_hex"] not in out.setdefault(upn, []):
168
+ out[upn].append(h["public_key_hex"])
169
+ for upn, e in self._revoked.items():
170
+ if e["public_key_hex"] not in out.setdefault(upn, []):
171
+ out[upn].append(e["public_key_hex"])
172
+ return out
173
+
130
174
  def revoke(self, upn: str, *, revoked_by: Optional[str] = None, now: Optional[datetime] = None) -> bool:
131
175
  """Stop trusting `upn`'s key from now on. The entry moves to the
132
176
  revoked list with when and by whom, so an audit of earlier evidence
@@ -139,6 +183,7 @@ class ApproverRegistry:
139
183
  return False
140
184
  self._revoked[upn] = {**entry, "revoked_at": (now or datetime.now(timezone.utc)).isoformat(),
141
185
  "revoked_by": revoked_by or upn}
186
+ self._remember(upn, entry, "revoked", revoked_by or upn, now)
142
187
  self._save()
143
188
  return True
144
189
 
@@ -39,6 +39,7 @@ the response metadata; `gargoyle audit` verifies them later, offline.
39
39
 
40
40
  import argparse
41
41
  import json
42
+ from typing import Any, Dict
42
43
  import os
43
44
  import re
44
45
  import stat
@@ -50,6 +51,17 @@ from pathlib import Path
50
51
 
51
52
  PRINCIPAL = "spiffe://gargoyle.local/agents/assistant"
52
53
 
54
+ OBSERVE_RULES = {
55
+ # Observe mode: every call allowed (deletes too), every call recorded, none held. The
56
+ # first week of a deployment, so the hold and deny rules are written
57
+ # from the audit rather than guessed. The policy pin changes when the
58
+ # rules do, so switching modes means restarting the issuer and copying
59
+ # the new record.json to each client.
60
+ "version": "1",
61
+ "default": "allow",
62
+ "rules": [],
63
+ }
64
+
53
65
  DEFAULT_RULES = {
54
66
  "version": "1",
55
67
  "default": "deny",
@@ -117,7 +129,22 @@ def cmd_init(args) -> int:
117
129
  issuer_url = args.issuer_url or "http://%s:%d" % (args.host, args.port)
118
130
 
119
131
  rules = out / "rules.json"
120
- if not rules.exists():
132
+ if args.observe:
133
+ current = None
134
+ if rules.exists():
135
+ try:
136
+ current = json.loads(rules.read_text())
137
+ except ValueError as exc:
138
+ print("%s is not valid JSON (%s); fix or remove it before --observe" % (rules, exc), file=sys.stderr)
139
+ return 2
140
+ if current is None or current == DEFAULT_RULES:
141
+ _write(rules, json.dumps(OBSERVE_RULES, indent=2) + "\n")
142
+ elif current != OBSERVE_RULES:
143
+ print("rules.json was edited by hand and is left as it is; --observe changed nothing", file=sys.stderr)
144
+ print("observe mode: rules.json allows every call, destructive ones included; the gate records all of "
145
+ "them and holds none. Tighten rules.json from `gargoyle audit --summary`, then restart the issuer "
146
+ "and copy the new record.json to the clients.")
147
+ elif not rules.exists():
121
148
  _write(rules, json.dumps(DEFAULT_RULES, indent=2) + "\n")
122
149
  _write(out / "issuer.env", _env_lines({
123
150
  "GARGOYLE_ISSUER_KEY_ID": "issuer-1", "GARGOYLE_ISSUER_KEY_HEX": issuer_hex,
@@ -359,20 +386,45 @@ def cmd_approvers(args) -> int:
359
386
  return 1
360
387
 
361
388
 
389
+ def _audit_summary(report, raw) -> None:
390
+ """Calls by principal and tool with their outcomes, then who released
391
+ and which clients: a week of observe mode as a table, the input for
392
+ writing the hold and deny rules."""
393
+ from collections import Counter
394
+ counts: Dict[tuple, Counter] = {}
395
+ releases: Counter = Counter()
396
+ clients: Counter = Counter()
397
+ ats = [e.get("at") for e in raw if isinstance(e, dict) and isinstance(e.get("at"), str)]
398
+ for r in report:
399
+ counts.setdefault((r["principal"] or "?", r["tool"] or "?"), Counter())[r["kind"] or "?"] += 1
400
+ for who in r.get("released_by") or []:
401
+ releases[who] += 1
402
+ clients[r.get("client") or "(client unnamed)"] += 1
403
+ print("summary: %d call(s)%s" % (len(report), (", %s to %s" % (min(ats)[:19], max(ats)[:19])) if ats else ""))
404
+ print(" %-28s %-24s %7s %7s" % ("principal", "tool", "honored", "refused"))
405
+ for (principal, tool), c in sorted(counts.items(), key=lambda kv: (kv[0][0], -sum(kv[1].values()), kv[0][1])):
406
+ print(" %-28s %-24s %7d %7d" % (principal[:28], tool[:24], c.get("honored", 0), c.get("refused", 0)))
407
+ if releases:
408
+ print(" releases: " + ", ".join("%s %d" % (w, n) for w, n in releases.most_common()))
409
+ print(" clients: " + ", ".join("%s %d" % (w, n) for w, n in clients.most_common()))
410
+
411
+
362
412
  def cmd_audit(args) -> int:
363
413
  from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
364
414
  from gargoyle.execution import verify_chain
365
415
 
366
416
  keys = json.loads((Path(args.out).resolve() / "public-keys.json").read_text())
367
- load = lambda m: {k: Ed25519PublicKey.from_public_bytes(bytes.fromhex(v)) for k, v in m.items()}
368
- approvers = dict(keys["approver"])
417
+ one = lambda h: Ed25519PublicKey.from_public_bytes(bytes.fromhex(h))
418
+ load = lambda m: {k: ([one(x) for x in v] if isinstance(v, list) else one(v)) for k, v in m.items()}
419
+ approvers: Dict[str, Any] = dict(keys["approver"])
369
420
  enrolled = Path(args.out).resolve() / "approvers.json" # approvers who enrolled through Entra, by UPN
370
421
  if enrolled.exists():
371
- doc = json.loads(enrolled.read_text())
372
- # Revoked approvers too: the audit verifies what was signed while they
373
- # were trusted. Only the live service and middleware refuse them.
374
- approvers.update({u: e["public_key_hex"] for u, e in doc.get("revoked", {}).items()})
375
- approvers.update({u: e["public_key_hex"] for u, e in doc.get("approvers", {}).items()})
422
+ from gargoyle.approvers import ApproverRegistry
423
+ # Every key each approver ever held, current first: grants signed
424
+ # under a key since replaced in the browser or revoked still have to
425
+ # verify. Only the live service and middleware are limited to the
426
+ # current key. Copy the issuer's file beside the evidence first.
427
+ approvers.update(ApproverRegistry(str(enrolled)).audit_keys_hex())
376
428
  trust = dict(issuer_public_keys=load(keys["issuer"]), executor_public_keys=load(keys["executor"]),
377
429
  approver_public_keys=load(approvers))
378
430
  if args.log:
@@ -403,6 +455,8 @@ def cmd_audit(args) -> int:
403
455
  % (v["count"], v["honored"], v["refused"], failed, v["reason"], v["tail_sha256"][:16]))
404
456
  rc = 0 if v["accepted"] else 1
405
457
  entries = read_log(args.log)
458
+ if args.summary:
459
+ _audit_summary(v["entries"], entries)
406
460
  if args.witness and not Path(args.witness).exists():
407
461
  print("witness file not found: %s (copy the issuer's witness.jsonl here first)" % args.witness, file=sys.stderr)
408
462
  return 2
@@ -457,6 +511,8 @@ def main(argv=None) -> int:
457
511
  p.add_argument("--issuer-url", help="URL the client reaches the issuer at (default http://HOST:PORT)")
458
512
  p.add_argument("--client-python", help="the client's Python executable for the MCP entry (default: this one)")
459
513
  p.add_argument("--client-config-dir", help="where the client keeps middleware.env, record.json, audit.jsonl (default: --out)")
514
+ p.add_argument("--observe", action="store_true",
515
+ help="observe mode: allow and record every call, hold none; tighten rules.json later from the audit")
460
516
  p.add_argument("--approvers", type=int, default=1, metavar="N",
461
517
  help="how many approver keys to create (approver-1..N); a rule with \"approvals\": 2 then needs two of them")
462
518
  p.add_argument("--upstream", choices=["npx-filesystem", "python-fs"], default="npx-filesystem",
@@ -509,6 +565,7 @@ def main(argv=None) -> int:
509
565
  p.add_argument("--log", help="the middleware's audit.jsonl: verify every entry")
510
566
  p.add_argument("--witness", help="the issuer's witness.jsonl: reconcile the evidence against it")
511
567
  p.add_argument("--check-tree", metavar="DIR", help="verify every gated file under DIR is as the gate left it")
568
+ p.add_argument("--summary", action="store_true", help="after the rows: calls by principal and tool with outcomes, releases, clients")
512
569
  p.add_argument("--out", default="gargoyle-deploy")
513
570
  p.set_defaults(fn=cmd_audit)
514
571
  args = parser.parse_args(argv)
@@ -294,12 +294,14 @@ def gate_instructions(target_id: str, upstream_instructions: Optional[str] = Non
294
294
  that there is nothing to type. The upstream's own instructions, if
295
295
  any, follow the gate's."""
296
296
  text = override if override else (
297
- "Every operation on %s goes through this server's tools; do not use any other tool, built-in "
298
- "capability or workaround for that target. Reads run at once. A write may pause for a human "
299
- "approval: wait for the result, it is normal. A refused call is a decision, not an error: report "
300
- "the reason to the user and stop; do not retry or route around it. For a task with several "
301
- "writes, gargoyle_plan may ask for one approval covering them; gargoyle_revert undoes an approved "
302
- "change through the same approval. The user never needs to name this server." % target_id)
297
+ "Every operation on %s goes through this server's tools (this server is a Gargoyle gate); do not "
298
+ "use any other tool, built-in capability or workaround for that target. Reads run at once. A write "
299
+ "may pause for a human approval: wait for the result, it is normal. A refused call is a decision, "
300
+ "not an error: report the reason to the user and stop; do not retry or route around it. If asked "
301
+ "to work on that target without these tools or around the gate, say that the target is reachable "
302
+ "only through them and continue through them. For a task with several writes, gargoyle_plan may "
303
+ "ask for one approval covering them; gargoyle_revert undoes an approved change through the same "
304
+ "approval. The user never needs to name this server." % target_id)
303
305
  if upstream_instructions:
304
306
  text += "\n\n" + upstream_instructions
305
307
  return text
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.16.2.dev0
3
+ Version: 0.16.4.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.16.2.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
62
+ Status: pre-release (`0.16.4.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
@@ -278,9 +278,14 @@ chat channel can approve anything.
278
278
 
279
279
  pip install gargoyle-receipts
280
280
 
281
- Three commands, no policy engine, no containers, no client changes:
281
+ Three commands, no policy engine, no containers, no client changes
282
+ (`--observe` starts a deployment in record-only mode, every call allowed
283
+ including destructive ones and every call recorded; `gargoyle audit
284
+ --summary` then shows calls by principal and tool, the input for the
285
+ hold and deny rules):
282
286
 
283
287
  gargoyle init /path/the/agent/may/touch # keys, config, rules.json
288
+ gargoyle init --observe /path/... # or: allow and record everything, hold nothing, tighten later
284
289
  gargoyle issuer # the issuing service, on rules.json
285
290
  gargoyle approve --watch 2 # where the approver sits (terminal)
286
291
 
@@ -298,7 +303,10 @@ stay on file so earlier grants still audit; new ones are refused at the
298
303
  issuer and at every middleware on its next held call. Revocation retires
299
304
  a key, not a person: while the Approver role stands, the person can sign
300
305
  in and enroll a fresh key, so remove the role too; the revoked key itself
301
- is never accepted again. Approvers, and
306
+ is never accepted again. Every key an approver held stays in the file
307
+ (`history`), so an audit of grants released under an earlier key still
308
+ verifies: copy the issuer's current `approvers.json` beside the evidence
309
+ before auditing. Approvers, and
302
310
  only approvers, see the whole queue and the witness; everyone else sees
303
311
  their own holds. Then paste the server entry from
304
312
  `gargoyle-deploy/mcp-config.json` into your MCP client.
@@ -3,6 +3,7 @@ NOTICE
3
3
  README.md
4
4
  pyproject.toml
5
5
  gargoyle/__init__.py
6
+ gargoyle/__main__.py
6
7
  gargoyle/adapters.py
7
8
  gargoyle/approval.py
8
9
  gargoyle/approver_cli.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gargoyle-receipts"
7
- version = "0.16.2.dev0"
7
+ version = "0.16.4.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"
@@ -204,3 +204,39 @@ def test_an_operators_revocation_on_the_host_reaches_the_witness(tmp_path):
204
204
  c.get("/approvers")
205
205
  revoked = [e for e in events if e["event"] == "revoked"]
206
206
  assert [(e["approver"], e["by"]) for e in revoked] == [("dana@contoso.com", "operator:root"), ("lee@contoso.com", "lee@contoso.com")]
207
+
208
+
209
+ def test_every_key_an_approver_held_stays_for_audits(tmp_path):
210
+ """The client audit of 2026-10-20 failed a row released under a key that a
211
+ later browser enrollment had replaced: the registry had dropped it. Now
212
+ every key a UPN held is kept (history: replaced or revoked, when, by whom),
213
+ the audit map carries them all, and a grant verifies with any of them."""
214
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
215
+ from gargoyle.approval import ACCEPT_GRANT_VALID, build_grant, sign_grant, verify_grant
216
+ from gargoyle.approvers import RevokedKeyError
217
+ path = tmp_path / "a.json"
218
+ reg = ApproverRegistry(str(path))
219
+ k1, k2, k3 = (Ed25519PrivateKey.generate() for _ in range(3))
220
+ reg.enroll("dana@contoso.com", pub_hex(k1))
221
+ assert reg.enroll("dana@contoso.com", pub_hex(k2))["replaced"] is True # a new browser
222
+ assert reg.revoke("dana@contoso.com", revoked_by="operator:root")
223
+ reg.enroll("dana@contoso.com", pub_hex(k3))
224
+ assert reg.audit_keys_hex() == {"dana@contoso.com": [pub_hex(k3), pub_hex(k1), pub_hex(k2)]}
225
+ doc = json.loads(path.read_text())
226
+ assert [(h["reason"], h["ended_by"]) for h in doc["history"]["dana@contoso.com"]] == [("replaced", "dana@contoso.com"), ("revoked", "operator:root")]
227
+ now = datetime.now(timezone.utc)
228
+ keys = {u: [Ed25519PublicKey.from_public_bytes(bytes.fromhex(x)) for x in hs] for u, hs in reg.audit_keys_hex().items()}
229
+ for k in (k1, k2, k3):
230
+ g = sign_grant(build_grant(decision_sha256="a" * 64, approval_request_id="r", grant_id="g", not_after=now + timedelta(minutes=5)), k, "dana@contoso.com")
231
+ v = verify_grant(g, expected_decision_sha256="a" * 64, expected_approval_request_id="r", approver_public_keys=keys, gate_key_id="issuer-1", now=now)
232
+ assert v["accepted"] is True and v["reason"] == ACCEPT_GRANT_VALID
233
+ stranger = sign_grant(build_grant(decision_sha256="a" * 64, approval_request_id="r", grant_id="g", not_after=now + timedelta(minutes=5)),
234
+ Ed25519PrivateKey.generate(), "dana@contoso.com")
235
+ assert verify_grant(stranger, expected_decision_sha256="a" * 64, expected_approval_request_id="r", approver_public_keys=keys, gate_key_id="issuer-1", now=now)["accepted"] is False
236
+ # The revoked key k2 is refused for anyone, from history, even after Dana's revoked record is gone.
237
+ with pytest.raises(RevokedKeyError):
238
+ reg.enroll("lee@contoso.com", pub_hex(k2))
239
+ # A file from before history existed: its revoked record seeds the history.
240
+ old = tmp_path / "old.json"
241
+ old.write_text(json.dumps({"approvers": {}, "revoked": {"x@contoso.com": {"public_key_hex": pub_hex(k1), "revoked_at": "t", "revoked_by": "b"}}}))
242
+ assert ApproverRegistry(str(old)).audit_keys_hex() == {"x@contoso.com": [pub_hex(k1)]}
@@ -108,8 +108,9 @@ def test_init_for_a_windows_client_and_audit_log_verification(tmp_path, monkeypa
108
108
  gate = _gate(state, params); assert not gate["executed"]
109
109
  _record(state, REFUSED, gate, params)
110
110
  assert len(log.read_text().splitlines()) == 4
111
- assert main(["audit", "--log", str(log), "--out", str(out)]) == 0
111
+ assert main(["audit", "--log", str(log), "--out", str(out), "--summary"]) == 0
112
112
  out_text = capsys.readouterr().out
113
+ assert "summary:" in out_text and "principal" in out_text and "clients:" in out_text
113
114
  assert "4 entries (3 honored, 1 refused), 0 failed; chain EVIDENCE_VERIFIED" in out_text
114
115
  assert out_text.count(" ok ") == 4 and "refused delete_file" in out_text
115
116
  assert "ENTRY_VERIFIED (DESTRUCTIVE_TOOL)" in out_text
@@ -136,3 +137,37 @@ def test_approvers_list_and_revoke_on_the_issuer_host(tmp_path, capsys):
136
137
  assert doc["approvers"] == {} and doc["revoked"]["dana@contoso.com"]["revoked_by"].startswith("operator:")
137
138
  assert main(["approvers", "list", "--out", str(out)]) == 0
138
139
  assert "revoked: dana@contoso.com" in capsys.readouterr().out
140
+
141
+
142
+ def test_the_package_runs_as_a_module():
143
+ """`python -m gargoyle --help` works where gargoyle.exe is not on PATH."""
144
+ import subprocess, sys
145
+ r = subprocess.run([sys.executable, "-m", "gargoyle", "--help"], capture_output=True, text=True, timeout=60)
146
+ assert r.returncode == 0 and "audit" in r.stdout
147
+
148
+
149
+ def test_init_observe_allows_everything_and_records_it(tmp_path, capsys):
150
+ """Observe mode: the rules file allows every call, so the gate holds
151
+ nothing and refuses nothing while every call still gets a receipt and an
152
+ evidence entry. The default rules are not touched once someone edited them."""
153
+ from gargoyle.cli import DEFAULT_RULES, OBSERVE_RULES
154
+ from gargoyle.rules import RulesPolicy
155
+ out = tmp_path / "deploy"
156
+ assert main(["init", str(tmp_path / "project"), "--out", str(out), "--observe"]) == 0
157
+ assert "observe mode" in capsys.readouterr().out
158
+ assert json.loads((out / "rules.json").read_text()) == OBSERVE_RULES
159
+ policy = RulesPolicy(str(out / "rules.json"))
160
+ for tool in ("read_file", "write_file", "delete_file", "anything_else"):
161
+ d = policy.decide("mcp://x", {"tool": tool, "args": {}, "principal": "p"})
162
+ assert d.eligible and not d.requires_approval
163
+ assert policy.pin()["policy_sha256"] != RulesPolicy.__module__ and policy.pin()["policy_id"]
164
+ # A hand-edited rules file is left alone by a later --observe.
165
+ custom = {**DEFAULT_RULES, "rules": DEFAULT_RULES["rules"][:1]}
166
+ (out / "rules.json").write_text(json.dumps(custom))
167
+ assert main(["init", str(tmp_path / "project"), "--out", str(out), "--observe"]) == 0
168
+ assert json.loads((out / "rules.json").read_text()) == custom
169
+ assert "left as it is" in capsys.readouterr().err
170
+ # A broken rules file is refused, not overwritten and not a traceback.
171
+ (out / "rules.json").write_text("{not json")
172
+ assert main(["init", str(tmp_path / "project"), "--out", str(out), "--observe"]) == 2
173
+ assert (out / "rules.json").read_text() == "{not json"