gargoyle-receipts 0.21.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.21.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.22.0.dev0}/PKG-INFO +5 -3
  2. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/README.md +4 -2
  3. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/cli.py +13 -2
  4. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/doctor.py +11 -0
  5. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/evidence.py +25 -8
  6. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/reconcile.py +14 -3
  7. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +5 -3
  8. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/pyproject.toml +1 -1
  9. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_doctor.py +7 -3
  10. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_evidence.py +22 -0
  11. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_witness.py +14 -1
  12. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/LICENSE +0 -0
  13. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/NOTICE +0 -0
  14. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/__init__.py +0 -0
  15. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/__main__.py +0 -0
  16. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/adapters.py +0 -0
  17. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/approval.py +0 -0
  18. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/approver_cli.py +0 -0
  19. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/approver_trust.py +0 -0
  20. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/approvers.py +0 -0
  21. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/claude_hook.py +0 -0
  22. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/consumer.py +0 -0
  23. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/emilia.py +0 -0
  24. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/emilia_runner.py +0 -0
  25. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/entra_login.py +0 -0
  26. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/envelope.py +0 -0
  27. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/execution.py +0 -0
  28. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/fs_server.py +0 -0
  29. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/globs.py +0 -0
  30. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/holdstore.py +0 -0
  31. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/holdtext.py +0 -0
  32. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/identity.py +0 -0
  33. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/issuer.py +0 -0
  34. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/issuer_service.py +0 -0
  35. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/jws.py +0 -0
  36. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/key_record_source.py +0 -0
  37. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/mcp_server.py +0 -0
  38. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/notify.py +0 -0
  39. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/oauth_broker.py +0 -0
  40. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/opa.py +0 -0
  41. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/operations.py +0 -0
  42. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/pending_store.py +0 -0
  43. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/plans.py +0 -0
  44. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/policy.py +0 -0
  45. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/published_record_source.py +0 -0
  46. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/remote.py +0 -0
  47. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/replay_cache.py +0 -0
  48. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/revert.py +0 -0
  49. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/rulecheck.py +0 -0
  50. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/rules.py +0 -0
  51. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/sidedoors.py +0 -0
  52. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
  53. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/state.py +0 -0
  54. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/static/approve.html +0 -0
  55. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/static/approve.js +0 -0
  56. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/verifier.py +0 -0
  57. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/witness.py +0 -0
  58. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +0 -0
  59. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  60. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  61. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  62. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  63. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/setup.cfg +0 -0
  64. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_annotations.py +0 -0
  65. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_approval_views.py +0 -0
  66. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_approver_cli.py +0 -0
  67. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_approvers.py +0 -0
  68. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_canonical.py +0 -0
  69. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_claude_hook.py +0 -0
  70. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_cli.py +0 -0
  71. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_console_js.py +0 -0
  72. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_consumer.py +0 -0
  73. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_declines.py +0 -0
  74. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_emilia_interop.py +0 -0
  75. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_emilia_runner.py +0 -0
  76. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_entra_identity.py +0 -0
  77. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_entra_login.py +0 -0
  78. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_env_file.py +0 -0
  79. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_execution.py +0 -0
  80. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_fs_server.py +0 -0
  81. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_globs.py +0 -0
  82. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_graph.py +0 -0
  83. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_hardening_2026_11_01.py +0 -0
  84. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_historical.py +0 -0
  85. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_identity.py +0 -0
  86. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_issuer_service.py +0 -0
  87. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_jws.py +0 -0
  88. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_mcp_proxy.py +0 -0
  89. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_mcp_self_issue.py +0 -0
  90. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_mcp_server.py +0 -0
  91. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_notify.py +0 -0
  92. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_oauth_broker.py +0 -0
  93. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_opa.py +0 -0
  94. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_operability.py +0 -0
  95. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_operations.py +0 -0
  96. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_plans.py +0 -0
  97. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_policy.py +0 -0
  98. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_published_record.py +0 -0
  99. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_record_follow.py +0 -0
  100. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_remote.py +0 -0
  101. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_revert.py +0 -0
  102. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_review_fixes.py +0 -0
  103. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_rulecheck.py +0 -0
  104. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_rules.py +0 -0
  105. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_sidedoors.py +0 -0
  106. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_state.py +0 -0
  107. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_state_text.py +0 -0
  108. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_stdio_encoding.py +0 -0
  109. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_threshold.py +0 -0
  110. {gargoyle_receipts-0.21.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_typescript_conformance.py +0 -0
  111. {gargoyle_receipts-0.21.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.21.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.21.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.** 564 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
@@ -682,6 +682,8 @@ keys (both at once when a pinned key is also set).
682
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 |
683
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` |
684
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 |
685
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 |
686
688
  | `GARGOYLE_WITNESS_PATH` | the issuer's signed, hash-chained record of every decision (`gargoyle issuer` sets `witness.jsonl` beside the keys) |
687
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.21.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.** 564 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
@@ -644,6 +644,8 @@ keys (both at once when a pinned key is also set).
644
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 |
645
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` |
646
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 |
647
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 |
648
650
  | `GARGOYLE_WITNESS_PATH` | the issuer's signed, hash-chained record of every decision (`gargoyle issuer` sets `witness.jsonl` beside the keys) |
649
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 |
@@ -817,6 +817,12 @@ def cmd_audit(args) -> int:
817
817
  from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
818
818
  from gargoyle.execution import verify_chain
819
819
 
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
820
826
  out = _audit_trust_dir(args)
821
827
  if out is None:
822
828
  print("public-keys.json not found in %s%s: pass --out DIR, the folder that holds public-keys.json (and approvers.json), "
@@ -908,8 +914,9 @@ def cmd_audit(args) -> int:
908
914
  r = reconcile(entries, read_witness(args.witness), principal=principal, target_url=target)
909
915
  older = [f for f in r["findings"] if f["finding"] == "PREDATES_WITNESS"]
910
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"]
911
918
  for f in r["findings"]:
912
- if f["finding"] in ("PREDATES_WITNESS", "PREDATES_EVIDENCE"):
919
+ if f["finding"] in ("PREDATES_WITNESS", "PREDATES_EVIDENCE", "IN_FLIGHT"):
913
920
  continue
914
921
  print(" FINDING %-22s %s" % (f["finding"], " ".join("%s=%s" % (k, str(v)[:40]) for k, v in f.items() if k != "finding")))
915
922
  if older:
@@ -920,7 +927,11 @@ def cmd_audit(args) -> int:
920
927
  print(" note: %d receipt(s) the issuer issued to this principal predate this evidence log (first entry %s; %d of them held): "
921
928
  "a log that began later cannot be asked about them. If the log was not meant to start then, that is the finding."
922
929
  % (len(before_log), (r.get("log_start") or "?")[:19], held_n))
923
- hard = len(r["findings"]) - len(older) - len(before_log)
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)
924
935
  print("reconciled: %d issued by the issuer, %d in evidence, %d finding(s)%s"
925
936
  % (r["issued"], r["evidence_with_receipt"], hard,
926
937
  " (%d issued for other targets, not this gate's)" % r["issued_elsewhere"] if r.get("issued_elsewhere") else ""))
@@ -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,29 +127,46 @@ 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."""
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."""
135
141
  import contextlib
142
+ import time as _time
136
143
 
137
144
  @contextlib.contextmanager
138
145
  def hold():
139
146
  with open(lock_path, "a+b") as lock:
147
+ deadline = _time.monotonic() + wait_seconds
140
148
  try:
141
149
  import fcntl
142
- fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
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)
143
158
  unlock = lambda: fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
144
159
  except ImportError: # Windows
145
160
  import msvcrt
146
161
  lock.seek(0)
147
162
  while True:
148
163
  try:
149
- msvcrt.locking(lock.fileno(), msvcrt.LK_LOCK, 1) # retries about ten seconds, then raises
164
+ msvcrt.locking(lock.fileno(), msvcrt.LK_NBLCK, 1)
150
165
  break
151
166
  except OSError:
152
- continue
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)
153
170
  unlock = lambda: (lock.seek(0), msvcrt.locking(lock.fileno(), msvcrt.LK_UNLCK, 1))
154
171
  try:
155
172
  yield
@@ -64,6 +64,12 @@ def reconcile(evidence: Iterable[Any], witness: Iterable[Any], *, principal: Opt
64
64
  # a log missing its head is a chain failure, and what the head would
65
65
  # have held stays MISSING_EVIDENCE.
66
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
67
73
  for e in evidence:
68
74
  if not isinstance(e, dict):
69
75
  continue
@@ -115,13 +121,18 @@ def reconcile(evidence: Iterable[Any], witness: Iterable[Any], *, principal: Opt
115
121
  at_w = _parse(w.get("at"))
116
122
  # Every issuance precedes its own evidence entry by a little; only one
117
123
  # 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")
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"
120
130
  findings.append({"finding": kind, "decision_id": did, "tool": w.get("tool"), "principal": w.get("principal"),
121
131
  "held": bool(w.get("held")), "at": w.get("at")})
122
- hard = [f for f in findings if f["finding"] not in ("PREDATES_WITNESS", "PREDATES_EVIDENCE")]
132
+ hard = [f for f in findings if f["finding"] not in ("PREDATES_WITNESS", "PREDATES_EVIDENCE", "IN_FLIGHT")]
123
133
  return {"accepted": not hard, "findings": findings, "horizon": horizon.isoformat() if horizon else None,
124
134
  "log_start": log_start.isoformat() if log_start else None,
135
+ "log_end": log_end.isoformat() if log_end else None,
125
136
  "issued": len(issued), "evidence_with_receipt": len(seen_decisions), "issued_elsewhere": elsewhere}
126
137
 
127
138
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.21.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.21.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.** 564 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
@@ -682,6 +682,8 @@ keys (both at once when a pinned key is also set).
682
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 |
683
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` |
684
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 |
685
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 |
686
688
  | `GARGOYLE_WITNESS_PATH` | the issuer's signed, hash-chained record of every decision (`gargoyle issuer` sets `witness.jsonl` beside the keys) |
687
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.21.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"
@@ -53,7 +53,7 @@ class Http:
53
53
 
54
54
  ISSUER = "https://192.168.56.101:8443"
55
55
  HEALTH = {"ok": True, "policy": {"policy_id": "gargoyle.rules/1", "policy_sha256": "71145c21" + "0" * 56}, "entra": True,
56
- "persistent": True, "pending_receipts": 0, "enrolled_approvers": 1, "witness_head": {"seq": 533, "entry_sha256": "ab" * 32}}
56
+ "persistent": True, "pending_receipts": 0, "enrolled_approvers": 2, "witness_head": {"seq": 533, "entry_sha256": "ab" * 32}}
57
57
 
58
58
 
59
59
  def _env(tmp_path, **over):
@@ -131,10 +131,14 @@ def test_issuer_tls_failure_points_at_requests_ca_bundle(tmp_path):
131
131
 
132
132
 
133
133
  def test_issuer_health_detail_and_date(tmp_path):
134
+ http = Http({ISSUER + "/health": Resp(200, dict(HEALTH, enrolled_approvers=1), {"Date": "Sun, 01 Nov 2026 20:35:12 GMT"})})
135
+ r = check_issuer(_env(tmp_path), _ctx(tmp_path, http))
136
+ # One enrolled approver is a warning: one browser key between every hold and its release.
137
+ assert r["check"].status == WARN and "rules 71145c21" in r["check"].detail and "witness seq 533" in r["check"].detail
138
+ assert "second approver" in r["check"].fix and r["date"].startswith("Sun")
134
139
  http = Http({ISSUER + "/health": Resp(200, HEALTH, {"Date": "Sun, 01 Nov 2026 20:35:12 GMT"})})
135
140
  r = check_issuer(_env(tmp_path), _ctx(tmp_path, http))
136
- assert r["check"].status == PASS and "rules 71145c21" in r["check"].detail and "witness seq 533" in r["check"].detail
137
- assert r["date"].startswith("Sun")
141
+ assert r["check"].status == PASS and r["date"].startswith("Sun")
138
142
 
139
143
 
140
144
  def test_clock_skew_levels(tmp_path):
@@ -209,3 +209,25 @@ def test_entries_name_the_client_and_older_versions_still_verify(tmp_path):
209
209
  assert not _well_formed(v4_missing)
210
210
  with pytest.raises(ValueError):
211
211
  build_entry(seq=1, prev_sha256=GENESIS, kind=REFUSED, tool="t", args={}, principal=PRINCIPAL, reason="x", client=7)
212
+
213
+
214
+ def test_lock_wait_is_bounded(tmp_path):
215
+ # A holder that never lets go must not hang every later hook; the wait ends
216
+ # in an OSError the caller turns into a refusal.
217
+ import threading
218
+ from gargoyle.evidence import locked
219
+ lock_path = tmp_path / "audit.jsonl.lock"
220
+ held = threading.Event(); release = threading.Event()
221
+
222
+ def holder():
223
+ with locked(lock_path):
224
+ held.set(); release.wait(5)
225
+ t = threading.Thread(target=holder); t.start(); held.wait(5)
226
+ try:
227
+ with pytest.raises(OSError, match="not acquired"):
228
+ with locked(lock_path, wait_seconds=0.2):
229
+ pass
230
+ finally:
231
+ release.set(); t.join()
232
+ with locked(lock_path, wait_seconds=0.2):
233
+ pass
@@ -114,6 +114,15 @@ def test_honest_evidence_reconciles_and_tampering_is_found(tmp_path):
114
114
  page = c.get("/witness?limit=1", headers=approver_h).json()
115
115
  assert [e["seq"] for e in page["entries"]] == [4] and page["seq"] == 4
116
116
  assert c.get("/witness?after=4", headers=approver_h).json() == {"entries": [], "seq": 4}
117
+ # An issuance newer than the log's last entry is in flight (the audit's own
118
+ # gated command, a call still held): information, settled by a later audit.
119
+ late = dict(witness[1], decision_id="just-issued", at=(datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat())
120
+ r = reconcile(evidence, witness + [late], principal=PRINCIPAL)
121
+ assert r["accepted"] and [f["finding"] for f in r["findings"]] == ["IN_FLIGHT"] and r["log_end"]
122
+ # The same issuance older than the log's last entry is missing: its row had its chance.
123
+ early = dict(late, at=(datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat())
124
+ r = reconcile(evidence, witness + [early], principal=PRINCIPAL)
125
+ assert not r["accepted"] and [f["finding"] for f in r["findings"]] == ["MISSING_EVIDENCE"]
117
126
  # Evidence from before the witness began is information, not a finding.
118
127
  old = dict(evidence[0], at="2026-01-01T00:00:00+00:00", receipt=dict(evidence[0]["receipt"], decision_id="before-the-witness"))
119
128
  r = reconcile(evidence + [old], witness, principal=PRINCIPAL) # beside the real rows, not in place of one
@@ -139,7 +148,8 @@ def test_honest_evidence_reconciles_and_tampering_is_found(tmp_path):
139
148
  at=(datetime.fromisoformat(evidence[0]["at"]) - timedelta(days=2)).isoformat())
140
149
  r = reconcile(evidence, witness + [ancient], principal=PRINCIPAL)
141
150
  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())
151
+ # Inside the log's span (before its last entry) and unrecorded: missing, not in flight.
152
+ later = dict(ancient, decision_id="d-later", at=(datetime.fromisoformat(evidence[-1]["at"]) - timedelta(microseconds=1)).isoformat())
143
153
  r = reconcile(evidence, witness + [later], principal=PRINCIPAL)
144
154
  assert not r["accepted"] and [f["finding"] for f in r["findings"]] == ["MISSING_EVIDENCE"]
145
155
 
@@ -211,6 +221,9 @@ def test_audit_names_a_missing_witness_or_folder_plainly(tmp_path, capsys):
211
221
  assert "pass --log" in capsys.readouterr().err
212
222
  assert main(["audit", "--out", str(out), "--check-tree", str(tmp_path)]) == 2
213
223
  assert "pass --log" in capsys.readouterr().err
224
+ # A log that is not there (rotated, or never written) is named, not a traceback.
225
+ assert main(["audit", "--log", str(tmp_path / "gone.jsonl"), "--out", str(out)]) == 2
226
+ assert "evidence log not found" in capsys.readouterr().err
214
227
  assert main(["audit", "--out", str(out)]) == 2
215
228
  assert "nothing to verify" in capsys.readouterr().err
216
229
  # The issuer creates the witness file at start, before any decision.