gargoyle-receipts 0.18.0.dev0__tar.gz → 0.20.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 (116) hide show
  1. gargoyle_receipts-0.20.0.dev0/PKG-INFO +765 -0
  2. gargoyle_receipts-0.20.0.dev0/README.md +727 -0
  3. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/approval.py +65 -2
  4. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/approver_cli.py +8 -2
  5. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/approvers.py +21 -10
  6. gargoyle_receipts-0.20.0.dev0/gargoyle/claude_hook.py +309 -0
  7. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/cli.py +399 -25
  8. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/consumer.py +3 -1
  9. gargoyle_receipts-0.20.0.dev0/gargoyle/doctor.py +500 -0
  10. gargoyle_receipts-0.20.0.dev0/gargoyle/emilia.py +438 -0
  11. gargoyle_receipts-0.20.0.dev0/gargoyle/emilia_runner.py +137 -0
  12. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/envelope.py +16 -6
  13. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/execution.py +2 -1
  14. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/fs_server.py +14 -2
  15. gargoyle_receipts-0.20.0.dev0/gargoyle/globs.py +112 -0
  16. gargoyle_receipts-0.20.0.dev0/gargoyle/holdtext.py +155 -0
  17. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/issuer_service.py +244 -28
  18. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/mcp_server.py +193 -33
  19. gargoyle_receipts-0.20.0.dev0/gargoyle/oauth_broker.py +506 -0
  20. gargoyle_receipts-0.20.0.dev0/gargoyle/operations.py +313 -0
  21. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/plans.py +19 -3
  22. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/policy.py +9 -3
  23. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/reconcile.py +11 -3
  24. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/remote.py +77 -21
  25. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/revert.py +57 -12
  26. gargoyle_receipts-0.20.0.dev0/gargoyle/rulecheck.py +280 -0
  27. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/rules.py +19 -5
  28. gargoyle_receipts-0.20.0.dev0/gargoyle/sidedoors.py +480 -0
  29. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/state.py +98 -2
  30. gargoyle_receipts-0.20.0.dev0/gargoyle/static/approve.html +652 -0
  31. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/static/approve.js +46 -0
  32. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/witness.py +51 -0
  33. gargoyle_receipts-0.20.0.dev0/gargoyle_receipts.egg-info/PKG-INFO +765 -0
  34. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +21 -0
  35. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/pyproject.toml +1 -1
  36. gargoyle_receipts-0.20.0.dev0/tests/test_approval_views.py +308 -0
  37. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_approvers.py +42 -1
  38. gargoyle_receipts-0.20.0.dev0/tests/test_claude_hook.py +179 -0
  39. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_cli.py +30 -0
  40. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_console_js.py +32 -0
  41. gargoyle_receipts-0.20.0.dev0/tests/test_declines.py +112 -0
  42. gargoyle_receipts-0.20.0.dev0/tests/test_doctor.py +313 -0
  43. gargoyle_receipts-0.20.0.dev0/tests/test_emilia_interop.py +309 -0
  44. gargoyle_receipts-0.20.0.dev0/tests/test_emilia_runner.py +55 -0
  45. gargoyle_receipts-0.20.0.dev0/tests/test_env_file.py +24 -0
  46. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_fs_server.py +39 -2
  47. gargoyle_receipts-0.20.0.dev0/tests/test_hardening_2026_11_01.py +161 -0
  48. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_issuer_service.py +10 -0
  49. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_mcp_self_issue.py +3 -2
  50. gargoyle_receipts-0.20.0.dev0/tests/test_oauth_broker.py +305 -0
  51. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_operability.py +1 -1
  52. gargoyle_receipts-0.20.0.dev0/tests/test_operations.py +152 -0
  53. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_record_follow.py +2 -2
  54. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_revert.py +2 -2
  55. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_review_fixes.py +8 -8
  56. gargoyle_receipts-0.20.0.dev0/tests/test_rulecheck.py +141 -0
  57. gargoyle_receipts-0.20.0.dev0/tests/test_sidedoors.py +255 -0
  58. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_state.py +26 -0
  59. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_threshold.py +2 -2
  60. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_witness.py +60 -0
  61. gargoyle_receipts-0.18.0.dev0/PKG-INFO +0 -490
  62. gargoyle_receipts-0.18.0.dev0/README.md +0 -452
  63. gargoyle_receipts-0.18.0.dev0/gargoyle/globs.py +0 -58
  64. gargoyle_receipts-0.18.0.dev0/gargoyle/static/approve.html +0 -304
  65. gargoyle_receipts-0.18.0.dev0/gargoyle_receipts.egg-info/PKG-INFO +0 -490
  66. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/LICENSE +0 -0
  67. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/NOTICE +0 -0
  68. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/__init__.py +0 -0
  69. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/__main__.py +0 -0
  70. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/adapters.py +0 -0
  71. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/approver_trust.py +0 -0
  72. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/entra_login.py +0 -0
  73. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/evidence.py +0 -0
  74. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/holdstore.py +0 -0
  75. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/identity.py +0 -0
  76. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/issuer.py +0 -0
  77. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/jws.py +0 -0
  78. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/key_record_source.py +0 -0
  79. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/notify.py +0 -0
  80. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/opa.py +0 -0
  81. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/pending_store.py +0 -0
  82. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/published_record_source.py +0 -0
  83. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/replay_cache.py +0 -0
  84. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
  85. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle/verifier.py +0 -0
  86. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  87. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  88. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  89. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  90. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/setup.cfg +0 -0
  91. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_annotations.py +0 -0
  92. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_approver_cli.py +0 -0
  93. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_canonical.py +0 -0
  94. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_consumer.py +0 -0
  95. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_entra_identity.py +0 -0
  96. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_entra_login.py +0 -0
  97. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_evidence.py +0 -0
  98. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_execution.py +0 -0
  99. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_globs.py +0 -0
  100. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_graph.py +0 -0
  101. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_historical.py +0 -0
  102. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_identity.py +0 -0
  103. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_jws.py +0 -0
  104. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_mcp_proxy.py +0 -0
  105. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_mcp_server.py +0 -0
  106. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_notify.py +0 -0
  107. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_opa.py +0 -0
  108. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_plans.py +0 -0
  109. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_policy.py +0 -0
  110. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_published_record.py +0 -0
  111. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_remote.py +0 -0
  112. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_rules.py +0 -0
  113. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_state_text.py +0 -0
  114. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_stdio_encoding.py +0 -0
  115. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_typescript_conformance.py +0 -0
  116. {gargoyle_receipts-0.18.0.dev0 → gargoyle_receipts-0.20.0.dev0}/tests/test_wire_spec.py +0 -0
@@ -0,0 +1,765 @@
1
+ Metadata-Version: 2.4
2
+ Name: gargoyle-receipts
3
+ Version: 0.20.0.dev0
4
+ Summary: Signed, single-use, argument-bound receipts and human-approval holds for agent tool calls.
5
+ Author: Justin LaPorte
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/Elyon-Sol/Gargoyle
8
+ Project-URL: Source, https://github.com/Elyon-Sol/Gargoyle
9
+ Project-URL: Specification, https://github.com/Elyon-Sol/Gargoyle/blob/main/SPEC.md
10
+ Project-URL: Issues, https://github.com/Elyon-Sol/Gargoyle/issues
11
+ Keywords: agents,mcp,authorization,receipts,ed25519,human-in-the-loop
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Security
20
+ Classifier: Topic :: Security :: Cryptography
21
+ Requires-Python: >=3.12
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ License-File: NOTICE
25
+ Requires-Dist: cryptography>=44.0.0
26
+ Requires-Dist: fastapi>=0.115
27
+ Requires-Dist: uvicorn>=0.30
28
+ Requires-Dist: requests>=2.32.3
29
+ Provides-Extra: redis
30
+ Requires-Dist: redis; extra == "redis"
31
+ Provides-Extra: entra
32
+ Requires-Dist: msal>=1.28; extra == "entra"
33
+ Provides-Extra: dev
34
+ Requires-Dist: httpx>=0.27; extra == "dev"
35
+ Requires-Dist: pytest>=8; extra == "dev"
36
+ Requires-Dist: redis; extra == "dev"
37
+ Dynamic: license-file
38
+
39
+ # Gargoyle
40
+
41
+ Signed, single-use, argument-bound receipts and human-approval holds for
42
+ agent tool calls.
43
+
44
+ [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
45
+ [![CI](https://github.com/Elyon-Sol/Gargoyle/actions/workflows/ci.yml/badge.svg)](https://github.com/Elyon-Sol/Gargoyle/actions/workflows/ci.yml)
46
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
47
+
48
+ An agent asks for permission to run a tool. Something you already trust
49
+ decides (OPA, Cedar, a JSON rules file, your own code). Gargoyle turns that
50
+ decision into a **receipt**: an Ed25519-signed record that names the tool,
51
+ a hash of its arguments, the principal the decision was made for, the
52
+ target it may be presented to, and the policy that decided. The target
53
+ checks the receipt before it acts, and honors it once. When the policy says
54
+ a call needs a human, the receipt is held until an approver signs a
55
+ **grant** for exactly that decision with a key the issuing service does not
56
+ have. After the call runs, the executor signs an **execution receipt** with
57
+ the state before and after. The three verify together, offline, with three
58
+ public keys.
59
+
60
+ Gargoyle does not decide whether a call is allowed. It makes an allow
61
+ decision portable, verifiable, bound to one call by one caller, and
62
+ spendable once, and it keeps a record of what was asked, what was released,
63
+ by whom, and what changed.
64
+
65
+ ## Status
66
+
67
+ Pre-release `0.20.0.dev0` on PyPI as `gargoyle-receipts`. Wire format
68
+ `gargoyle/3`, evidence log `gargoyle-evidence/4`, witness
69
+ `gargoyle-witness/1`; all three are specified in [SPEC.md](SPEC.md) and may
70
+ still change before a first release.
71
+
72
+ **Run live, on the author's own tenant and hosts (2026-10-16 to 2026-11-01).**
73
+ One issuer host on Ubuntu behind Apache with TLS, Claude Code clients on
74
+ Windows 11 and on Linux, Claude Desktop on Windows 11, Entra ID for every
75
+ identity.
76
+ Exercised end to end and recorded in the witness: the receipt, grant and
77
+ execution chain with offline verification; state-bound approval and undo;
78
+ witness reconciliation and the tree check; Entra sign-in for submitters and
79
+ for approvers with browser-held, non-exportable approver keys, including a
80
+ lost key revoked and a new one enrolled; a hold routed to an Entra app role
81
+ and released under it; the Claude Code hook over Bash, Edit and Write;
82
+ discovery mode naming the side doors around the gate; declined holds with
83
+ the reason relayed to the assistant and a cooldown on retries; Windows
84
+ file-name canonicalization refusing names the OS would rewrite; and on the
85
+ issuer host, `gargoyle doctor`, `gargoyle rules` and `gargoyle backup` with
86
+ a verified `restore`.
87
+
88
+ **Built and tested, not yet run against a live counterpart.** Two-signature
89
+ (m-of-n) release with a deputy role; the Microsoft Graph adapter (tested
90
+ against a fake Graph server); the OAuth broker for hosted connectors
91
+ (deployed, metadata and redirect checks verified, no human sign-in through a
92
+ connector yet).
93
+
94
+ **Assurance.** 561 Python tests on Linux and Windows across Python 3.12,
95
+ 3.13 and 3.14, at dependency floors and at a lock file; a TypeScript suite
96
+ that recomputes the conformance vectors byte for byte; two outside,
97
+ model-driven security reviews on 2026-10-20 (ten findings, all fixed); the
98
+ EMILIA Protocol's own verifiers accepting a live Gargoyle export unmodified,
99
+ and EMILIA's repository carrying the Gargoyle interop pack. No human has
100
+ reviewed the code independently of the model that wrote it. See
101
+ [SECURITY.md](SECURITY.md) and [ROADMAP.md](ROADMAP.md).
102
+
103
+ ## How this was built
104
+
105
+ Gargoyle is designed and operated by one person with an operations and
106
+ security background and no software engineering background. The code,
107
+ tests and most of the prose were written by an AI model (Anthropic's
108
+ Claude) working under that person's direction: the design decisions, the
109
+ threat judgments, the deployments and the choice of what to build, remove
110
+ or publish are the author's; the implementation is the model's.
111
+
112
+ Read that as a limit on assurance. Nobody who can read this code deeply
113
+ has reviewed it independently of the model that wrote it. What stands
114
+ outside that loop is listed under Status above; the test suite and CI,
115
+ which the model also wrote, count for less. SECURITY.md says so and will
116
+ until it is false. If you are that reviewer, open an issue.
117
+
118
+ ## What a receipt gives you
119
+
120
+ - **Binding.** A receipt for `transfer_funds` with `{"amount": 250}` is
121
+ refused for `{"amount": 250000}`, for `delete_database`, and at any
122
+ target but the one it names.
123
+ - **A principal.** The receipt carries the identity the issuer
124
+ authenticated; the target compares it with the identity it authenticated.
125
+ A receipt obtained by one agent cannot be spent by another. When the agent
126
+ acts for a user, the receipt records that too (`on_behalf_of`).
127
+ - **Standard encodings.** A receipt can travel as a JWS (EdDSA) that any
128
+ JWT library verifies, and the issuer can take the caller's identity from
129
+ an OAuth bearer token, an RFC 8693 delegation token or a Transaction
130
+ Token, signed EdDSA, RS256 or ES256 by keys from the provider's JWKS.
131
+ With Entra ID the principal is the user's UPN and the token's groups and
132
+ app roles become policy inputs, so a rules file can say "finance may hold
133
+ invoice updates; shipping may not" without naming people.
134
+ - **Single use.** Every receipt carries a signed `decision_id`; an executor
135
+ claims it exactly once. A replayed receipt is refused.
136
+ - **Expiry.** Every receipt carries a signed `not_after` (default 300 s). A
137
+ captured receipt, or a leaked issuer key, is useful for minutes, not
138
+ forever.
139
+ - **Currency.** The receipt pins the policy that decided. If the policy
140
+ changes, outstanding receipts stop being honored and the call must be
141
+ decided again.
142
+ - **Holds.** A decision that requires approval is flagged inside the signed
143
+ receipt. The target will not run it without a grant a human signed for
144
+ that exact decision, under their own key, on their own device.
145
+ - **State binding.** A held write is bound to the target's current state.
146
+ The approver sees the change as a diff against that state; the call is
147
+ refused if the target moved before it ran; the execution receipt records
148
+ the state before and after; an approved change can be undone through the
149
+ gate the same way.
150
+ - **Proof of execution.** An executor with a key answers each honored call
151
+ with a signed execution receipt: the decision it fulfilled, the policy
152
+ version that allowed it, and a hash of the outputs. Receipt, grant and
153
+ execution receipt verify together, offline, with three public keys.
154
+
155
+ That is the sentence Gargoyle can say: a specific policy version
156
+ authorized a specific call for a specific principal, a human's key
157
+ released it against a known state, and exactly that call ran once,
158
+ verifiable offline by anyone.
159
+
160
+ ## Quick start: the gate in front of a filesystem MCP server
161
+
162
+ pip install gargoyle-receipts
163
+
164
+ Three commands, no policy engine, no containers, no client changes:
165
+
166
+ gargoyle init /path/the/agent/may/touch # keys, config, rules.json
167
+ gargoyle issuer # the issuing service, on rules.json
168
+ gargoyle approve --watch 2 # where the approver sits (terminal)
169
+
170
+ Then paste the server entry from `gargoyle-deploy/mcp-config.json` into
171
+ your MCP client. Reads are free, writes block until the approver's key
172
+ signs, deletes are refused; the policy is a JSON rules file you edit.
173
+ `gargoyle init --observe` starts a deployment in record-only mode instead:
174
+ every call allowed and recorded, nothing held, so a week of
175
+ `gargoyle audit --summary` is the input for the hold and deny rules.
176
+
177
+ Or approve in a browser: open the issuer's `/approve` page and load
178
+ `approver-web.json`, or, with Entra configured, sign in with Microsoft. The
179
+ console is described below.
180
+
181
+ [docs/guide-hyperv-ubuntu-apache.md](docs/guide-hyperv-ubuntu-apache.md)
182
+ is the full single-instance build: Ubuntu VM, Apache with TLS, a Windows 11
183
+ client running Claude Code. [docs/guide-production-entra.md](docs/guide-production-entra.md)
184
+ is the same for a Microsoft 365 tenant whose users are on Claude Desktop or
185
+ claude.ai. [examples/filesystem-agent](examples/filesystem-agent/) walks
186
+ through the gate with OPA in Docker for sites that run a policy engine.
187
+
188
+ ### The `gargoyle` command
189
+
190
+ | Command | What it does | Where it runs |
191
+ |---|---|---|
192
+ | `gargoyle init DIR [--observe] [--approvers N] [--client claude-code]` | keys, tokens, rules file, service unit, Apache site, MCP entry | once, anywhere |
193
+ | `gargoyle issuer` | the issuing service: receipts, holds, grants, witness, console | the issuer host |
194
+ | `gargoyle approve --watch 2` | review and release held calls in a terminal | an approver's machine |
195
+ | `gargoyle login --tenant T --client-id C --scope S` | sign a workstation's user in with Entra ID; receipts then name them | each workstation |
196
+ | `gargoyle tokens --out DIR` | new client and approver tokens from the same keys | the issuer host |
197
+ | `gargoyle approvers list` / `revoke UPN` | the enrolled approver registry; a revoke is picked up without a restart | the issuer host |
198
+ | `gargoyle witness [--follow]` | the issuer's witness as a table | the issuer host |
199
+ | `gargoyle doctor --env middleware.env` | preflight a workstation's gate, one PASS/WARN/FAIL line per concern | each workstation |
200
+ | `gargoyle rules test` / `explain` / `lint` | what a rules file would do, before it is the policy | anywhere |
201
+ | `gargoyle sidedoors --env middleware.env [--report]` | list every other way into the gated files this host shows | each workstation |
202
+ | `gargoyle backup DIR` / `restore ARCHIVE` | the issuer host's state as one verified archive, and back | the issuer host |
203
+ | `gargoyle audit --log audit.jsonl [--witness witness.jsonl] [--check-tree DIR] [--summary]` | verify the evidence log, reconcile it against the witness, find files changed around the gate | anywhere, offline |
204
+ | `gargoyle emilia --log audit.jsonl` | export approved calls as EMILIA Trust Receipts | anywhere, offline |
205
+ | `gargoyle remote` / `remote-token` | the gate as an HTTPS MCP server for hosted connectors, and a person's capability URL for it | the gate host |
206
+ | `gargoyle hook --env middleware.env` | the Claude Code hook command (PreToolUse, PostToolUse) | each workstation, from settings |
207
+
208
+ ## The approval console
209
+
210
+ The issuer serves a browser console at `/approve`. It shows each held call
211
+ with its full arguments, checked against the receipt, and signs the grant
212
+ locally with WebCrypto. Below the holds it shows the witness as
213
+ **Activity** (who asked, which tool, what the policy did), which is the
214
+ whole picture during an observe-mode week; every row opens its witness
215
+ entry.
216
+
217
+ **Keys and sign-in.** The approver's key never leaves the browser. With
218
+ Entra it is generated there, non-exportable, and enrolled under the
219
+ approver's UPN, so grants and audits name the person. One key per account:
220
+ a second browser does not enroll until the first key consents or the
221
+ operator revokes it. Every key an approver has held stays in
222
+ `approvers.json` (`history`), so an audit of grants released under an
223
+ earlier key still verifies; copy the issuer's current file beside the
224
+ evidence before auditing.
225
+
226
+ **Revocation.** An approver retires a lost or replaced key with
227
+ `POST /approvers/revoke` from their own sign-in; the operator revokes
228
+ anyone with `gargoyle approvers revoke <upn>` on the issuer's host, and the
229
+ running service picks the change up without a restart. Revoked keys stay
230
+ on file so earlier grants still audit; new grants under them are refused
231
+ at the issuer and at every middleware on its next held call. Revocation
232
+ retires a key, not a person: while the Approver role stands, the person can
233
+ sign in and enroll a fresh key, so remove the role too. The role is what
234
+ counts at release time: a grant under an enrolled key is accepted only from
235
+ that account, under a token that carries the role now, so a role removed in
236
+ the directory ends the power at once.
237
+
238
+ **Who sees what.** Approvers, and only approvers, see the whole queue and
239
+ the witness; everyone else sees their own holds.
240
+
241
+ **More than one signature.** `"approvals": 2` on a rule, with an optional
242
+ `"approvers": [...]` naming which keys count, puts the terms in the receipt
243
+ itself, and `gargoyle init --approvers 2` creates the keys.
244
+
245
+ **Routing by role.** `"approver_roles": ["Approver.Business"]` on a rule
246
+ means only a sign-in whose Entra app roles include that one may release
247
+ the hold; the issuer attests the role on the grant and the executor refuses
248
+ a grant without the attestation. Routing is what keeps approvals rare for
249
+ the person who should see few.
250
+
251
+ **Three views of one hold.** The console renders every hold three ways for
252
+ the same signature. The technical view shows tool, raw arguments, hashes,
253
+ pin and the diff. The deputy and business views lead with the issuer's
254
+ one-line description of the change (`Replace the contents of plan.md in
255
+ /srv/clients/acme (412 bytes -> 530 bytes; +4/-1 lines)`), the change as
256
+ before and after lines, who asked and how long is left, with the technical
257
+ details one click away. The sentence is computed in the issuer from the
258
+ held arguments and the bound state (`gargoyle.holdtext`), never authored;
259
+ a hold no template can describe is offered to the business view read-only.
260
+ The view follows the sign-in's role (`Approver.Deputy`, `Approver.Business`;
261
+ plain `Approver` and key files get the technical view) and can be switched.
262
+ The business view hides holds routed to other roles and the activity
263
+ stream; its status line says how many holds the person released this week
264
+ and which tool most of them were, the prompt to add a rule or a scoped
265
+ grant instead. The rendering never changes what is signed.
266
+
267
+ **Notice.** A hold can be announced to Slack or any webhook
268
+ (`GARGOYLE_HOLD_WEBHOOK_URL`); the notice carries no key, so nothing in a
269
+ chat channel can approve anything.
270
+
271
+ ## Claude Code's own tools: the hook
272
+
273
+ Claude Code's Bash, Edit, Write and the MCP tools it calls directly do not
274
+ pass through an MCP server the gate can front. They do pass through Claude
275
+ Code's hooks, and `gargoyle hook` is one:
276
+
277
+ gargoyle init /path/the/agent/works/in --client claude-code # rules for Write/Edit/Bash, claude-settings.json
278
+ gargoyle hook --env middleware.env [--final] # the hook command (PreToolUse, PostToolUse)
279
+
280
+ Same issuer, receipts, holds, approvers and evidence log as the MCP gate;
281
+ the approver sees the shell command or the file content, a held Write is
282
+ bound to the file's state, the refusal reaches Claude by name. Deploy the
283
+ hook from managed settings for a fleet. It covers Claude Code only, and it
284
+ is evidence rather than containment where the user owns the workstation: a
285
+ line typed with the `!` prefix runs in the user's shell with no hook at all
286
+ (verified 2026-10-31), and no setting changes that.
287
+ [docs/claude-code-hook.md](docs/claude-code-hook.md) has the setup and the
288
+ limits.
289
+
290
+ ## Rules before they are the policy: `gargoyle rules`
291
+
292
+ First match wins, so a rule one line too late never fires; a glob written
293
+ for `path` never sees `move_file`, which sends `source` and `destination`;
294
+ a role name typed once wrong routes a hold to nobody. Three commands answer
295
+ such questions from the file alone, with the issuer's own `decide_rules`:
296
+
297
+ gargoyle rules explain --rules rules.json --tool move_file --arg destination=/x/run.ps1 # every rule, matched or why not
298
+ gargoyle rules test --rules rules.json --calls calls.jsonl --roles Approver,Approver.Business # one row per call vs `expect`, then lint
299
+ gargoyle rules lint --rules rules.json # UNREACHABLE, ARG_KEY_UNKNOWN, ROLE_UNKNOWN
300
+
301
+ `examples/rules/` holds a Windows observe-mode pilot's rules and the
302
+ fourteen calls it decided live, as a `calls.jsonl` the suite runs. The
303
+ matching model is one page: [docs/rules.md](docs/rules.md).
304
+
305
+ ## Preflight: `gargoyle doctor`
306
+
307
+ A gate that fails closed looks broken to the person installing it. The
308
+ first tenant's install lost an afternoon to four things, none of them in
309
+ the gate: the submitter's Entra token could not be refreshed (the API's
310
+ enterprise application had "Assignment required" on), the console's
311
+ browser key was no longer the one enrolled, Claude Code's own auto-mode
312
+ classifier refused the gate's write before the gate saw it, and the
313
+ workstation's endpoint protection flagged the signed Claude Code binary.
314
+ The doctor reads the same `middleware.env` the gate reads and says, per
315
+ concern, PASS, WARN or FAIL with the one fix that applies:
316
+
317
+ gargoyle doctor --env C:\gargoyle\middleware.env # Windows workstation (the installer runs it last)
318
+ gargoyle doctor --env ~/.gargoyle/hook/middleware.env # a hook host
319
+ gargoyle doctor --env middleware.env --json # for a script
320
+
321
+ Checks, in the order a person debugs: the env file is complete and
322
+ private; the CA bundle names the issuer's host and has not expired; the
323
+ issuer answers `/health` through Python's trust; the clock is within a
324
+ minute of the issuer's; the rules pin the gate will enforce is the one the
325
+ issuer runs; the submitter gets a token silently, now, as the named user
326
+ (an `AADSTS50105` is translated into the Entra setting that causes it); the
327
+ state root exists and does not hold the client's own state; the evidence
328
+ log and snapshot folder are writable and discovery is not overdue; Claude
329
+ Code's layers are right (side doors denied by managed settings or hooked,
330
+ the gate's MCP tools allowed by rule, the hook registered with this env
331
+ file); and Node, where present, trusts the issuer too. It ends with the
332
+ note the installer needs before the EDR alert arrives. Exit 0 when nothing
333
+ fails. It changes nothing and sends nothing but two GETs and one silent
334
+ token refresh.
335
+
336
+ ## Side doors: discovery mode
337
+
338
+ A gate stands in front of one tool server. The files behind it can still be
339
+ reached around it: a second MCP server in a client's config serving the same
340
+ folder without the gate, Claude Code's own Write and Bash with no hook, a
341
+ project `.mcp.json` anyone who writes the folder can extend, a managed MCP
342
+ file that is not exclusive, a share that exports the folder, a
343
+ world-writable folder, a symlink that lands outside it, a gated file
344
+ changed with no evidence entry, a script written through the gate that a
345
+ person then runs outside it, Claude Code's `!` prefix, and a client's own
346
+ state folder (`.claude/`) kept inside the gated root. None of these is an
347
+ attack; all of them are what an audit of the evidence log cannot see. The
348
+ gate looks for them itself (`gargoyle.sidedoors`):
349
+
350
+ gargoyle sidedoors --env middleware.env # list them now; exit 1 when any are found
351
+ gargoyle sidedoors --env middleware.env --report # also write audit.sidedoors.json and tell the issuer
352
+
353
+ The MCP gate takes the same look at startup and the hook on its next run,
354
+ whenever the last report beside the evidence log is older than
355
+ `GARGOYLE_MCP_DISCOVERY_HOURS` (default 24; `0` disables). Findings go
356
+ beside the evidence log with the time each was first seen, and to the
357
+ issuer (`POST /sidedoors`), whose witness records them, so they show in the
358
+ console's Activity and in `gargoyle audit --summary`. Each finding names a
359
+ remedy. Discovery is notice, never admission: nothing is held or refused on
360
+ it.
361
+
362
+ ## Evidence, witness and audit
363
+
364
+ The MCP middleware and the hook keep an evidence log: one hash-chained line
365
+ per decision, refusals and declined holds included, so the trail answers
366
+ "what did the agent try" as well as "what did it do". Every entry names the
367
+ MCP client that asked, from its initialize handshake, so a fleet with
368
+ Claude on some desks and another client on others still has one trail. The
369
+ replay claims that make a receipt single-use live beside the log in SQLite,
370
+ so a restarted gate still refuses a receipt it honored.
371
+
372
+ The evidence is the workstation's own statement, and the workstation
373
+ belongs to its user. So the issuer keeps a witness: a signed, hash-chained
374
+ record of every receipt it issued, every grant it accepted, every decline,
375
+ enrollment, revocation and discovery report, on the operator's server.
376
+
377
+ gargoyle audit --log audit.jsonl # verify every entry and the chain; prints the tail hash to note down
378
+ gargoyle audit --log audit.jsonl --witness witness.jsonl # reconcile by decision id: unwitnessed receipts, releases without a grant, missing evidence
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
+ gargoyle audit --log audit.jsonl --summary # calls by principal and tool, side-door findings
381
+
382
+ On a machine the user administers the gate constrains the assistant, not
383
+ the user; these checks make the difference visible. SPEC.md section 15
384
+ specifies the log, the witness and every reconciliation finding.
385
+
386
+ A held call is approved against the state it was shown in. Before asking
387
+ for the receipt the middleware hashes the target (a file's bytes, or its
388
+ absence); the issuer binds that hash into the signed receipt; the console
389
+ shows the proposed content as a diff against that state; after the human
390
+ signs, the middleware hashes the target again and refuses if it moved; and
391
+ the execution receipt carries the state before and after. An approved
392
+ change can be undone the same way: the middleware keeps what a held write
393
+ replaced and offers `gargoyle_revert`, which composes the restoring write
394
+ and sends it through the gate, held, shown as a diff, bound to the current
395
+ state, and refused if anything changed since. The audit marks a revert
396
+ from the state hashes alone.
397
+
398
+ ## Backup and restore
399
+
400
+ Everything on the issuer host that cannot be regenerated (the hold store,
401
+ the witness, the keys, the rules, the enrolled approvers, the remote
402
+ gates' state) goes into one archive named with the witness head:
403
+
404
+ gargoyle backup /var/backups/gargoyle # on the issuer host, as root
405
+ gargoyle restore --verify-only ARCHIVE # check it against the running state; writes nothing
406
+ gargoyle restore ARCHIVE --opt /opt/gargoyle # put it back; services stopped first
407
+
408
+ A backup refuses to run if it cannot read every secret it should contain,
409
+ and writes nothing. `restore --verify-only` verifies the witness chain and
410
+ the hold store's integrity inside the archive. [docs/operations.md](docs/operations.md) says what the archive holds,
411
+ how to schedule it, and what a restart does to work in flight.
412
+
413
+ ## Scoped grants
414
+
415
+ One card per held call is right for a mailbox rule and wrong for a long
416
+ task. The assistant can ask for a plan (`gargoyle_plan`): a list of steps,
417
+ each a tool with exact arguments or argument patterns, and a note saying
418
+ why. The issuer judges every step under the policy, signs the plan, and
419
+ holds it as one card; the approver signs once; each later call matching an
420
+ unused step runs without a card of its own until the plan expires. Every
421
+ step is still bound, claimed once, and recorded with the plan and step that
422
+ released it. The rules file decides what may be planned (`"plan": false`
423
+ keeps a tool individual; sends should be), because a plan approves scope
424
+ rather than content.
425
+
426
+ ## Microsoft 365 and other targets: adapters
427
+
428
+ What a held call changes is the adapter's business (`gargoyle.adapters`):
429
+ it describes the target's state before and after, tells the approver what
430
+ the call means in plain words, and composes the call that undoes it. Files
431
+ on the executor's host are the default. `GARGOYLE_MCP_STATE_PROBE=graph`
432
+ selects the Graph adapter for a Microsoft Graph MCP server: inbox rules,
433
+ calendar events, contacts and Teams membership are bound to their state,
434
+ explained on the card ("creates inbox rule 'Fwd invoices' (to an outside
435
+ address: x@evil.example)"), undone by the inverse Graph call, and a send is
436
+ marked as something that cannot be undone. The adapter reads state through
437
+ the upstream server itself, so the gate needs no Graph credential of its
438
+ own. Which tool is which is a JSON spec; the reference naming is in
439
+ `gargoyle/specs/graph-reference.json`, and a different server's names are
440
+ mapped there, not in code. The Graph adapter is tested against a fake
441
+ Graph server and has not yet run against a tenant's mailbox.
442
+
443
+ ## Remote mode
444
+
445
+ Claude's hosted connectors (claude.ai in the browser, Claude Desktop's
446
+ custom connectors) and Microsoft 365 servers are reached over HTTPS, not
447
+ through a local process. `gargoyle remote` runs the same gate as a
448
+ Streamable HTTP MCP server in front of a stdio command or a remote MCP
449
+ server (`--upstream-url`), so those paths go through it too:
450
+
451
+ gargoyle remote-token --out gargoyle-deploy --principal dana@contoso.com --url https://gargoyle.lan:8444
452
+ gargoyle remote --out gargoyle-deploy --port 8444 --ssl-certfile ... --ssl-keyfile ... -- python -m gargoyle.fs_server /srv/shared
453
+
454
+ With Entra, the connector signs the user in: set `GARGOYLE_OAUTH_BASE_URL`
455
+ (and the gate's own app registration, `GARGOYLE_OAUTH_ENTRA_CLIENT_ID` /
456
+ `_CLIENT_SECRET`) and the gate is the OAuth authorization server the hosted
457
+ client expects (metadata, dynamic registration, code flow with PKCE) while
458
+ the sign-in itself happens at the tenant, under its MFA and Conditional
459
+ Access. The gate then presents the user's own Entra token to the issuer,
460
+ refreshed silently, and the receipt's principal is their UPN; the session
461
+ ends when the tenant refuses the refresh (`gargoyle.oauth_broker`,
462
+ [docs/guide-production-entra.md](docs/guide-production-entra.md)). Without
463
+ a directory, each person gets a capability URL (`/mcp/<token>`) from
464
+ `gargoyle remote-token` that names them on every receipt; the operator
465
+ vouches for that mapping, as with an API key, so treat the URL as a
466
+ credential. All sessions share one upstream, one replay cache, one evidence
467
+ log and one snapshot store, so single use and undo hold across users.
468
+ Device-bound identity remains the stdio mode's property.
469
+
470
+ ## Where it sits
471
+
472
+ MCP gateways hold a tool call, notify a reviewer and log the click.
473
+ Gargoyle is not a gateway. It runs beside the client, and its approval is a
474
+ signature made with a key the issuer never holds, checked at execution and
475
+ again by anyone later. In 2026 several Internet-Drafts describe parts of
476
+ the same shape: Acta signed receipts (an access decision, signed), EP
477
+ authorization receipts (a human's pre-execution signoff), XAIP receipts (a
478
+ signed execution record). Gargoyle chains all three on one decision id with
479
+ the policy version inside, uses the same RFC 8785 canonical form they do,
480
+ and SPEC.md section 16 maps every field.
481
+ [docs/comparison-2026-10-20.md](docs/comparison-2026-10-20.md) scores
482
+ Gargoyle against its nearest peers from their primary sources;
483
+ [docs/interop-emilia.md](docs/interop-emilia.md) records the EMILIA
484
+ interoperation, in both directions.
485
+
486
+ ## Using the library
487
+
488
+ ### Issuing a receipt
489
+
490
+ The policy is anything with `pin()` and `decide()`; see `gargoyle.policy`.
491
+ The interaction you pass carries the call (`tool`, `args`, `principal`) and
492
+ whatever claims your policy reads. The receipt records only the call and
493
+ the policy pin.
494
+
495
+ ```python
496
+ from gargoyle.issuer import ENVELOPE_HEADER, issue
497
+
498
+ who = principal_from_token(bearer_or_transaction_token, IDP_KEYS, audience="issuer") # gargoyle.identity
499
+ # IDP_KEYS: pinned, or entra_keys("<tenant>") / keys_from_jwks(<provider JWKS>)
500
+ interaction = {
501
+ "tool": "transfer_funds",
502
+ "args": {"amount": 250, "to": "acct-9"},
503
+ "principal": who["principal"], # the agent, as the identity provider authenticated it
504
+ "on_behalf_of": who["on_behalf_of"], # the user it acts for, when the token says so
505
+ **claims, # whatever your policy decides on
506
+ }
507
+ result = issue(policy, "https://tools.example.com/target", interaction,
508
+ signing_key=issuer_private_key, key_id="issuer-2026-10")
509
+
510
+ if result.envelope is None:
511
+ refuse(result.decision.reason)
512
+ elif result.decision.requires_approval:
513
+ hold(result.envelope) # the issuing service does this for you
514
+ else:
515
+ send(call, headers={ENVELOPE_HEADER: json.dumps(result.envelope)})
516
+ # or, for a target that speaks JWT: gargoyle.jws.encode_receipt(result.envelope, issuer_private_key)
517
+ ```
518
+
519
+ **Open Policy Agent.** `gargoyle.opa.OpaPolicy` asks OPA's data API and
520
+ pins the exact set of policy modules OPA holds, so editing a policy
521
+ invalidates every outstanding receipt until the call is decided again.
522
+
523
+ ```python
524
+ from gargoyle.opa import OpaPolicy
525
+ policy = OpaPolicy("http://127.0.0.1:8181", "gargoyle/decision")
526
+ ```
527
+
528
+ ```rego
529
+ package gargoyle
530
+ default decision := {"allow": false, "reason": "DEFAULT_DENY"}
531
+ decision := {"allow": true, "requires_approval": input.tool == "transfer_funds"} {
532
+ input.principal == "spiffe://example.org/agents/payments"
533
+ input.tool in {"read_balance", "transfer_funds"}
534
+ }
535
+ ```
536
+
537
+ OPA unreachable, an undefined document, or a result in no recognized shape
538
+ is a refusal, never an issuance. Any other engine goes behind the same
539
+ two-method interface.
540
+
541
+ **No engine.** `gargoyle.rules.RulesPolicy` decides from a JSON file:
542
+ first matching rule wins, globs on tool name, principal, delegation, the
543
+ caller's groups and roles, and string arguments; actions allow, hold or
544
+ deny; a hold may name `approvals`, `approvers` and `approver_roles`.
545
+ Argument globs that look like paths are matched on a normalized form
546
+ (separators, `..`, letter case, and on Windows the name the OS would
547
+ actually open), so a rule for one folder is not slipped by traversal or
548
+ spelling; `*` spans folders. The pin is the file's hash, so an edit
549
+ invalidates outstanding receipts exactly as an engine change does. This is
550
+ what `gargoyle init` sets up and `gargoyle rules` tests.
551
+
552
+ ### Checking a receipt
553
+
554
+ Every executor runs the same check: establish the live policy pin,
555
+ establish the trusted issuer keys, verify the receipt against the call it
556
+ is about to run, claim the `decision_id`. `gargoyle.consumer.Consumer`
557
+ packages it.
558
+
559
+ ```python
560
+ from gargoyle.consumer import Consumer
561
+
562
+ consumer = Consumer(
563
+ target_id="https://tools.example.com/target",
564
+ pinned_public_keys={"issuer-2026-10": issuer_public_key},
565
+ record_bytes=open("published_record.json", "rb").read(), # {policy_id, policy_sha256}
566
+ )
567
+
568
+ call = {"tool": name, "args": arguments, "principal": peer_identity}
569
+ verdict = consumer.check(envelope, call)
570
+ if verdict.honored:
571
+ run(name, arguments)
572
+ else:
573
+ refuse(verdict.reason) # REF_VERIFY_* or REF_TARGET_*
574
+ ```
575
+
576
+ `peer_identity` is who *you* authenticated as the caller (an mTLS peer, a
577
+ bearer token's subject, an identity a trusted proxy asserted). It is never
578
+ read from the request. A consumer refuses to be built without issuer-key
579
+ trust, and the verifier refuses to run without it, so an unsigned receipt
580
+ is never honored. For a flagged receipt, pass the human's grant as
581
+ `grant=`; the consumer verifies it against the receipt's own decision and
582
+ hold, claims it once, and only then claims the receipt.
583
+
584
+ For a live pin that can be reissued, give the consumer a record source (a
585
+ publisher-signed record with a serial and an expiry) instead of a static
586
+ pin; for issuer keys that can be rotated and revoked, a key record source.
587
+ Both are consulted on every check and fail closed.
588
+
589
+ ### Proof that it ran
590
+
591
+ Give a consumer an executor key and it signs an execution receipt after the
592
+ side effect. Anyone holding the issuer's, approver's and executor's public
593
+ keys can then audit one call with no network and no live state:
594
+
595
+ ```python
596
+ from gargoyle.execution import verify_chain
597
+
598
+ chain = verify_chain(receipt, execution,
599
+ issuer_public_keys=ISSUER_KEYS, executor_public_keys=EXECUTOR_KEYS,
600
+ grant=grant, approver_public_keys=APPROVER_KEYS,
601
+ outputs=outputs, check_outputs=True)
602
+ chain["accepted"], chain["steps"] # True, [("authorization", ...), ("approval", ...), ("execution", ...)]
603
+ ```
604
+
605
+ The receipt, grant and execution receipt verify in Python or TypeScript
606
+ against the same vectors; the evidence log, the witness, plans and the
607
+ older formats verify in Python.
608
+
609
+ ### Approval holds without the issuing service
610
+
611
+ When the policy's decision carries `requires_approval`, the receipt is
612
+ issued with `requires_approval: true` and a signed `approval_request_id`.
613
+ No target will run it on its own: the consumer demands a grant for exactly
614
+ that decision and hold, verifies it (provenance, separation of duties,
615
+ binding, expiry), claims it once, and only then claims the receipt. A human
616
+ can run `gargoyle-approver` on a machine that holds the approver private
617
+ key; given the receipt it shows the tool, argument hash, principal, target
618
+ and policy that are inside the issuer's signature, and emits the grant.
619
+ Approver keys should come from a signed key record where the key's role is
620
+ `approver` (`gargoyle.approver_trust`), so an issuer-controlled key can
621
+ never approve.
622
+
623
+ ## The shipped consumer and its configuration
624
+
625
+ **MCP middleware** (`python -m gargoyle.mcp_server -- <upstream command>`):
626
+ a stdio MCP server you put in front of any other stdio MCP server. The
627
+ handshake, `tools/list` and everything else are relayed. A `tools/call` is
628
+ forwarded only when `params._meta["gargoyle/envelope"]` carries a valid
629
+ receipt for that tool, those arguments and the principal the server serves
630
+ (and `params._meta["gargoyle/grant"]` a grant, when the receipt is
631
+ flagged); a refused call is answered here and never reaches the upstream.
632
+ `--env FILE` before the `--` reads the settings below from a `KEY=VALUE`
633
+ file (variables already in the environment win), so an MCP entry that
634
+ every local user can read, such as Claude Code's managed
635
+ `managed-mcp.json`, names `middleware.env` instead of carrying the executor
636
+ key. With `GARGOYLE_MCP_ISSUER_URL` and an identity it obtains a receipt
637
+ from the issuing service for any call that carries none, and waits for the
638
+ human's grant when the call is flagged, so clients that cannot attach
639
+ receipts work unchanged. Configuration is by environment:
640
+
641
+ | Variable | Meaning |
642
+ |---|---|
643
+ | `GARGOYLE_MCP_GATE_KEY_ID`, `GARGOYLE_MCP_GATE_PUBLIC_KEY_HEX` | the pinned issuer key |
644
+ | `GARGOYLE_MCP_TARGET_ID` | the target identity receipts bind to |
645
+ | `GARGOYLE_MCP_PRINCIPAL` | the client this server serves |
646
+ | `GARGOYLE_MCP_RECORD_PATH`, `GARGOYLE_MCP_RECORD_URL` | the live policy pin: a file copied out of band, or (what `init` sets) the issuer's signed record at `<issuer>/record`, followed live and cached a minute, so a rule change reaches every gate without a copy or a restart |
647
+ | `GARGOYLE_MCP_APPROVER_KEY_ID`, `GARGOYLE_MCP_APPROVER_PUBLIC_KEY_HEX` | the approver keys whose grants release flagged calls (optional, both or neither; comma-separated lists for several) |
648
+ | `GARGOYLE_MCP_EXECUTOR_KEY_ID`, `GARGOYLE_MCP_EXECUTOR_KEY_HEX` | this server's own signing key, so honored calls carry an execution receipt in `_meta["gargoyle/execution"]` (optional, both or neither) |
649
+ | `GARGOYLE_MCP_ISSUER_URL`, `GARGOYLE_MCP_CLIENT_TOKEN` | self-issue mode: the issuing service and the identity token (whose subject is this server's principal) to obtain receipts with; `GARGOYLE_MCP_APPROVAL_TIMEOUT_SECONDS` and `GARGOYLE_MCP_POLL_SECONDS` tune the wait for a grant |
650
+ | `GARGOYLE_MCP_ENTRA_TENANT`, `_CLIENT_ID`, `_SCOPE`, `_TOKEN_CACHE` | instead of a fixed client token: the user's own Entra ID token, written by `gargoyle login` (device code, MSAL cache, silent refresh); the principal is their UPN. No usable token refuses `REF_TARGET_LOGIN_REQUIRED` |
651
+ | `GARGOYLE_MCP_STATE_PROBE` | `filesystem` (default) binds a held file write to the file's current state: the approver sees the change as a diff, the call refuses if the file moved before the run (`REF_VERIFY_STATE_MISMATCH`), and the execution receipt records pre and post state; `graph` selects the Graph adapter; `none` disables |
652
+ | `GARGOYLE_MCP_STATE_ROOT` | the upstream's root: a relative path argument is made absolute against it before the rules, the gate-folder check, the state probe and the upstream see it, so all four look at the same file (default: the path in a `mcp://filesystem/` target id; `init` writes it). With no root, a relative path on a state-bound tool is refused with `REF_TARGET_RELATIVE_PATH`; a name Windows would rewrite (trailing dot or space, a stream suffix, a device name) with `REF_TARGET_PATH_NOT_CANONICAL`, and globs are matched against the canonical name too |
653
+ | `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
654
+ | `GARGOYLE_MCP_SNAPSHOT_DIR` | where the bytes a held, state-bound write replaces are kept (default `snapshots/` beside the audit log; `none` disables undo); the middleware then offers a `gargoyle_revert` tool that restores an approved change through the gate, held and state-bound like any write |
655
+ | `GARGOYLE_MCP_AUDIT_LOG` | the evidence log: every decision, honored or refused, as one hash-chained JSON line with its receipt, grant and execution receipt; `gargoyle audit --log` verifies the file; the replay claims that make a receipt single-use live beside it in `<log stem>.replay.sqlite` |
656
+ | `GARGOYLE_MCP_DISCOVERY_HOURS` | how old the last side-door report may be before the gate looks again at startup or on the hook's next run (default 24; `0` disables) |
657
+ | `GARGOYLE_MCP_INSTRUCTIONS` | replaces the standing instructions the gate hands the model in the MCP handshake (default: all work on the target goes through these tools, a wait is normal, a refusal is final, plans and undo exist); the user never has to name the server |
658
+ | `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
659
+ | remote gate `GARGOYLE_OAUTH_BASE_URL`, `_ENTRA_CLIENT_ID`, `_ENTRA_CLIENT_SECRET` (`_ENTRA_TENANT`, `_ENTRA_SCOPE`, `_REDIRECT_ALLOW`, `_ACCESS_TTL`) | the hosted connector signs the user in with Entra through the gate (RFC 8414/9728 metadata, RFC 7591 registration limited to the allowed callbacks, code flow with PKCE); the gate holds the user's Entra token for the issuer and refreshes it; sessions in `oauth-state.json` beside the gate's keys |
660
+
661
+ **Issuing service** (`gargoyle.issuer_service`, run with uvicorn from
662
+ `app_from_env()`, or `gargoyle issuer`): identity token in, receipt out,
663
+ with the hold and grant exchange for flagged calls (`/issue`, `/pending`,
664
+ `/grants`, `/decline`, `/record`, `/keys`, `/sidedoors`) and the browser
665
+ approval console at `/approve`. Policy from a rules file or OPA; identity
666
+ from a pinned key or, with `GARGOYLE_ENTRA_TENANT`, the tenant's signing
667
+ keys (both at once when a pinned key is also set).
668
+
669
+ | Variable | Meaning |
670
+ |---|---|
671
+ | `GARGOYLE_ISSUER_AUDIENCE` | the `aud` an identity token must carry; a comma-separated list accepts several, so init-minted tokens (`gargoyle-issuer`) and a tenant's tokens (the issuer app's client id) work side by side |
672
+ | `GARGOYLE_ENTRA_TENANT` + `GARGOYLE_ISSUER_AUDIENCE` | trust the tenant's tokens; the audience is required with a tenant (the service will not start without it), and a directory token for any other tenant is `REF_IDENTITY_WRONG_TENANT` |
673
+ | `GARGOYLE_ENTRA_CLIENT_ID`, `GARGOYLE_ENTRA_SCOPE` | lets the console sign approvers in with Entra (code flow with PKCE, no library); the browser generates a non-exportable key and enrolls it under the UPN |
674
+ | `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
+ | `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
+ | `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 |
677
+ | `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
+ | `GARGOYLE_WITNESS_PATH` | the issuer's signed, hash-chained record of every decision (`gargoyle issuer` sets `witness.jsonl` beside the keys) |
679
+ | `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 |
680
+ | `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled, revoked, sidedoors |
681
+ | `GET /health` | public: `ok`, the policy pin, pending counts, approver counts, `entra`, `persistent`, `witness_head` (the chain's tail sequence and entry hash, so a client can tell it talks to the issuer whose witness it will later reconcile against), and the week's hold and release figures by tool and by role |
682
+
683
+ A declined hold carries the approver's reason back to the assistant
684
+ verbatim, and a principal whose same tool and path have been declined
685
+ repeatedly is refused with `REF_ISSUER_DECLINED_RECENTLY` and the earlier
686
+ reason, without a new card.
687
+
688
+ ## Modules
689
+
690
+ | Module | Role |
691
+ |---|---|
692
+ | `policy`, `opa`, `rules`, `globs` | the decision interface; the OPA adapter; a policy from a JSON rules file; argument glob matching |
693
+ | `issuer`, `envelope`, `identity`, `jws` | ask the policy and sign; receipt construction and currency; identity tokens to principal and `on_behalf_of`; the receipt as a JWS |
694
+ | `verifier`, `consumer`, `execution` | the pure target-side check and every refusal code; `Consumer` (pin, keys, verify, claim, attest); execution receipts and the offline chain |
695
+ | `approval`, `approver_cli`, `approver_trust`, `approvers` | grants; the human's terminal tool; approver key trust; the enrolled-approver registry with history and revocation |
696
+ | `state`, `revert`, `adapters`, `holdtext` | state-bound approval; undo through the gate; what a call changes, per target type; the one-line description of a hold |
697
+ | `plans` | scoped grants: one approval for a bounded plan |
698
+ | `evidence`, `witness`, `reconcile` | the executor's hash-chained log; the issuer's signed witness; reconciliation and the tree check |
699
+ | `replay_cache`, `pending_store`, `holdstore` | single use for receipts and holds, in memory, SQLite or Redis; the issuer's persistent hold store |
700
+ | `mcp_server`, `remote`, `oauth_broker`, `fs_server` | the stdio middleware with self-issue mode; the gate as a Streamable HTTP server; Entra sign-in for hosted connectors; a Python filesystem MCP server |
701
+ | `claude_hook` | the same gate as a Claude Code hook over its own tools |
702
+ | `sidedoors` | discovery mode: every other way into the gated files this host shows |
703
+ | `issuer_service`, `entra_login`, `notify` | the HTTP issuing service and console; `gargoyle login`; the hold webhook |
704
+ | `doctor`, `rulecheck`, `operations` | `gargoyle doctor`; `gargoyle rules`; `gargoyle backup` and `restore` |
705
+ | `emilia`, `emilia_runner` | EMILIA Trust Receipt export and the EMILIA conformance runner |
706
+ | `published_record_source`, `key_record_source` | the live policy pin as a publisher-signed record; root-signed issuer and approver keys with windows and revocation |
707
+ | `cli` | the `gargoyle` command |
708
+
709
+ ## Documentation
710
+
711
+ | Document | What it is for |
712
+ |---|---|
713
+ | [SPEC.md](SPEC.md) | the wire specification: receipt, grant, execution receipt, evidence log, witness, plans, JWS, relation to other receipt formats; every vector recomputed by the suite |
714
+ | [ROADMAP.md](ROADMAP.md) | what was built when, what was found live, and what is next |
715
+ | [SECURITY.md](SECURITY.md) | how to report a problem, what is in scope, what the design does not claim |
716
+ | [docs/guide-hyperv-ubuntu-apache.md](docs/guide-hyperv-ubuntu-apache.md) | the single-instance build, end to end, with troubleshooting |
717
+ | [docs/guide-production-entra.md](docs/guide-production-entra.md) | one tenant, one hosted gate, Entra ID as the control point |
718
+ | [docs/entra-setup.md](docs/entra-setup.md) | the two app registrations, app roles, and the settings that bite |
719
+ | [docs/claude-code-hook.md](docs/claude-code-hook.md) | the gate as a Claude Code hook, and its limits |
720
+ | [docs/rules.md](docs/rules.md) | how a call is decided by a rules file, on one page |
721
+ | [docs/operations.md](docs/operations.md) | what the issuer host holds, backup, restore, restart |
722
+ | [docs/beta-checklist.md](docs/beta-checklist.md) | a first client deployment: set up, tell people, watch, back out |
723
+ | [docs/scenario-client-workstation.md](docs/scenario-client-workstation.md) | the scenario the design answers (2026-10-16) |
724
+ | [docs/comparison-2026-10-20.md](docs/comparison-2026-10-20.md) | Gargoyle against its nearest peers, from primary sources |
725
+ | [docs/interop-emilia.md](docs/interop-emilia.md) | interoperation with the EMILIA Protocol, both directions |
726
+ | [docs/ally-design.md](docs/ally-design.md) | how Gargoyle fits beside governance runtimes and the receipt drafts |
727
+ | [ts/README.md](ts/README.md) | the TypeScript verifier and consumer |
728
+ | [examples/](examples/) | the gated filesystem agent with OPA; a live pilot's rules and the calls they decided |
729
+
730
+ ## Install for development
731
+
732
+ pip install -e ".[dev]"
733
+ python -m pytest
734
+
735
+ Python 3.12 or later. The TypeScript verifier and consumer for Node 20+
736
+ lives in [ts/](ts/) (`cd ts && npm install && npm test`); its suite
737
+ recomputes the same conformance vectors byte for byte, so the two
738
+ implementations cannot drift apart silently. Redis is optional (`.[redis]`)
739
+ and only needed to share the replay cache and pending-approval set across
740
+ replicas of the remote gate; `.[entra]` adds MSAL for `gargoyle login`.
741
+
742
+ ## What Gargoyle does not do
743
+
744
+ - It does not decide. The policy does, and the receipt is only as good as
745
+ that decision.
746
+ - It does not authenticate principals. The issuer and the target each
747
+ authenticate the caller themselves; Gargoyle compares the two results.
748
+ - It cannot stop a caller that never asks for a receipt. Only a target that
749
+ refuses unattested calls can. On a workstation its user administers, the
750
+ gate constrains the assistant, not the user; changes made around the gate
751
+ are detected by reconciliation, the tree check and discovery, not
752
+ prevented.
753
+ - It does not judge the change. The console proves the approver saw the
754
+ exact arguments and the exact diff and signed; whether the change should
755
+ happen is the approver's call.
756
+ - It does not distribute trust material. Issuer keys, record files, root
757
+ keys and the client token are the deployer's to protect.
758
+ - The approval flow separates duties only if the approver's private key is
759
+ out of the issuing service's reach. That is a deployment property; the
760
+ browser console's non-exportable key is how the reference deployment
761
+ meets it.
762
+
763
+ ## License
764
+
765
+ Apache License 2.0; see [LICENSE](LICENSE) and [NOTICE](NOTICE).