gargoyle-receipts 0.20.0.dev0__tar.gz → 0.22.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.22.0.dev0}/PKG-INFO +14 -4
  2. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/README.md +13 -3
  3. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/claude_hook.py +30 -15
  4. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/cli.py +79 -5
  5. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/doctor.py +11 -0
  6. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/evidence.py +56 -28
  7. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/issuer_service.py +8 -0
  8. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/mcp_server.py +13 -8
  9. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/reconcile.py +31 -2
  10. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +14 -4
  11. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/pyproject.toml +1 -1
  12. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_approvers.py +18 -0
  13. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_claude_hook.py +58 -0
  14. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_doctor.py +7 -3
  15. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_evidence.py +22 -0
  16. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_revert.py +9 -0
  17. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_review_fixes.py +1 -1
  18. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_witness.py +63 -0
  19. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/LICENSE +0 -0
  20. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/NOTICE +0 -0
  21. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/__init__.py +0 -0
  22. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/__main__.py +0 -0
  23. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/adapters.py +0 -0
  24. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/approval.py +0 -0
  25. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/approver_cli.py +0 -0
  26. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/approver_trust.py +0 -0
  27. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/approvers.py +0 -0
  28. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/consumer.py +0 -0
  29. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/emilia.py +0 -0
  30. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/emilia_runner.py +0 -0
  31. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/entra_login.py +0 -0
  32. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/envelope.py +0 -0
  33. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/execution.py +0 -0
  34. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/fs_server.py +0 -0
  35. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/globs.py +0 -0
  36. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/holdstore.py +0 -0
  37. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/holdtext.py +0 -0
  38. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/identity.py +0 -0
  39. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/issuer.py +0 -0
  40. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/jws.py +0 -0
  41. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/key_record_source.py +0 -0
  42. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/notify.py +0 -0
  43. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/oauth_broker.py +0 -0
  44. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/opa.py +0 -0
  45. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/operations.py +0 -0
  46. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/pending_store.py +0 -0
  47. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/plans.py +0 -0
  48. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/policy.py +0 -0
  49. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/published_record_source.py +0 -0
  50. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/remote.py +0 -0
  51. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/replay_cache.py +0 -0
  52. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/revert.py +0 -0
  53. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/rulecheck.py +0 -0
  54. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/rules.py +0 -0
  55. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/sidedoors.py +0 -0
  56. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
  57. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/state.py +0 -0
  58. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/static/approve.html +0 -0
  59. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/static/approve.js +0 -0
  60. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/verifier.py +0 -0
  61. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/witness.py +0 -0
  62. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +0 -0
  63. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  64. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  65. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  66. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  67. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/setup.cfg +0 -0
  68. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_annotations.py +0 -0
  69. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_approval_views.py +0 -0
  70. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_approver_cli.py +0 -0
  71. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_canonical.py +0 -0
  72. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_cli.py +0 -0
  73. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_console_js.py +0 -0
  74. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_consumer.py +0 -0
  75. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_declines.py +0 -0
  76. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_emilia_interop.py +0 -0
  77. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_emilia_runner.py +0 -0
  78. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_entra_identity.py +0 -0
  79. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_entra_login.py +0 -0
  80. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_env_file.py +0 -0
  81. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_execution.py +0 -0
  82. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_fs_server.py +0 -0
  83. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_globs.py +0 -0
  84. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_graph.py +0 -0
  85. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_hardening_2026_11_01.py +0 -0
  86. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_historical.py +0 -0
  87. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_identity.py +0 -0
  88. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_issuer_service.py +0 -0
  89. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_jws.py +0 -0
  90. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_mcp_proxy.py +0 -0
  91. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_mcp_self_issue.py +0 -0
  92. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_mcp_server.py +0 -0
  93. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_notify.py +0 -0
  94. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_oauth_broker.py +0 -0
  95. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_opa.py +0 -0
  96. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_operability.py +0 -0
  97. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_operations.py +0 -0
  98. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_plans.py +0 -0
  99. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_policy.py +0 -0
  100. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_published_record.py +0 -0
  101. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_record_follow.py +0 -0
  102. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_remote.py +0 -0
  103. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_rulecheck.py +0 -0
  104. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_rules.py +0 -0
  105. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_sidedoors.py +0 -0
  106. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_state.py +0 -0
  107. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_state_text.py +0 -0
  108. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_stdio_encoding.py +0 -0
  109. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_threshold.py +0 -0
  110. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_typescript_conformance.py +0 -0
  111. {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.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.22.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.22.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.** 565 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).
@@ -674,6 +682,8 @@ keys (both at once when a pinned key is also set).
674
682
  | `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys, with a `revoked` list, re-read when its content changes) and which Entra app role (default `Approver`) or groups may enroll, see every hold and the witness |
675
683
  | `GARGOYLE_HOLDS_PATH` | SQLite file for holds, grants, declines and plans, so a restart loses nothing a person is acting on (`gargoyle issuer` sets `holds.sqlite` beside the keys); `GET /health` reports `persistent` |
676
684
  | `GARGOYLE_MAX_OPEN_HOLDS` | how many held receipts and plans one principal may have waiting at once (default 20); the call over the cap is `REF_ISSUER_TOO_MANY_HOLDS` and nothing is stored |
685
+ | `GARGOYLE_DECLINE_LIMIT`, `GARGOYLE_DECLINE_WINDOW_HOURS` | the decline cooldown: after this many declines of the same (principal, tool, path) within the window (defaults 2 and 24), the issuer refuses the next ask with `REF_ISSUER_DECLINED_RECENTLY` instead of holding it again, so an approver is not asked the same question a third time in a day; `0` disables |
686
+ | `GARGOYLE_RECEIPT_TTL_SECONDS` | how long an issued receipt stays presentable (default 300); a held receipt's hold lives up to six times that, capped at an hour |
677
687
  | `GARGOYLE_ALLOW_SELF_APPROVAL` | `1` lets a person release their own call; by default the approver may be neither the hold's principal nor the party acted for (`REF_ISSUER_SELF_APPROVAL`). A grant for an expired hold is `REF_ISSUER_HOLD_EXPIRED` and the hold is dropped |
678
688
  | `GARGOYLE_WITNESS_PATH` | the issuer's signed, hash-chained record of every decision (`gargoyle issuer` sets `witness.jsonl` beside the keys) |
679
689
  | `GARGOYLE_HOLD_WEBHOOK_URL`, `GARGOYLE_APPROVE_URL` | POST a notice of every new hold (Slack incoming-webhook shape, `text` plus the hold) with a link to the console; notice only, the grant still needs the approver's key |
@@ -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.22.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.** 565 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).
@@ -636,6 +644,8 @@ keys (both at once when a pinned key is also set).
636
644
  | `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys, with a `revoked` list, re-read when its content changes) and which Entra app role (default `Approver`) or groups may enroll, see every hold and the witness |
637
645
  | `GARGOYLE_HOLDS_PATH` | SQLite file for holds, grants, declines and plans, so a restart loses nothing a person is acting on (`gargoyle issuer` sets `holds.sqlite` beside the keys); `GET /health` reports `persistent` |
638
646
  | `GARGOYLE_MAX_OPEN_HOLDS` | how many held receipts and plans one principal may have waiting at once (default 20); the call over the cap is `REF_ISSUER_TOO_MANY_HOLDS` and nothing is stored |
647
+ | `GARGOYLE_DECLINE_LIMIT`, `GARGOYLE_DECLINE_WINDOW_HOURS` | the decline cooldown: after this many declines of the same (principal, tool, path) within the window (defaults 2 and 24), the issuer refuses the next ask with `REF_ISSUER_DECLINED_RECENTLY` instead of holding it again, so an approver is not asked the same question a third time in a day; `0` disables |
648
+ | `GARGOYLE_RECEIPT_TTL_SECONDS` | how long an issued receipt stays presentable (default 300); a held receipt's hold lives up to six times that, capped at an hour |
639
649
  | `GARGOYLE_ALLOW_SELF_APPROVAL` | `1` lets a person release their own call; by default the approver may be neither the hold's principal nor the party acted for (`REF_ISSUER_SELF_APPROVAL`). A grant for an expired hold is `REF_ISSUER_HOLD_EXPIRED` and the hold is dropped |
640
650
  | `GARGOYLE_WITNESS_PATH` | the issuer's signed, hash-chained record of every decision (`gargoyle issuer` sets `witness.jsonl` beside the keys) |
641
651
  | `GARGOYLE_HOLD_WEBHOOK_URL`, `GARGOYLE_APPROVE_URL` | POST a notice of every new hold (Slack incoming-webhook shape, `text` plus the hold) with a link to the console; notice only, the grant still needs the approver's key |
@@ -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,60 @@ 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
+ # A missing log is a usage error with the path named, not a traceback: a
821
+ # client whose evidence log was rotated or has not been written yet.
822
+ if getattr(args, "log", None) and not Path(args.log).is_file():
823
+ print("evidence log not found: %s (nothing recorded there yet, or the log was rotated under another name)"
824
+ % Path(args.log).resolve(), file=sys.stderr)
825
+ return 2
826
+ out = _audit_trust_dir(args)
827
+ if out is None:
828
+ print("public-keys.json not found in %s%s: pass --out DIR, the folder that holds public-keys.json (and approvers.json), "
829
+ "on a workstation the one with middleware.env" % (Path(args.out).resolve(),
830
+ (" or beside %s" % Path(args.log).resolve().parent) if getattr(args, "log", None) else ""), file=sys.stderr)
831
+ return 2
832
+ keys, approvers = _trust_hex(out)
833
+ if getattr(args, "issuer", None):
834
+ try:
835
+ issuer_keys, issuer_approvers = _issuer_trust(args.issuer)
836
+ except Exception as exc:
837
+ print("issuer not reachable for keys: %s (%s)" % (args.issuer, exc), file=sys.stderr)
838
+ return 2
839
+ keys["issuer"] = dict(keys.get("issuer", {}), **issuer_keys)
840
+ # The issuer's list is current and complete; a local copy may be older.
841
+ for upn, hexes in issuer_approvers.items():
842
+ have = approvers.get(upn)
843
+ have = have if isinstance(have, list) else ([have] if have else [])
844
+ approvers[upn] = list(hexes) + [h for h in have if h not in hexes]
794
845
  one = lambda h: Ed25519PublicKey.from_public_bytes(bytes.fromhex(h))
795
846
  load = lambda m: {k: ([one(x) for x in v] if isinstance(v, list) else one(v)) for k, v in m.items()}
796
847
  trust = dict(issuer_public_keys=load(keys["issuer"]), executor_public_keys=load(keys["executor"]),
@@ -821,6 +872,17 @@ def cmd_audit(args) -> int:
821
872
  failed = sum(1 for r in v["entries"] if not r["accepted"])
822
873
  print("%d entries (%d honored, %d refused), %d failed; chain %s; tail %s"
823
874
  % (v["count"], v["honored"], v["refused"], failed, v["reason"], v["tail_sha256"][:16]))
875
+ from gargoyle.approval import REF_APPROVAL_KEY_UNKNOWN, REF_APPROVAL_SIGNATURE_INVALID
876
+ bad_sigs = [r for r in v["entries"] if not r["accepted"] and r["reason"] in (REF_APPROVAL_SIGNATURE_INVALID, REF_APPROVAL_KEY_UNKNOWN)]
877
+ if bad_sigs:
878
+ ids = sorted({k for r in bad_sigs for k in (r.get("released_by") or [])})
879
+ unknown = [k for k in ids if k not in approvers]
880
+ stale = [k for k in ids if k in approvers]
881
+ print(" note: %d release(s) failed the grant check%s%s. The approver keys here come from %s; an approver who "
882
+ "re-enrolled a browser key since that copy was made signs with a key it does not list. Copy the issuer's "
883
+ "current approvers.json beside public-keys.json, or pass --issuer URL to fetch every key the issuer has seen."
884
+ % (len(bad_sigs), (": approver id(s) not known here: %s" % ", ".join(unknown)) if unknown else "",
885
+ ("; known approver(s) whose listed key does not verify the grant: %s" % ", ".join(stale)) if stale else "", out))
824
886
  rc = 0 if v["accepted"] else 1
825
887
  entries = read_log(args.log)
826
888
  if args.summary:
@@ -851,14 +913,25 @@ def cmd_audit(args) -> int:
851
913
  if isinstance(e, dict) and isinstance(e.get("receipt"), dict) and e["receipt"].get("target_url")), None)
852
914
  r = reconcile(entries, read_witness(args.witness), principal=principal, target_url=target)
853
915
  older = [f for f in r["findings"] if f["finding"] == "PREDATES_WITNESS"]
916
+ before_log = [f for f in r["findings"] if f["finding"] == "PREDATES_EVIDENCE"]
917
+ in_flight = [f for f in r["findings"] if f["finding"] == "IN_FLIGHT"]
854
918
  for f in r["findings"]:
855
- if f["finding"] == "PREDATES_WITNESS":
919
+ if f["finding"] in ("PREDATES_WITNESS", "PREDATES_EVIDENCE", "IN_FLIGHT"):
856
920
  continue
857
921
  print(" FINDING %-22s %s" % (f["finding"], " ".join("%s=%s" % (k, str(v)[:40]) for k, v in f.items() if k != "finding")))
858
922
  if older:
859
923
  print(" note: %d honored row(s) predate the witness (started %s) and cannot be vouched for either way: seq %s"
860
924
  % (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)
925
+ if before_log:
926
+ held_n = sum(1 for f in before_log if f.get("held"))
927
+ print(" note: %d receipt(s) the issuer issued to this principal predate this evidence log (first entry %s; %d of them held): "
928
+ "a log that began later cannot be asked about them. If the log was not meant to start then, that is the finding."
929
+ % (len(before_log), (r.get("log_start") or "?")[:19], held_n))
930
+ if in_flight:
931
+ print(" note: %d receipt(s) issued after this log's last entry (%s) have no row yet: in flight when the log was read "
932
+ "(this audit's own gated command, a call still held). Audit again later; one still missing then is MISSING_EVIDENCE."
933
+ % (len(in_flight), (r.get("log_end") or "?")[:19]))
934
+ hard = len(r["findings"]) - len(older) - len(before_log) - len(in_flight)
862
935
  print("reconciled: %d issued by the issuer, %d in evidence, %d finding(s)%s"
863
936
  % (r["issued"], r["evidence_with_receipt"], hard,
864
937
  " (%d issued for other targets, not this gate's)" % r["issued_elsewhere"] if r.get("issued_elsewhere") else ""))
@@ -1021,7 +1094,8 @@ def main(argv=None) -> int:
1021
1094
  p.add_argument("--witness", help="the issuer's witness.jsonl: reconcile the evidence against it")
1022
1095
  p.add_argument("--check-tree", metavar="DIR", help="verify every gated file under DIR is as the gate left it")
1023
1096
  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")
1097
+ 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)")
1098
+ 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
1099
  p.set_defaults(fn=cmd_audit)
1026
1100
  p = sub.add_parser("emilia", help="export an evidence log's approved calls as EMILIA Trust Receipts")
1027
1101
  p.add_argument("--log", required=True, help="the middleware's audit.jsonl")
@@ -177,6 +177,17 @@ def check_issuer(env: Dict[str, str], ctx: Context) -> Dict[str, Any]:
177
177
  if isinstance(head, dict):
178
178
  detail += ", witness seq %s" % head.get("seq")
179
179
  date = None
180
+ try:
181
+ date = r.headers.get("Date") if hasattr(r, "headers") else None
182
+ except Exception:
183
+ date = None
184
+ if h.get("enrolled_approvers") == 1:
185
+ # One browser key between every hold and its release: lost, re-enrolled
186
+ # or on leave, nothing is released until a second approver exists.
187
+ return {"check": Check("issuer", WARN, detail,
188
+ "one approver enrolled: a lost or re-enrolled browser key, or an absence, leaves every hold waiting; enroll a second approver (a deputy role) and test a release by each"),
189
+ "health": h, "date": date}
190
+ date = None
180
191
  try:
181
192
  date = r.headers.get("Date") if hasattr(r, "headers") else None
182
193
  except Exception:
@@ -127,6 +127,54 @@ def build_entry(*, seq: int, prev_sha256: str, kind: str, tool: str, args: Any,
127
127
  return entry
128
128
 
129
129
 
130
+ LOCK_WAIT_SECONDS = 60.0 # a lock not acquired in this long means a stuck holder; the caller fails closed
131
+
132
+
133
+ def locked(lock_path: Any, wait_seconds: float = LOCK_WAIT_SECONDS):
134
+ """An exclusive lock on `lock_path` (POSIX flock, Windows byte-range
135
+ lock), released on exit. Processes that share a file on one workstation,
136
+ the hook's PreToolUse and PostToolUse among them, hold it across their
137
+ read-modify-write so neither loses the other's update. The wait is
138
+ bounded: a holder that never lets go (a hung process) raises OSError
139
+ after `wait_seconds` rather than hanging every later call, and a gate
140
+ that cannot record does not let the call through."""
141
+ import contextlib
142
+ import time as _time
143
+
144
+ @contextlib.contextmanager
145
+ def hold():
146
+ with open(lock_path, "a+b") as lock:
147
+ deadline = _time.monotonic() + wait_seconds
148
+ try:
149
+ import fcntl
150
+ while True:
151
+ try:
152
+ fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
153
+ break
154
+ except OSError:
155
+ if _time.monotonic() >= deadline:
156
+ raise OSError("lock not acquired within %.0f s: %s (a stuck holder?)" % (wait_seconds, lock_path))
157
+ _time.sleep(0.02)
158
+ unlock = lambda: fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
159
+ except ImportError: # Windows
160
+ import msvcrt
161
+ lock.seek(0)
162
+ while True:
163
+ try:
164
+ msvcrt.locking(lock.fileno(), msvcrt.LK_NBLCK, 1)
165
+ break
166
+ except OSError:
167
+ if _time.monotonic() >= deadline:
168
+ raise OSError("lock not acquired within %.0f s: %s (a stuck holder?)" % (wait_seconds, lock_path))
169
+ _time.sleep(0.02)
170
+ unlock = lambda: (lock.seek(0), msvcrt.locking(lock.fileno(), msvcrt.LK_UNLCK, 1))
171
+ try:
172
+ yield
173
+ finally:
174
+ unlock()
175
+ return hold()
176
+
177
+
130
178
  class EvidenceLog:
131
179
  """Appends entries to a JSON Lines file, continuing the chain found at
132
180
  its tail. Several gate processes may share one log (a Claude Code and
@@ -156,33 +204,8 @@ class EvidenceLog:
156
204
  self._seq, self._tail = int(last["seq"]), str(last["entry_sha256"])
157
205
 
158
206
  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()
207
+ """An exclusive, blocking lock on the log's lock file, released on exit."""
208
+ return locked(self.path.with_name(self.path.name + ".lock"))
186
209
 
187
210
  def append(self, kind: str, tool: str, args: Any, principal: str, reason: str, *,
188
211
  receipt: Any = None, grant: Any = None, execution: Any = None,
@@ -287,8 +310,13 @@ def verify_log(
287
310
  else:
288
311
  reason = ACCEPT_ENTRY_VERIFIED
289
312
  if e["receipt"] is not None:
313
+ # Provenance only: a refused entry says the receipt was issued and
314
+ # the call did not run. It may be written long after the receipt
315
+ # expired (an honored call nobody came back for is reaped an hour
316
+ # on), so it is checked as of its own issuance, not the entry's.
317
+ issued_at = e["receipt"].get("timestamp_utc") if isinstance(e["receipt"], dict) else None
290
318
  p = verify_provenance(e["receipt"], pinned_public_keys=issuer_public_keys,
291
- now=_parse_at(e["at"]), historical=True)
319
+ now=_parse_at(issued_at if isinstance(issued_at, str) else e["at"]), historical=True)
292
320
  ok, reason = p["accepted"], (ACCEPT_ENTRY_VERIFIED if p["accepted"] else p["reason"])
293
321
  counts[e["kind"]] += 1
294
322
  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,23 @@ 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"))
67
+ # The log's last entry: an issuance newer than it is a call whose evidence
68
+ # row the executor had not written when this log was read (the audit's own
69
+ # gated command, a call still waiting for its approver). Reported as
70
+ # IN_FLIGHT, information, not failure; the next audit settles it.
71
+ last = next((e for e in reversed(evidence) if isinstance(e, dict)), None)
72
+ log_end: Optional[datetime] = _parse(last.get("at")) if last is not None else None
55
73
  for e in evidence:
56
74
  if not isinstance(e, dict):
57
75
  continue
@@ -100,10 +118,21 @@ def reconcile(evidence: Iterable[Any], witness: Iterable[Any], *, principal: Opt
100
118
  if target_url is not None and isinstance(w.get("target_url"), str) and w["target_url"] != target_url:
101
119
  elsewhere += 1
102
120
  continue
103
- findings.append({"finding": "MISSING_EVIDENCE", "decision_id": did, "tool": w.get("tool"), "principal": w.get("principal"),
121
+ at_w = _parse(w.get("at"))
122
+ # Every issuance precedes its own evidence entry by a little; only one
123
+ # older than the log's first entry by more than the window predates it.
124
+ if log_start is not None and at_w is not None and (log_start - at_w).total_seconds() > window_seconds:
125
+ kind = "PREDATES_EVIDENCE"
126
+ elif log_end is not None and at_w is not None and at_w > log_end:
127
+ kind = "IN_FLIGHT"
128
+ else:
129
+ kind = "MISSING_EVIDENCE"
130
+ findings.append({"finding": kind, "decision_id": did, "tool": w.get("tool"), "principal": w.get("principal"),
104
131
  "held": bool(w.get("held")), "at": w.get("at")})
105
- hard = [f for f in findings if f["finding"] != "PREDATES_WITNESS"]
132
+ hard = [f for f in findings if f["finding"] not in ("PREDATES_WITNESS", "PREDATES_EVIDENCE", "IN_FLIGHT")]
106
133
  return {"accepted": not hard, "findings": findings, "horizon": horizon.isoformat() if horizon else None,
134
+ "log_start": log_start.isoformat() if log_start else None,
135
+ "log_end": log_end.isoformat() if log_end else None,
107
136
  "issued": len(issued), "evidence_with_receipt": len(seen_decisions), "issued_elsewhere": elsewhere}
108
137
 
109
138
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.20.0.dev0
3
+ Version: 0.22.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.22.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.** 565 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).
@@ -674,6 +682,8 @@ keys (both at once when a pinned key is also set).
674
682
  | `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys, with a `revoked` list, re-read when its content changes) and which Entra app role (default `Approver`) or groups may enroll, see every hold and the witness |
675
683
  | `GARGOYLE_HOLDS_PATH` | SQLite file for holds, grants, declines and plans, so a restart loses nothing a person is acting on (`gargoyle issuer` sets `holds.sqlite` beside the keys); `GET /health` reports `persistent` |
676
684
  | `GARGOYLE_MAX_OPEN_HOLDS` | how many held receipts and plans one principal may have waiting at once (default 20); the call over the cap is `REF_ISSUER_TOO_MANY_HOLDS` and nothing is stored |
685
+ | `GARGOYLE_DECLINE_LIMIT`, `GARGOYLE_DECLINE_WINDOW_HOURS` | the decline cooldown: after this many declines of the same (principal, tool, path) within the window (defaults 2 and 24), the issuer refuses the next ask with `REF_ISSUER_DECLINED_RECENTLY` instead of holding it again, so an approver is not asked the same question a third time in a day; `0` disables |
686
+ | `GARGOYLE_RECEIPT_TTL_SECONDS` | how long an issued receipt stays presentable (default 300); a held receipt's hold lives up to six times that, capped at an hour |
677
687
  | `GARGOYLE_ALLOW_SELF_APPROVAL` | `1` lets a person release their own call; by default the approver may be neither the hold's principal nor the party acted for (`REF_ISSUER_SELF_APPROVAL`). A grant for an expired hold is `REF_ISSUER_HOLD_EXPIRED` and the hold is dropped |
678
688
  | `GARGOYLE_WITNESS_PATH` | the issuer's signed, hash-chained record of every decision (`gargoyle issuer` sets `witness.jsonl` beside the keys) |
679
689
  | `GARGOYLE_HOLD_WEBHOOK_URL`, `GARGOYLE_APPROVE_URL` | POST a notice of every new hold (Slack incoming-webhook shape, `text` plus the hold) with a link to the console; notice only, the grant still needs the approver's key |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gargoyle-receipts"
7
- version = "0.20.0.dev0"
7
+ version = "0.22.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"