gargoyle-receipts 0.20.0.dev0__tar.gz → 0.21.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 (111) hide show
  1. {gargoyle_receipts-0.20.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.21.0.dev0}/PKG-INFO +12 -4
  2. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/README.md +11 -3
  3. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/claude_hook.py +30 -15
  4. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/cli.py +68 -5
  5. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/evidence.py +39 -28
  6. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/issuer_service.py +8 -0
  7. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/mcp_server.py +13 -8
  8. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/reconcile.py +20 -2
  9. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +12 -4
  10. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/pyproject.toml +1 -1
  11. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_approvers.py +18 -0
  12. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_claude_hook.py +58 -0
  13. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_revert.py +9 -0
  14. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_review_fixes.py +1 -1
  15. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_witness.py +50 -0
  16. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/LICENSE +0 -0
  17. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/NOTICE +0 -0
  18. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/__init__.py +0 -0
  19. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/__main__.py +0 -0
  20. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/adapters.py +0 -0
  21. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/approval.py +0 -0
  22. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/approver_cli.py +0 -0
  23. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/approver_trust.py +0 -0
  24. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/approvers.py +0 -0
  25. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/consumer.py +0 -0
  26. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/doctor.py +0 -0
  27. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/emilia.py +0 -0
  28. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/emilia_runner.py +0 -0
  29. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/entra_login.py +0 -0
  30. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/envelope.py +0 -0
  31. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/execution.py +0 -0
  32. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/fs_server.py +0 -0
  33. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/globs.py +0 -0
  34. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/holdstore.py +0 -0
  35. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/holdtext.py +0 -0
  36. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/identity.py +0 -0
  37. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/issuer.py +0 -0
  38. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/jws.py +0 -0
  39. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/key_record_source.py +0 -0
  40. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/notify.py +0 -0
  41. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/oauth_broker.py +0 -0
  42. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/opa.py +0 -0
  43. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/operations.py +0 -0
  44. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/pending_store.py +0 -0
  45. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/plans.py +0 -0
  46. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/policy.py +0 -0
  47. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/published_record_source.py +0 -0
  48. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/remote.py +0 -0
  49. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/replay_cache.py +0 -0
  50. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/revert.py +0 -0
  51. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/rulecheck.py +0 -0
  52. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/rules.py +0 -0
  53. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/sidedoors.py +0 -0
  54. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
  55. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/state.py +0 -0
  56. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/static/approve.html +0 -0
  57. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/static/approve.js +0 -0
  58. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/verifier.py +0 -0
  59. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/witness.py +0 -0
  60. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +0 -0
  61. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  62. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  63. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  64. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  65. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/setup.cfg +0 -0
  66. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_annotations.py +0 -0
  67. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_approval_views.py +0 -0
  68. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_approver_cli.py +0 -0
  69. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_canonical.py +0 -0
  70. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_cli.py +0 -0
  71. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_console_js.py +0 -0
  72. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_consumer.py +0 -0
  73. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_declines.py +0 -0
  74. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_doctor.py +0 -0
  75. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_emilia_interop.py +0 -0
  76. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_emilia_runner.py +0 -0
  77. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_entra_identity.py +0 -0
  78. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_entra_login.py +0 -0
  79. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_env_file.py +0 -0
  80. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_evidence.py +0 -0
  81. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_execution.py +0 -0
  82. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_fs_server.py +0 -0
  83. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_globs.py +0 -0
  84. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_graph.py +0 -0
  85. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_hardening_2026_11_01.py +0 -0
  86. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_historical.py +0 -0
  87. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_identity.py +0 -0
  88. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_issuer_service.py +0 -0
  89. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_jws.py +0 -0
  90. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_mcp_proxy.py +0 -0
  91. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_mcp_self_issue.py +0 -0
  92. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_mcp_server.py +0 -0
  93. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_notify.py +0 -0
  94. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_oauth_broker.py +0 -0
  95. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_opa.py +0 -0
  96. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_operability.py +0 -0
  97. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_operations.py +0 -0
  98. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_plans.py +0 -0
  99. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_policy.py +0 -0
  100. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_published_record.py +0 -0
  101. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_record_follow.py +0 -0
  102. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_remote.py +0 -0
  103. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_rulecheck.py +0 -0
  104. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_rules.py +0 -0
  105. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_sidedoors.py +0 -0
  106. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_state.py +0 -0
  107. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_state_text.py +0 -0
  108. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_stdio_encoding.py +0 -0
  109. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_threshold.py +0 -0
  110. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_typescript_conformance.py +0 -0
  111. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.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.20.0.dev0
3
+ Version: 0.21.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
@@ -64,7 +64,7 @@ by whom, and what changed.
64
64
 
65
65
  ## Status
66
66
 
67
- Pre-release `0.20.0.dev0` on PyPI as `gargoyle-receipts`. Wire format
67
+ Pre-release `0.21.0.dev0` on PyPI as `gargoyle-receipts`. Wire format
68
68
  `gargoyle/3`, evidence log `gargoyle-evidence/4`, witness
69
69
  `gargoyle-witness/1`; all three are specified in [SPEC.md](SPEC.md) and may
70
70
  still change before a first release.
@@ -91,7 +91,7 @@ against a fake Graph server); the OAuth broker for hosted connectors
91
91
  (deployed, metadata and redirect checks verified, no human sign-in through a
92
92
  connector yet).
93
93
 
94
- **Assurance.** 561 Python tests on Linux and Windows across Python 3.12,
94
+ **Assurance.** 564 Python tests on Linux and Windows across Python 3.12,
95
95
  3.13 and 3.14, at dependency floors and at a lock file; a TypeScript suite
96
96
  that recomputes the conformance vectors byte for byte; two outside,
97
97
  model-driven security reviews on 2026-10-20 (ten findings, all fixed); the
@@ -378,6 +378,13 @@ enrollment, revocation and discovery report, on the operator's server.
378
378
  gargoyle audit --log audit.jsonl --witness witness.jsonl # reconcile by decision id: unwitnessed receipts, releases without a grant, missing evidence
379
379
  gargoyle audit --log audit.jsonl --check-tree /srv/shared # hash the gated folder against the recorded states; name files changed around the gate
380
380
  gargoyle audit --log audit.jsonl --summary # calls by principal and tool, side-door findings
381
+ gargoyle audit --log audit.jsonl --issuer https://gargoyle.lan # take the issuer's keys and every approver key it has seen from the issuer, not from a copied file
382
+
383
+ The audit needs the public keys (`public-keys.json`, by default beside the
384
+ log or in `--out`). Grants verify against every key an approver has held,
385
+ so an approver who re-enrolled a browser key is still audited; with
386
+ `--issuer` that list comes from the issuer itself and a stale copy of
387
+ `approvers.json` can no longer fail a valid grant.
381
388
 
382
389
  On a machine the user administers the gate constrains the assistant, not
383
390
  the user; these checks make the difference visible. SPEC.md section 15
@@ -661,7 +668,8 @@ receipts work unchanged. Configuration is by environment:
661
668
  **Issuing service** (`gargoyle.issuer_service`, run with uvicorn from
662
669
  `app_from_env()`, or `gargoyle issuer`): identity token in, receipt out,
663
670
  with the hold and grant exchange for flagged calls (`/issue`, `/pending`,
664
- `/grants`, `/decline`, `/record`, `/keys`, `/sidedoors`) and the browser
671
+ `/grants`, `/decline`, `/record`, `/keys`, `/approvers`, `/approvers/audit`,
672
+ `/sidedoors`) and the browser
665
673
  approval console at `/approve`. Policy from a rules file or OPA; identity
666
674
  from a pinned key or, with `GARGOYLE_ENTRA_TENANT`, the tenant's signing
667
675
  keys (both at once when a pinned key is also set).
@@ -26,7 +26,7 @@ by whom, and what changed.
26
26
 
27
27
  ## Status
28
28
 
29
- Pre-release `0.20.0.dev0` on PyPI as `gargoyle-receipts`. Wire format
29
+ Pre-release `0.21.0.dev0` on PyPI as `gargoyle-receipts`. Wire format
30
30
  `gargoyle/3`, evidence log `gargoyle-evidence/4`, witness
31
31
  `gargoyle-witness/1`; all three are specified in [SPEC.md](SPEC.md) and may
32
32
  still change before a first release.
@@ -53,7 +53,7 @@ against a fake Graph server); the OAuth broker for hosted connectors
53
53
  (deployed, metadata and redirect checks verified, no human sign-in through a
54
54
  connector yet).
55
55
 
56
- **Assurance.** 561 Python tests on Linux and Windows across Python 3.12,
56
+ **Assurance.** 564 Python tests on Linux and Windows across Python 3.12,
57
57
  3.13 and 3.14, at dependency floors and at a lock file; a TypeScript suite
58
58
  that recomputes the conformance vectors byte for byte; two outside,
59
59
  model-driven security reviews on 2026-10-20 (ten findings, all fixed); the
@@ -340,6 +340,13 @@ enrollment, revocation and discovery report, on the operator's server.
340
340
  gargoyle audit --log audit.jsonl --witness witness.jsonl # reconcile by decision id: unwitnessed receipts, releases without a grant, missing evidence
341
341
  gargoyle audit --log audit.jsonl --check-tree /srv/shared # hash the gated folder against the recorded states; name files changed around the gate
342
342
  gargoyle audit --log audit.jsonl --summary # calls by principal and tool, side-door findings
343
+ gargoyle audit --log audit.jsonl --issuer https://gargoyle.lan # take the issuer's keys and every approver key it has seen from the issuer, not from a copied file
344
+
345
+ The audit needs the public keys (`public-keys.json`, by default beside the
346
+ log or in `--out`). Grants verify against every key an approver has held,
347
+ so an approver who re-enrolled a browser key is still audited; with
348
+ `--issuer` that list comes from the issuer itself and a stale copy of
349
+ `approvers.json` can no longer fail a valid grant.
343
350
 
344
351
  On a machine the user administers the gate constrains the assistant, not
345
352
  the user; these checks make the difference visible. SPEC.md section 15
@@ -623,7 +630,8 @@ receipts work unchanged. Configuration is by environment:
623
630
  **Issuing service** (`gargoyle.issuer_service`, run with uvicorn from
624
631
  `app_from_env()`, or `gargoyle issuer`): identity token in, receipt out,
625
632
  with the hold and grant exchange for flagged calls (`/issue`, `/pending`,
626
- `/grants`, `/decline`, `/record`, `/keys`, `/sidedoors`) and the browser
633
+ `/grants`, `/decline`, `/record`, `/keys`, `/approvers`, `/approvers/audit`,
634
+ `/sidedoors`) and the browser
627
635
  approval console at `/approve`. Policy from a rules file or OPA; identity
628
636
  from a pinned key or, with `GARGOYLE_ENTRA_TENANT`, the tenant's signing
629
637
  keys (both at once when a pinned key is also set).
@@ -116,6 +116,14 @@ class PendingReceipts:
116
116
  def __init__(self, path: str) -> None:
117
117
  self.path = Path(path)
118
118
 
119
+ def _locked(self):
120
+ """Pre- and PostToolUse hooks of parallel tool calls run as separate
121
+ processes at once; each read-modify-write holds the lock, or one
122
+ loses the other's entry (a call that ran with no evidence row, or an
123
+ honored one resurrected and reaped as never run)."""
124
+ from gargoyle.evidence import locked
125
+ return locked(self.path.with_name(self.path.name + ".lock"))
126
+
119
127
  def _read(self) -> Dict[str, Any]:
120
128
  try:
121
129
  doc = json.loads(self.path.read_text(encoding="utf-8"))
@@ -129,26 +137,29 @@ class PendingReceipts:
129
137
  os.replace(tmp, self.path)
130
138
 
131
139
  def put(self, tool_use_id: str, gate: Dict[str, Any], params: Dict[str, Any], now: float) -> None:
132
- doc = self._read()
133
- doc[tool_use_id] = {"envelope": gate["envelope"], "grant": gate["grant"], "self_issued": gate["self_issued"],
134
- "reason": gate["reason"], "ref": gate.get("ref"), "params": params, "at": now}
135
- for old in list(doc)[:-PENDING_LIMIT]:
136
- doc.pop(old, None)
137
- self._write(doc)
140
+ with self._locked():
141
+ doc = self._read()
142
+ doc[tool_use_id] = {"envelope": gate["envelope"], "grant": gate["grant"], "self_issued": gate["self_issued"],
143
+ "reason": gate["reason"], "ref": gate.get("ref"), "params": params, "at": now}
144
+ for old in list(doc)[:-PENDING_LIMIT]:
145
+ doc.pop(old, None)
146
+ self._write(doc)
138
147
 
139
148
  def pop(self, tool_use_id: str) -> Optional[Dict[str, Any]]:
140
- doc = self._read()
141
- gate = doc.pop(tool_use_id, None)
142
- if gate is not None:
143
- self._write(doc)
149
+ with self._locked():
150
+ doc = self._read()
151
+ gate = doc.pop(tool_use_id, None)
152
+ if gate is not None:
153
+ self._write(doc)
144
154
  return gate
145
155
 
146
156
  def reap(self, now: float, max_age: float) -> List[Dict[str, Any]]:
147
- doc = self._read()
148
- stale = [k for k, v in doc.items() if not isinstance(v.get("at"), (int, float)) or now - v["at"] > max_age]
149
- out = [doc.pop(k) for k in stale]
150
- if out:
151
- self._write(doc)
157
+ with self._locked():
158
+ doc = self._read()
159
+ stale = [k for k, v in doc.items() if not isinstance(v.get("at"), (int, float)) or now - v["at"] > max_age]
160
+ out = [doc.pop(k) for k in stale]
161
+ if out:
162
+ self._write(doc)
152
163
  return out
153
164
 
154
165
 
@@ -256,6 +267,10 @@ def decide(event: Dict[str, Any], state: Any, *, final: bool = False, now: Optio
256
267
  gate = pending.pop(tool_use_id) if (pending is not None and tool_use_id) else None
257
268
  if gate is None:
258
269
  return {}
270
+ # The receipt bound the arguments PreToolUse saw; Claude Code may
271
+ # present some tools' input differently afterwards, so the entry is
272
+ # written over what was gated, not what PostToolUse carries.
273
+ params = gate["params"]
259
274
  outputs = event.get("tool_response") if kind == POST else {"error": event.get("error")}
260
275
  execution = _attest(state, gate["envelope"], outputs, params)
261
276
  _record(state, HONORED, gate, params, execution)
@@ -635,9 +635,11 @@ def _audit_summary(report, raw) -> None:
635
635
  releases[who] += 1
636
636
  clients[r.get("client") or "(client unnamed)"] += 1
637
637
  print("summary: %d call(s)%s" % (len(report), (", %s to %s" % (min(ats)[:19], max(ats)[:19])) if ats else ""))
638
- print(" %-28s %-24s %7s %7s %7s" % ("principal", "tool", "honored", "failed", "refused"))
638
+ print(" %-28s %-24s %7s %7s %7s" % ("principal", "tool", "honored", "errored", "refused"))
639
639
  for (principal, tool), c in sorted(counts.items(), key=lambda kv: (kv[0][0], -sum(kv[1].values()), kv[0][1])):
640
640
  print(" %-28s %-24s %7d %7d %7d" % (principal[:28], tool[:24], c.get("honored", 0), c.get("failed", 0), c.get("refused", 0)))
641
+ if any(c.get("failed") for c in counts.values()):
642
+ print(" errored: admitted by the gate, then the upstream reported an error (a missing file, a bad argument); not a verification failure")
641
643
  if releases:
642
644
  print(" releases: " + ", ".join("%s %d" % (w, n) for w, n in releases.most_common()))
643
645
  print(" clients: " + ", ".join("%s %d" % (w, n) for w, n in clients.most_common()))
@@ -786,11 +788,54 @@ def cmd_restore(args) -> int:
786
788
  return 0 if (r.get("restored") or (args.verify_only and not r.get("refused"))) else 1
787
789
 
788
790
 
791
+ def _issuer_trust(url: str) -> "tuple[Dict[str, str], Dict[str, list]]":
792
+ """The issuer's current signing keys (GET /keys) and every key each
793
+ enrolled approver has held (GET /approvers/audit), so an audit on a
794
+ workstation needs no copy of approvers.json that can go stale. Both are
795
+ public endpoints. REQUESTS_CA_BUNDLE names the issuer's CA as usual."""
796
+ import requests
797
+ base = url.rstrip("/")
798
+ keys = requests.get(base + "/keys", timeout=10); keys.raise_for_status()
799
+ appr = requests.get(base + "/approvers/audit", timeout=10); appr.raise_for_status()
800
+ return dict(keys.json()), dict(appr.json().get("approvers", {}))
801
+
802
+
803
+ def _audit_trust_dir(args) -> "Path | None":
804
+ """Where public-keys.json is: --out, else the evidence log's own folder
805
+ (on a workstation the keys sit beside audit.jsonl). None when neither."""
806
+ out = Path(args.out).resolve()
807
+ if (out / "public-keys.json").exists():
808
+ return out
809
+ if getattr(args, "log", None):
810
+ beside = Path(args.log).resolve().parent
811
+ if (beside / "public-keys.json").exists():
812
+ return beside
813
+ return None
814
+
815
+
789
816
  def cmd_audit(args) -> int:
790
817
  from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
791
818
  from gargoyle.execution import verify_chain
792
819
 
793
- keys, approvers = _trust_hex(Path(args.out).resolve())
820
+ out = _audit_trust_dir(args)
821
+ if out is None:
822
+ print("public-keys.json not found in %s%s: pass --out DIR, the folder that holds public-keys.json (and approvers.json), "
823
+ "on a workstation the one with middleware.env" % (Path(args.out).resolve(),
824
+ (" or beside %s" % Path(args.log).resolve().parent) if getattr(args, "log", None) else ""), file=sys.stderr)
825
+ return 2
826
+ keys, approvers = _trust_hex(out)
827
+ if getattr(args, "issuer", None):
828
+ try:
829
+ issuer_keys, issuer_approvers = _issuer_trust(args.issuer)
830
+ except Exception as exc:
831
+ print("issuer not reachable for keys: %s (%s)" % (args.issuer, exc), file=sys.stderr)
832
+ return 2
833
+ keys["issuer"] = dict(keys.get("issuer", {}), **issuer_keys)
834
+ # The issuer's list is current and complete; a local copy may be older.
835
+ for upn, hexes in issuer_approvers.items():
836
+ have = approvers.get(upn)
837
+ have = have if isinstance(have, list) else ([have] if have else [])
838
+ approvers[upn] = list(hexes) + [h for h in have if h not in hexes]
794
839
  one = lambda h: Ed25519PublicKey.from_public_bytes(bytes.fromhex(h))
795
840
  load = lambda m: {k: ([one(x) for x in v] if isinstance(v, list) else one(v)) for k, v in m.items()}
796
841
  trust = dict(issuer_public_keys=load(keys["issuer"]), executor_public_keys=load(keys["executor"]),
@@ -821,6 +866,17 @@ def cmd_audit(args) -> int:
821
866
  failed = sum(1 for r in v["entries"] if not r["accepted"])
822
867
  print("%d entries (%d honored, %d refused), %d failed; chain %s; tail %s"
823
868
  % (v["count"], v["honored"], v["refused"], failed, v["reason"], v["tail_sha256"][:16]))
869
+ from gargoyle.approval import REF_APPROVAL_KEY_UNKNOWN, REF_APPROVAL_SIGNATURE_INVALID
870
+ bad_sigs = [r for r in v["entries"] if not r["accepted"] and r["reason"] in (REF_APPROVAL_SIGNATURE_INVALID, REF_APPROVAL_KEY_UNKNOWN)]
871
+ if bad_sigs:
872
+ ids = sorted({k for r in bad_sigs for k in (r.get("released_by") or [])})
873
+ unknown = [k for k in ids if k not in approvers]
874
+ stale = [k for k in ids if k in approvers]
875
+ print(" note: %d release(s) failed the grant check%s%s. The approver keys here come from %s; an approver who "
876
+ "re-enrolled a browser key since that copy was made signs with a key it does not list. Copy the issuer's "
877
+ "current approvers.json beside public-keys.json, or pass --issuer URL to fetch every key the issuer has seen."
878
+ % (len(bad_sigs), (": approver id(s) not known here: %s" % ", ".join(unknown)) if unknown else "",
879
+ ("; known approver(s) whose listed key does not verify the grant: %s" % ", ".join(stale)) if stale else "", out))
824
880
  rc = 0 if v["accepted"] else 1
825
881
  entries = read_log(args.log)
826
882
  if args.summary:
@@ -851,14 +907,20 @@ def cmd_audit(args) -> int:
851
907
  if isinstance(e, dict) and isinstance(e.get("receipt"), dict) and e["receipt"].get("target_url")), None)
852
908
  r = reconcile(entries, read_witness(args.witness), principal=principal, target_url=target)
853
909
  older = [f for f in r["findings"] if f["finding"] == "PREDATES_WITNESS"]
910
+ before_log = [f for f in r["findings"] if f["finding"] == "PREDATES_EVIDENCE"]
854
911
  for f in r["findings"]:
855
- if f["finding"] == "PREDATES_WITNESS":
912
+ if f["finding"] in ("PREDATES_WITNESS", "PREDATES_EVIDENCE"):
856
913
  continue
857
914
  print(" FINDING %-22s %s" % (f["finding"], " ".join("%s=%s" % (k, str(v)[:40]) for k, v in f.items() if k != "finding")))
858
915
  if older:
859
916
  print(" note: %d honored row(s) predate the witness (started %s) and cannot be vouched for either way: seq %s"
860
917
  % (len(older), (r.get("horizon") or "?")[:19], ", ".join(str(f["seq"]) for f in older[:12]) + (" ..." if len(older) > 12 else "")))
861
- hard = len(r["findings"]) - len(older)
918
+ if before_log:
919
+ held_n = sum(1 for f in before_log if f.get("held"))
920
+ print(" note: %d receipt(s) the issuer issued to this principal predate this evidence log (first entry %s; %d of them held): "
921
+ "a log that began later cannot be asked about them. If the log was not meant to start then, that is the finding."
922
+ % (len(before_log), (r.get("log_start") or "?")[:19], held_n))
923
+ hard = len(r["findings"]) - len(older) - len(before_log)
862
924
  print("reconciled: %d issued by the issuer, %d in evidence, %d finding(s)%s"
863
925
  % (r["issued"], r["evidence_with_receipt"], hard,
864
926
  " (%d issued for other targets, not this gate's)" % r["issued_elsewhere"] if r.get("issued_elsewhere") else ""))
@@ -1021,7 +1083,8 @@ def main(argv=None) -> int:
1021
1083
  p.add_argument("--witness", help="the issuer's witness.jsonl: reconcile the evidence against it")
1022
1084
  p.add_argument("--check-tree", metavar="DIR", help="verify every gated file under DIR is as the gate left it")
1023
1085
  p.add_argument("--summary", action="store_true", help="after the rows: calls by principal and tool with outcomes, releases, clients")
1024
- p.add_argument("--out", default="gargoyle-deploy")
1086
+ p.add_argument("--out", default="gargoyle-deploy", help="folder with public-keys.json and approvers.json (default: gargoyle-deploy, else the log's own folder)")
1087
+ p.add_argument("--issuer", metavar="URL", help="fetch the issuer's keys and every approver key it has seen (GET /keys, /approvers/audit) instead of relying on a copied approvers.json")
1025
1088
  p.set_defaults(fn=cmd_audit)
1026
1089
  p = sub.add_parser("emilia", help="export an evidence log's approved calls as EMILIA Trust Receipts")
1027
1090
  p.add_argument("--log", required=True, help="the middleware's audit.jsonl")
@@ -127,6 +127,37 @@ def build_entry(*, seq: int, prev_sha256: str, kind: str, tool: str, args: Any,
127
127
  return entry
128
128
 
129
129
 
130
+ def locked(lock_path: Any):
131
+ """An exclusive, blocking lock on `lock_path` (POSIX flock, Windows
132
+ byte-range lock), released on exit. Processes that share a file on one
133
+ workstation, the hook's PreToolUse and PostToolUse among them, hold it
134
+ across their read-modify-write so neither loses the other's update."""
135
+ import contextlib
136
+
137
+ @contextlib.contextmanager
138
+ def hold():
139
+ with open(lock_path, "a+b") as lock:
140
+ try:
141
+ import fcntl
142
+ fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
143
+ unlock = lambda: fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
144
+ except ImportError: # Windows
145
+ import msvcrt
146
+ lock.seek(0)
147
+ while True:
148
+ try:
149
+ msvcrt.locking(lock.fileno(), msvcrt.LK_LOCK, 1) # retries about ten seconds, then raises
150
+ break
151
+ except OSError:
152
+ continue
153
+ unlock = lambda: (lock.seek(0), msvcrt.locking(lock.fileno(), msvcrt.LK_UNLCK, 1))
154
+ try:
155
+ yield
156
+ finally:
157
+ unlock()
158
+ return hold()
159
+
160
+
130
161
  class EvidenceLog:
131
162
  """Appends entries to a JSON Lines file, continuing the chain found at
132
163
  its tail. Several gate processes may share one log (a Claude Code and
@@ -156,33 +187,8 @@ class EvidenceLog:
156
187
  self._seq, self._tail = int(last["seq"]), str(last["entry_sha256"])
157
188
 
158
189
  def _locked(self):
159
- """An exclusive, blocking lock on the log's lock file (POSIX flock,
160
- Windows byte-range lock), released on exit."""
161
- import contextlib
162
-
163
- @contextlib.contextmanager
164
- def hold():
165
- lock_path = self.path.with_name(self.path.name + ".lock")
166
- with open(lock_path, "a+b") as lock:
167
- try:
168
- import fcntl
169
- fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
170
- unlock = lambda: fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
171
- except ImportError: # Windows
172
- import msvcrt
173
- lock.seek(0)
174
- while True:
175
- try:
176
- msvcrt.locking(lock.fileno(), msvcrt.LK_LOCK, 1) # retries about ten seconds, then raises
177
- break
178
- except OSError:
179
- continue
180
- unlock = lambda: (lock.seek(0), msvcrt.locking(lock.fileno(), msvcrt.LK_UNLCK, 1))
181
- try:
182
- yield
183
- finally:
184
- unlock()
185
- return hold()
190
+ """An exclusive, blocking lock on the log's lock file, released on exit."""
191
+ return locked(self.path.with_name(self.path.name + ".lock"))
186
192
 
187
193
  def append(self, kind: str, tool: str, args: Any, principal: str, reason: str, *,
188
194
  receipt: Any = None, grant: Any = None, execution: Any = None,
@@ -287,8 +293,13 @@ def verify_log(
287
293
  else:
288
294
  reason = ACCEPT_ENTRY_VERIFIED
289
295
  if e["receipt"] is not None:
296
+ # Provenance only: a refused entry says the receipt was issued and
297
+ # the call did not run. It may be written long after the receipt
298
+ # expired (an honored call nobody came back for is reaped an hour
299
+ # on), so it is checked as of its own issuance, not the entry's.
300
+ issued_at = e["receipt"].get("timestamp_utc") if isinstance(e["receipt"], dict) else None
290
301
  p = verify_provenance(e["receipt"], pinned_public_keys=issuer_public_keys,
291
- now=_parse_at(e["at"]), historical=True)
302
+ now=_parse_at(issued_at if isinstance(issued_at, str) else e["at"]), historical=True)
292
303
  ok, reason = p["accepted"], (ACCEPT_ENTRY_VERIFIED if p["accepted"] else p["reason"])
293
304
  counts[e["kind"]] += 1
294
305
  plan_ref = None
@@ -777,6 +777,14 @@ def build_issuer_app(
777
777
  _notice_revocations()
778
778
  return {"approvers": registry.public_hex()}
779
779
 
780
+ @app.get("/approvers/audit")
781
+ async def approvers_audit():
782
+ """Public, for `gargoyle audit --issuer`: every key each approver has
783
+ held, current first, then replaced and revoked ones. A grant signed
784
+ while a key was trusted still verifies; nothing here admits a key."""
785
+ _notice_revocations()
786
+ return {"approvers": registry.audit_keys_hex()}
787
+
780
788
  @app.post("/approvers/enroll")
781
789
  async def enroll(request: Request, authorization: Optional[str] = Header(default=None)):
782
790
  """Bind the caller's UPN to the public key their browser generated.
@@ -888,16 +888,21 @@ def handle_message(state: ServerState, msg: Dict[str, Any]) -> Optional[Dict[str
888
888
  # then goes through the gate exactly as the assistant's own writes.
889
889
  from gargoyle.revert import REF_REVERT_DISABLED, revert_text
890
890
  from gargoyle.state import REF_TARGET_STATE_UNREADABLE, StateUnreadable
891
- if state.snapshots is None:
892
- return _success(msg_id, {"content": [{"type": "text", "text": revert_text(REF_REVERT_DISABLED)}], "isError": True,
893
- "_meta": {EXECUTED_META_KEY: False, REASON_META_KEY: REF_REVERT_DISABLED}})
894
891
  rargs = params.get("arguments") or {}
895
- try:
896
- plan = plan_revert(state.snapshots, str(rargs.get("decision_id") or "last"), state.adapter,
897
- ref=rargs.get("path") if isinstance(rargs.get("path"), str) else None)
898
- except StateUnreadable:
899
- plan = {"reason": REF_TARGET_STATE_UNREADABLE}
892
+ if state.snapshots is None:
893
+ plan = {"reason": REF_REVERT_DISABLED}
894
+ else:
895
+ try:
896
+ plan = plan_revert(state.snapshots, str(rargs.get("decision_id") or "last"), state.adapter,
897
+ ref=rargs.get("path") if isinstance(rargs.get("path"), str) else None)
898
+ except StateUnreadable:
899
+ plan = {"reason": REF_TARGET_STATE_UNREADABLE}
900
900
  if "reason" in plan:
901
+ # Refused before any receipt: the trail still says the assistant
902
+ # asked to undo something and why it could not (the Windows
903
+ # acceptance run of 2026-11-02 found this refusal unrecorded).
904
+ _record(state, "refused", {"reason": plan["reason"], "envelope": None, "grant": None,
905
+ "ref": rargs.get("path") if isinstance(rargs.get("path"), str) else None}, params)
901
906
  return _success(msg_id, {"content": [{"type": "text", "text": revert_text(plan["reason"])}], "isError": True,
902
907
  "_meta": {EXECUTED_META_KEY: False, REASON_META_KEY: plan["reason"]}})
903
908
  params = plan["params"]
@@ -6,6 +6,7 @@ Findings, never silently fixed:
6
6
  UNWITNESSED_RECEIPT evidence honors a decision the issuer never issued (forged or foreign)
7
7
  UNWITNESSED_RELEASE a held call was honored but the issuer saw no grant for its hold (or plan)
8
8
  MISSING_EVIDENCE the issuer issued a receipt to this principal and the client recorded nothing
9
+ PREDATES_EVIDENCE issued more than the window before this log's genesis entry; the log cannot be asked about it (information)
9
10
  EVIDENCE_BEFORE_ISSUE the client's entry predates the issuer's issuance
10
11
  CHANGED_OUTSIDE_GATE a file under the gated folder is not in the state the last gated write left it
11
12
  """
@@ -52,6 +53,17 @@ def reconcile(evidence: Iterable[Any], witness: Iterable[Any], *, principal: Opt
52
53
  findings: List[Dict[str, Any]] = []
53
54
  seen_decisions = set()
54
55
  honored_at: Dict[Any, Any] = {} # decision_id -> seq of the entry that first honored it
56
+ # The evidence log's own beginning: an issuance older than its first entry
57
+ # is one this log was never in a position to record (a fresh install, a
58
+ # rotated log). Reported, counted, and kept apart from MISSING_EVIDENCE.
59
+ evidence = list(evidence)
60
+ log_start: Optional[datetime] = None
61
+ first = next((e for e in evidence if isinstance(e, dict)), None)
62
+ if first is not None and first.get("seq") == 1 and first.get("prev_sha256") == "0" * 64:
63
+ # Only a log that begins at its genesis entry can say when it began;
64
+ # a log missing its head is a chain failure, and what the head would
65
+ # have held stays MISSING_EVIDENCE.
66
+ log_start = _parse(first.get("at"))
55
67
  for e in evidence:
56
68
  if not isinstance(e, dict):
57
69
  continue
@@ -100,10 +112,16 @@ def reconcile(evidence: Iterable[Any], witness: Iterable[Any], *, principal: Opt
100
112
  if target_url is not None and isinstance(w.get("target_url"), str) and w["target_url"] != target_url:
101
113
  elsewhere += 1
102
114
  continue
103
- findings.append({"finding": "MISSING_EVIDENCE", "decision_id": did, "tool": w.get("tool"), "principal": w.get("principal"),
115
+ at_w = _parse(w.get("at"))
116
+ # Every issuance precedes its own evidence entry by a little; only one
117
+ # older than the log's first entry by more than the window predates it.
118
+ kind = ("PREDATES_EVIDENCE" if (log_start is not None and at_w is not None
119
+ and (log_start - at_w).total_seconds() > window_seconds) else "MISSING_EVIDENCE")
120
+ findings.append({"finding": kind, "decision_id": did, "tool": w.get("tool"), "principal": w.get("principal"),
104
121
  "held": bool(w.get("held")), "at": w.get("at")})
105
- hard = [f for f in findings if f["finding"] != "PREDATES_WITNESS"]
122
+ hard = [f for f in findings if f["finding"] not in ("PREDATES_WITNESS", "PREDATES_EVIDENCE")]
106
123
  return {"accepted": not hard, "findings": findings, "horizon": horizon.isoformat() if horizon else None,
124
+ "log_start": log_start.isoformat() if log_start else None,
107
125
  "issued": len(issued), "evidence_with_receipt": len(seen_decisions), "issued_elsewhere": elsewhere}
108
126
 
109
127
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.20.0.dev0
3
+ Version: 0.21.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
@@ -64,7 +64,7 @@ by whom, and what changed.
64
64
 
65
65
  ## Status
66
66
 
67
- Pre-release `0.20.0.dev0` on PyPI as `gargoyle-receipts`. Wire format
67
+ Pre-release `0.21.0.dev0` on PyPI as `gargoyle-receipts`. Wire format
68
68
  `gargoyle/3`, evidence log `gargoyle-evidence/4`, witness
69
69
  `gargoyle-witness/1`; all three are specified in [SPEC.md](SPEC.md) and may
70
70
  still change before a first release.
@@ -91,7 +91,7 @@ against a fake Graph server); the OAuth broker for hosted connectors
91
91
  (deployed, metadata and redirect checks verified, no human sign-in through a
92
92
  connector yet).
93
93
 
94
- **Assurance.** 561 Python tests on Linux and Windows across Python 3.12,
94
+ **Assurance.** 564 Python tests on Linux and Windows across Python 3.12,
95
95
  3.13 and 3.14, at dependency floors and at a lock file; a TypeScript suite
96
96
  that recomputes the conformance vectors byte for byte; two outside,
97
97
  model-driven security reviews on 2026-10-20 (ten findings, all fixed); the
@@ -378,6 +378,13 @@ enrollment, revocation and discovery report, on the operator's server.
378
378
  gargoyle audit --log audit.jsonl --witness witness.jsonl # reconcile by decision id: unwitnessed receipts, releases without a grant, missing evidence
379
379
  gargoyle audit --log audit.jsonl --check-tree /srv/shared # hash the gated folder against the recorded states; name files changed around the gate
380
380
  gargoyle audit --log audit.jsonl --summary # calls by principal and tool, side-door findings
381
+ gargoyle audit --log audit.jsonl --issuer https://gargoyle.lan # take the issuer's keys and every approver key it has seen from the issuer, not from a copied file
382
+
383
+ The audit needs the public keys (`public-keys.json`, by default beside the
384
+ log or in `--out`). Grants verify against every key an approver has held,
385
+ so an approver who re-enrolled a browser key is still audited; with
386
+ `--issuer` that list comes from the issuer itself and a stale copy of
387
+ `approvers.json` can no longer fail a valid grant.
381
388
 
382
389
  On a machine the user administers the gate constrains the assistant, not
383
390
  the user; these checks make the difference visible. SPEC.md section 15
@@ -661,7 +668,8 @@ receipts work unchanged. Configuration is by environment:
661
668
  **Issuing service** (`gargoyle.issuer_service`, run with uvicorn from
662
669
  `app_from_env()`, or `gargoyle issuer`): identity token in, receipt out,
663
670
  with the hold and grant exchange for flagged calls (`/issue`, `/pending`,
664
- `/grants`, `/decline`, `/record`, `/keys`, `/sidedoors`) and the browser
671
+ `/grants`, `/decline`, `/record`, `/keys`, `/approvers`, `/approvers/audit`,
672
+ `/sidedoors`) and the browser
665
673
  approval console at `/approve`. Policy from a rules file or OPA; identity
666
674
  from a pinned key or, with `GARGOYLE_ENTRA_TENANT`, the tenant's signing
667
675
  keys (both at once when a pinned key is also set).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gargoyle-receipts"
7
- version = "0.20.0.dev0"
7
+ version = "0.21.0.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"
@@ -330,3 +330,21 @@ def test_a_revoke_inside_the_same_timestamp_tick_is_still_noticed(tmp_path):
330
330
  os.utime(path, ns=(before.st_atime_ns, before.st_mtime_ns)) # the write is invisible by mtime
331
331
  assert path.stat().st_mtime_ns == before.st_mtime_ns
332
332
  assert "dana@contoso.com" not in reg and reg.revoked_entries()["dana@contoso.com"]["revoked_by"] == "operator:root"
333
+
334
+
335
+ def test_approvers_audit_endpoint_lists_every_key_an_approver_held(tmp_path):
336
+ """GET /approvers/audit is what `gargoyle audit --issuer` reads: the current
337
+ key first, then replaced and revoked ones, so grants signed under any of
338
+ them verify; GET /approvers keeps listing only the current, trusted key."""
339
+ reg = ApproverRegistry(str(tmp_path / "approvers.json"))
340
+ app = build_issuer_app(policy=Hold(), signing_key=issuer, key_id="issuer-1", idp_public_keys={"idp-1": idp.public_key()},
341
+ approver_registry=reg)
342
+ c = TestClient(app)
343
+ h = {"Authorization": "Bearer " + token("dana@contoso.com", roles=["Approver"])}
344
+ k1, k2 = Ed25519PrivateKey.generate(), Ed25519PrivateKey.generate()
345
+ assert c.post("/approvers/enroll", json={"public_key_hex": pub_hex(k1)}, headers=h).json()["enrolled"]
346
+ assert c.post("/approvers/revoke", json={"upn": "dana@contoso.com"}, headers=h).json() == {"revoked": True}
347
+ assert c.post("/approvers/enroll", json={"public_key_hex": pub_hex(k2)}, headers=h).json()["enrolled"]
348
+ assert c.get("/approvers").json() == {"approvers": {"dana@contoso.com": pub_hex(k2)}}
349
+ audit = c.get("/approvers/audit").json()["approvers"]
350
+ assert audit["dana@contoso.com"][0] == pub_hex(k2) and set(audit["dana@contoso.com"]) == {pub_hex(k1), pub_hex(k2)}
@@ -177,3 +177,61 @@ def test_pending_store_is_bounded_and_survives_garbage(tmp_path):
177
177
  assert len(p._read()) == 200 and "t4" not in p._read() and "t204" in p._read()
178
178
  assert len(p.reap(now=1000.0, max_age=800.0)) == 195
179
179
  assert p.pop("t204")["params"]["name"] == "Read" and p.pop("t204") is None
180
+
181
+
182
+ def test_post_event_input_differs_from_pre_and_the_entry_still_matches_the_receipt(tmp_path, monkeypatch):
183
+ # Claude Code presents some tools' input differently in PostToolUse than
184
+ # in PreToolUse (seen live with Artifact). The receipt bound what the gate
185
+ # saw; the honored entry must hash those arguments or the audit reports
186
+ # REF_EVIDENCE_RECEIPT_MISSING for a call that was gated correctly.
187
+ out, project, env, state, client = deployment(tmp_path, monkeypatch)
188
+ decide(event(PRE, "Bash", {"command": "true"}, "toolu_1"), state())
189
+ decide(event(POST, "Bash", {"command": "true", "description": "added afterwards"}, "toolu_1", tool_response={"stdout": ""}), state())
190
+ entries = read_log(env["GARGOYLE_MCP_AUDIT_LOG"])
191
+ assert [(e["kind"], e["tool"]) for e in entries] == [("honored", "Bash")]
192
+ assert entries[0]["args_sha256"] == entries[0]["receipt"]["request_context"]["args_sha256"]
193
+ assert main(["audit", "--log", env["GARGOYLE_MCP_AUDIT_LOG"], "--out", str(out)]) == 0
194
+
195
+
196
+ def test_reaped_entry_written_after_the_receipt_expired_still_verifies(tmp_path, monkeypatch):
197
+ # An honored receipt nobody came back for is reaped REAP_SECONDS later,
198
+ # long after its TTL. The refused entry carries it as provenance of what
199
+ # was gated; it is checked as of issuance, not as of the entry.
200
+ import datetime as dt
201
+ from gargoyle import evidence as ev
202
+ out, project, env, state, client = deployment(tmp_path, monkeypatch)
203
+ decide(event(PRE, "Bash", {"command": "true"}, "toolu_1"), state(), now=1000.0)
204
+ later = dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=REAP_SECONDS + 1)
205
+
206
+ class Clock(dt.datetime):
207
+ @classmethod
208
+ def now(cls, tz=None):
209
+ return later if tz is not None else later.replace(tzinfo=None)
210
+ monkeypatch.setattr(ev, "datetime", Clock)
211
+ decide(event(PRE, "Read", {"file_path": str(project / "a.txt")}, "toolu_2"), state(), now=1000.0 + REAP_SECONDS + 1)
212
+ monkeypatch.undo()
213
+ entries = read_log(env["GARGOYLE_MCP_AUDIT_LOG"])
214
+ reaped = [e for e in entries if e["reason"] == REF_HOOK_NOT_RUN]
215
+ assert len(reaped) == 1 and reaped[0]["at"] > reaped[0]["receipt"]["not_after"]
216
+ # The audit exits 1 when any entry fails verification.
217
+ assert main(["audit", "--log", env["GARGOYLE_MCP_AUDIT_LOG"], "--out", str(out)]) == 0
218
+
219
+
220
+ def test_pending_store_survives_concurrent_hooks(tmp_path):
221
+ # Parallel tool calls run their Pre and Post hooks as separate processes
222
+ # at once. Without the lock a put can overwrite a concurrent pop (the
223
+ # honored receipt comes back and is reaped as never run) or a concurrent
224
+ # put (a call that ran leaves no evidence row).
225
+ import threading
226
+ p = PendingReceipts(str(tmp_path / "audit.hook-pending.json"))
227
+ gate = {"envelope": {"decision_id": "d"}, "grant": None, "self_issued": True, "reason": "ok", "ref": None}
228
+ ids = ["t%d" % i for i in range(120)]
229
+ threads = [threading.Thread(target=p.put, args=(i, gate, {"name": "Bash", "arguments": {}}, 1.0)) for i in ids]
230
+ for t in threads: t.start()
231
+ for t in threads: t.join()
232
+ assert sorted(p._read()) == sorted(ids)
233
+ popped = []
234
+ threads = [threading.Thread(target=lambda i=i: popped.append(p.pop(i))) for i in ids]
235
+ for t in threads: t.start()
236
+ for t in threads: t.join()
237
+ assert len(popped) == 120 and all(g is not None for g in popped) and p._read() == {}
@@ -137,5 +137,14 @@ def test_revert_restores_an_approved_write_through_the_gate(tmp_path):
137
137
  assert call(state, 6, "write_file", {"path": str(new), "content": "x"})["result"]["_meta"][EXECUTED_META_KEY]
138
138
  r = call(state, 7, REVERT_TOOL, {"decision_id": "last"})
139
139
  assert r["result"]["_meta"][REASON_META_KEY] == REF_REVERT_NEEDS_DELETE and new.exists()
140
+
141
+ # Both refusals are in the trail: the assistant asked to undo, and why it could not.
142
+ log = read_log(str(tmp_path / "audit.jsonl"))
143
+ refused = [(e["tool"], e["reason"]) for e in log if e["kind"] == "refused"]
144
+ assert refused == [(REVERT_TOOL, REF_REVERT_STATE_CHANGED), (REVERT_TOOL, REF_REVERT_NEEDS_DELETE)]
145
+ assert log[-1]["receipt"] is None and log[-1]["principal"]
146
+ v = verify_log(log, issuer_public_keys={"issuer-1": issuer.public_key()},
147
+ executor_public_keys={"executor-1": executor.public_key()}, approver_public_keys={"approver-1": approver.public_key()})
148
+ assert v["accepted"] and v["refused"] == 2
140
149
  finally:
141
150
  up.close()
@@ -134,7 +134,7 @@ def test_every_path_after_the_claim_leaves_evidence(tmp_path, capsys):
134
134
  assert report["accepted"] and [e["verdict"] for e in report["entries"]][:3] == [TOOL_ERROR, UPSTREAM_ERROR, UPSTREAM_UNAVAILABLE]
135
135
  _audit_summary(report["entries"], log)
136
136
  out = capsys.readouterr().out
137
- assert "failed" in out and "%-24s %7d %7d %7d" % ("write_file", 1, 3, 0) in out
137
+ assert "errored" in out and "upstream reported an error" in out and "%-24s %7d %7d %7d" % ("write_file", 1, 3, 0) in out
138
138
  # The spent receipts are spent: a retry of the failed one is a replay.
139
139
  again = handle_message(state, _call(envs[0], {"path": "/gargoyle-test-root/f0"}, 5))
140
140
  assert again["result"]["_meta"][REASON_META_KEY] == REF_VERIFY_REPLAY
@@ -133,6 +133,15 @@ def test_honest_evidence_reconciles_and_tampering_is_found(tmp_path):
133
133
  # 3. A receipt the issuer issued but the client never recorded: drop the client's read entry.
134
134
  r = reconcile([e for e in evidence if e["tool"] != "read_file"], witness, principal=PRINCIPAL)
135
135
  assert [f["finding"] for f in r["findings"]] == ["MISSING_EVIDENCE"] and r["findings"][0]["tool"] == "read_file"
136
+ # An issuance older than the log's first entry is one this log could never have recorded:
137
+ # information with a count, not a failure (a fresh install, a rotated log).
138
+ ancient = dict([w for w in witness if w["event"] == "issued"][0], decision_id="d-ancient",
139
+ at=(datetime.fromisoformat(evidence[0]["at"]) - timedelta(days=2)).isoformat())
140
+ r = reconcile(evidence, witness + [ancient], principal=PRINCIPAL)
141
+ assert r["accepted"] and [f["finding"] for f in r["findings"]] == ["PREDATES_EVIDENCE"] and r["log_start"] == evidence[0]["at"]
142
+ later = dict(ancient, decision_id="d-later", at=(datetime.fromisoformat(evidence[0]["at"]) + timedelta(seconds=1)).isoformat())
143
+ r = reconcile(evidence, witness + [later], principal=PRINCIPAL)
144
+ assert not r["accepted"] and [f["finding"] for f in r["findings"]] == ["MISSING_EVIDENCE"]
136
145
 
137
146
  # The tree: as the gate left it, then edited behind its back, then deleted.
138
147
  t = check_tree(evidence, str(root))
@@ -231,3 +240,44 @@ def test_reconciliation_is_scoped_to_the_evidence_logs_target(tmp_path):
231
240
  assert [f["finding"] for f in r["findings"]] == ["MISSING_EVIDENCE"]
232
241
  r = reconcile(evidence, witness + [legacy], principal=PRINCIPAL, target_url=TARGET) # no target on record: reported
233
242
  assert [f["finding"] for f in r["findings"]] == ["MISSING_EVIDENCE"] and r["issued_elsewhere"] == 0
243
+
244
+
245
+ def test_audit_finds_its_keys_beside_the_log_and_can_take_approver_keys_from_the_issuer(tmp_path, capsys, monkeypatch):
246
+ """A workstation's keys sit beside audit.jsonl; a wrong --out is not a
247
+ traceback. A grant signed by a key the local approvers copy does not list
248
+ fails with a note that says so; --issuer fetches the issuer's full list."""
249
+ root = tmp_path / "root"; root.mkdir(); (root / "n.md").write_text("one\n", encoding="utf-8", newline="\n")
250
+ state, up, c, h, w = _rig(tmp_path, root)
251
+ try:
252
+ assert call(state, 2, "write_file", {"path": str(root / "n.md"), "content": "two\n"})["_meta"][EXECUTED_META_KEY]
253
+ finally:
254
+ up.close()
255
+ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
256
+ hexpub = lambda k: k.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
257
+ from gargoyle.cli import main
258
+ log = str(tmp_path / "audit.jsonl")
259
+ # No keys anywhere: a usage error, not a traceback.
260
+ monkeypatch.chdir(tmp_path)
261
+ assert main(["audit", "--log", log]) == 2
262
+ assert "public-keys.json not found" in capsys.readouterr().err
263
+ # Keys beside the log, --out left at its default: found.
264
+ (tmp_path / "public-keys.json").write_text(json.dumps({"issuer": {"issuer-1": hexpub(issuer)}, "executor": {"executor-1": hexpub(executor)},
265
+ "approver": {"approver-1": hexpub(approver)}}))
266
+ assert main(["audit", "--log", log]) == 0
267
+ # The local copy does not know the approver's key: the release fails, and the note says what to do.
268
+ (tmp_path / "public-keys.json").write_text(json.dumps({"issuer": {"issuer-1": hexpub(issuer)}, "executor": {"executor-1": hexpub(executor)},
269
+ "approver": {}}))
270
+ assert main(["audit", "--log", log]) == 1
271
+ text = capsys.readouterr().out
272
+ assert "REF_APPROVAL_KEY_UNKNOWN" in text and "not known here: approver-1" in text and "--issuer URL" in text
273
+ # The copy knows the approver under an older key (the Windows run of 2026-11-02): named as stale, same remedy.
274
+ (tmp_path / "public-keys.json").write_text(json.dumps({"issuer": {"issuer-1": hexpub(issuer)}, "executor": {"executor-1": hexpub(executor)},
275
+ "approver": {"approver-1": hexpub(Ed25519PrivateKey.generate())}}))
276
+ assert main(["audit", "--log", log]) == 1
277
+ text = capsys.readouterr().out
278
+ assert "REF_APPROVAL_SIGNATURE_INVALID" in text and "listed key does not verify the grant: approver-1" in text
279
+ # --issuer: the issuer's list (current key plus history) stands in for the copy.
280
+ import gargoyle.cli as cli
281
+ monkeypatch.setattr(cli, "_issuer_trust", lambda url: ({"issuer-1": hexpub(issuer)}, {"approver-1": [hexpub(Ed25519PrivateKey.generate()), hexpub(approver)]}))
282
+ assert main(["audit", "--log", log, "--issuer", "https://issuer.test"]) == 0
283
+ assert "0 failed" in capsys.readouterr().out