gargoyle-receipts 0.19.0.dev0__tar.gz → 0.21.0.dev0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gargoyle_receipts-0.21.0.dev0/PKG-INFO +773 -0
- gargoyle_receipts-0.21.0.dev0/README.md +735 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/approval.py +65 -2
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/approver_cli.py +8 -2
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/approvers.py +21 -10
- gargoyle_receipts-0.21.0.dev0/gargoyle/claude_hook.py +324 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/cli.py +415 -20
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/consumer.py +3 -1
- gargoyle_receipts-0.21.0.dev0/gargoyle/doctor.py +500 -0
- gargoyle_receipts-0.21.0.dev0/gargoyle/emilia_runner.py +137 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/envelope.py +16 -6
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/evidence.py +39 -28
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/execution.py +2 -1
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/fs_server.py +14 -2
- gargoyle_receipts-0.21.0.dev0/gargoyle/globs.py +112 -0
- gargoyle_receipts-0.21.0.dev0/gargoyle/holdtext.py +155 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/issuer_service.py +244 -28
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/mcp_server.py +198 -33
- gargoyle_receipts-0.21.0.dev0/gargoyle/oauth_broker.py +506 -0
- gargoyle_receipts-0.21.0.dev0/gargoyle/operations.py +313 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/plans.py +19 -3
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/policy.py +9 -3
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/reconcile.py +31 -5
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/remote.py +77 -21
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/revert.py +57 -12
- gargoyle_receipts-0.21.0.dev0/gargoyle/rulecheck.py +280 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/rules.py +19 -5
- gargoyle_receipts-0.21.0.dev0/gargoyle/sidedoors.py +480 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/state.py +98 -2
- gargoyle_receipts-0.21.0.dev0/gargoyle/static/approve.html +652 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/witness.py +51 -0
- gargoyle_receipts-0.21.0.dev0/gargoyle_receipts.egg-info/PKG-INFO +773 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +19 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/pyproject.toml +1 -1
- gargoyle_receipts-0.21.0.dev0/tests/test_approval_views.py +308 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_approvers.py +60 -1
- gargoyle_receipts-0.21.0.dev0/tests/test_claude_hook.py +237 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_cli.py +30 -0
- gargoyle_receipts-0.21.0.dev0/tests/test_declines.py +112 -0
- gargoyle_receipts-0.21.0.dev0/tests/test_doctor.py +313 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_emilia_interop.py +19 -48
- gargoyle_receipts-0.21.0.dev0/tests/test_emilia_runner.py +55 -0
- gargoyle_receipts-0.21.0.dev0/tests/test_env_file.py +24 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_fs_server.py +39 -2
- gargoyle_receipts-0.21.0.dev0/tests/test_hardening_2026_11_01.py +161 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_issuer_service.py +10 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_mcp_self_issue.py +3 -2
- gargoyle_receipts-0.21.0.dev0/tests/test_oauth_broker.py +305 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_operability.py +1 -1
- gargoyle_receipts-0.21.0.dev0/tests/test_operations.py +152 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_record_follow.py +2 -2
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_revert.py +11 -2
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_review_fixes.py +9 -9
- gargoyle_receipts-0.21.0.dev0/tests/test_rulecheck.py +141 -0
- gargoyle_receipts-0.21.0.dev0/tests/test_sidedoors.py +255 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_state.py +26 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_threshold.py +2 -2
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_witness.py +110 -0
- gargoyle_receipts-0.19.0.dev0/PKG-INFO +0 -491
- gargoyle_receipts-0.19.0.dev0/README.md +0 -453
- gargoyle_receipts-0.19.0.dev0/gargoyle/globs.py +0 -58
- gargoyle_receipts-0.19.0.dev0/gargoyle/static/approve.html +0 -310
- gargoyle_receipts-0.19.0.dev0/gargoyle_receipts.egg-info/PKG-INFO +0 -491
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/__main__.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/adapters.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/emilia.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/holdstore.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/identity.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/published_record_source.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/replay_cache.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/static/approve.js +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/verifier.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_console_js.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_entra_identity.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_evidence.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_globs.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_graph.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_historical.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_plans.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_remote.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_rules.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_state_text.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.19.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_wire_spec.py +0 -0
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gargoyle-receipts
|
|
3
|
+
Version: 0.21.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
|
+
[](https://pypi.org/project/gargoyle-receipts/)
|
|
45
|
+
[](https://github.com/Elyon-Sol/Gargoyle/actions/workflows/ci.yml)
|
|
46
|
+
[](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.21.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.** 564 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
|
+
gargoyle audit --log audit.jsonl --issuer https://gargoyle.lan # take the issuer's keys and every approver key it has seen from the issuer, not from a copied file
|
|
382
|
+
|
|
383
|
+
The audit needs the public keys (`public-keys.json`, by default beside the
|
|
384
|
+
log or in `--out`). Grants verify against every key an approver has held,
|
|
385
|
+
so an approver who re-enrolled a browser key is still audited; with
|
|
386
|
+
`--issuer` that list comes from the issuer itself and a stale copy of
|
|
387
|
+
`approvers.json` can no longer fail a valid grant.
|
|
388
|
+
|
|
389
|
+
On a machine the user administers the gate constrains the assistant, not
|
|
390
|
+
the user; these checks make the difference visible. SPEC.md section 15
|
|
391
|
+
specifies the log, the witness and every reconciliation finding.
|
|
392
|
+
|
|
393
|
+
A held call is approved against the state it was shown in. Before asking
|
|
394
|
+
for the receipt the middleware hashes the target (a file's bytes, or its
|
|
395
|
+
absence); the issuer binds that hash into the signed receipt; the console
|
|
396
|
+
shows the proposed content as a diff against that state; after the human
|
|
397
|
+
signs, the middleware hashes the target again and refuses if it moved; and
|
|
398
|
+
the execution receipt carries the state before and after. An approved
|
|
399
|
+
change can be undone the same way: the middleware keeps what a held write
|
|
400
|
+
replaced and offers `gargoyle_revert`, which composes the restoring write
|
|
401
|
+
and sends it through the gate, held, shown as a diff, bound to the current
|
|
402
|
+
state, and refused if anything changed since. The audit marks a revert
|
|
403
|
+
from the state hashes alone.
|
|
404
|
+
|
|
405
|
+
## Backup and restore
|
|
406
|
+
|
|
407
|
+
Everything on the issuer host that cannot be regenerated (the hold store,
|
|
408
|
+
the witness, the keys, the rules, the enrolled approvers, the remote
|
|
409
|
+
gates' state) goes into one archive named with the witness head:
|
|
410
|
+
|
|
411
|
+
gargoyle backup /var/backups/gargoyle # on the issuer host, as root
|
|
412
|
+
gargoyle restore --verify-only ARCHIVE # check it against the running state; writes nothing
|
|
413
|
+
gargoyle restore ARCHIVE --opt /opt/gargoyle # put it back; services stopped first
|
|
414
|
+
|
|
415
|
+
A backup refuses to run if it cannot read every secret it should contain,
|
|
416
|
+
and writes nothing. `restore --verify-only` verifies the witness chain and
|
|
417
|
+
the hold store's integrity inside the archive. [docs/operations.md](docs/operations.md) says what the archive holds,
|
|
418
|
+
how to schedule it, and what a restart does to work in flight.
|
|
419
|
+
|
|
420
|
+
## Scoped grants
|
|
421
|
+
|
|
422
|
+
One card per held call is right for a mailbox rule and wrong for a long
|
|
423
|
+
task. The assistant can ask for a plan (`gargoyle_plan`): a list of steps,
|
|
424
|
+
each a tool with exact arguments or argument patterns, and a note saying
|
|
425
|
+
why. The issuer judges every step under the policy, signs the plan, and
|
|
426
|
+
holds it as one card; the approver signs once; each later call matching an
|
|
427
|
+
unused step runs without a card of its own until the plan expires. Every
|
|
428
|
+
step is still bound, claimed once, and recorded with the plan and step that
|
|
429
|
+
released it. The rules file decides what may be planned (`"plan": false`
|
|
430
|
+
keeps a tool individual; sends should be), because a plan approves scope
|
|
431
|
+
rather than content.
|
|
432
|
+
|
|
433
|
+
## Microsoft 365 and other targets: adapters
|
|
434
|
+
|
|
435
|
+
What a held call changes is the adapter's business (`gargoyle.adapters`):
|
|
436
|
+
it describes the target's state before and after, tells the approver what
|
|
437
|
+
the call means in plain words, and composes the call that undoes it. Files
|
|
438
|
+
on the executor's host are the default. `GARGOYLE_MCP_STATE_PROBE=graph`
|
|
439
|
+
selects the Graph adapter for a Microsoft Graph MCP server: inbox rules,
|
|
440
|
+
calendar events, contacts and Teams membership are bound to their state,
|
|
441
|
+
explained on the card ("creates inbox rule 'Fwd invoices' (to an outside
|
|
442
|
+
address: x@evil.example)"), undone by the inverse Graph call, and a send is
|
|
443
|
+
marked as something that cannot be undone. The adapter reads state through
|
|
444
|
+
the upstream server itself, so the gate needs no Graph credential of its
|
|
445
|
+
own. Which tool is which is a JSON spec; the reference naming is in
|
|
446
|
+
`gargoyle/specs/graph-reference.json`, and a different server's names are
|
|
447
|
+
mapped there, not in code. The Graph adapter is tested against a fake
|
|
448
|
+
Graph server and has not yet run against a tenant's mailbox.
|
|
449
|
+
|
|
450
|
+
## Remote mode
|
|
451
|
+
|
|
452
|
+
Claude's hosted connectors (claude.ai in the browser, Claude Desktop's
|
|
453
|
+
custom connectors) and Microsoft 365 servers are reached over HTTPS, not
|
|
454
|
+
through a local process. `gargoyle remote` runs the same gate as a
|
|
455
|
+
Streamable HTTP MCP server in front of a stdio command or a remote MCP
|
|
456
|
+
server (`--upstream-url`), so those paths go through it too:
|
|
457
|
+
|
|
458
|
+
gargoyle remote-token --out gargoyle-deploy --principal dana@contoso.com --url https://gargoyle.lan:8444
|
|
459
|
+
gargoyle remote --out gargoyle-deploy --port 8444 --ssl-certfile ... --ssl-keyfile ... -- python -m gargoyle.fs_server /srv/shared
|
|
460
|
+
|
|
461
|
+
With Entra, the connector signs the user in: set `GARGOYLE_OAUTH_BASE_URL`
|
|
462
|
+
(and the gate's own app registration, `GARGOYLE_OAUTH_ENTRA_CLIENT_ID` /
|
|
463
|
+
`_CLIENT_SECRET`) and the gate is the OAuth authorization server the hosted
|
|
464
|
+
client expects (metadata, dynamic registration, code flow with PKCE) while
|
|
465
|
+
the sign-in itself happens at the tenant, under its MFA and Conditional
|
|
466
|
+
Access. The gate then presents the user's own Entra token to the issuer,
|
|
467
|
+
refreshed silently, and the receipt's principal is their UPN; the session
|
|
468
|
+
ends when the tenant refuses the refresh (`gargoyle.oauth_broker`,
|
|
469
|
+
[docs/guide-production-entra.md](docs/guide-production-entra.md)). Without
|
|
470
|
+
a directory, each person gets a capability URL (`/mcp/<token>`) from
|
|
471
|
+
`gargoyle remote-token` that names them on every receipt; the operator
|
|
472
|
+
vouches for that mapping, as with an API key, so treat the URL as a
|
|
473
|
+
credential. All sessions share one upstream, one replay cache, one evidence
|
|
474
|
+
log and one snapshot store, so single use and undo hold across users.
|
|
475
|
+
Device-bound identity remains the stdio mode's property.
|
|
476
|
+
|
|
477
|
+
## Where it sits
|
|
478
|
+
|
|
479
|
+
MCP gateways hold a tool call, notify a reviewer and log the click.
|
|
480
|
+
Gargoyle is not a gateway. It runs beside the client, and its approval is a
|
|
481
|
+
signature made with a key the issuer never holds, checked at execution and
|
|
482
|
+
again by anyone later. In 2026 several Internet-Drafts describe parts of
|
|
483
|
+
the same shape: Acta signed receipts (an access decision, signed), EP
|
|
484
|
+
authorization receipts (a human's pre-execution signoff), XAIP receipts (a
|
|
485
|
+
signed execution record). Gargoyle chains all three on one decision id with
|
|
486
|
+
the policy version inside, uses the same RFC 8785 canonical form they do,
|
|
487
|
+
and SPEC.md section 16 maps every field.
|
|
488
|
+
[docs/comparison-2026-10-20.md](docs/comparison-2026-10-20.md) scores
|
|
489
|
+
Gargoyle against its nearest peers from their primary sources;
|
|
490
|
+
[docs/interop-emilia.md](docs/interop-emilia.md) records the EMILIA
|
|
491
|
+
interoperation, in both directions.
|
|
492
|
+
|
|
493
|
+
## Using the library
|
|
494
|
+
|
|
495
|
+
### Issuing a receipt
|
|
496
|
+
|
|
497
|
+
The policy is anything with `pin()` and `decide()`; see `gargoyle.policy`.
|
|
498
|
+
The interaction you pass carries the call (`tool`, `args`, `principal`) and
|
|
499
|
+
whatever claims your policy reads. The receipt records only the call and
|
|
500
|
+
the policy pin.
|
|
501
|
+
|
|
502
|
+
```python
|
|
503
|
+
from gargoyle.issuer import ENVELOPE_HEADER, issue
|
|
504
|
+
|
|
505
|
+
who = principal_from_token(bearer_or_transaction_token, IDP_KEYS, audience="issuer") # gargoyle.identity
|
|
506
|
+
# IDP_KEYS: pinned, or entra_keys("<tenant>") / keys_from_jwks(<provider JWKS>)
|
|
507
|
+
interaction = {
|
|
508
|
+
"tool": "transfer_funds",
|
|
509
|
+
"args": {"amount": 250, "to": "acct-9"},
|
|
510
|
+
"principal": who["principal"], # the agent, as the identity provider authenticated it
|
|
511
|
+
"on_behalf_of": who["on_behalf_of"], # the user it acts for, when the token says so
|
|
512
|
+
**claims, # whatever your policy decides on
|
|
513
|
+
}
|
|
514
|
+
result = issue(policy, "https://tools.example.com/target", interaction,
|
|
515
|
+
signing_key=issuer_private_key, key_id="issuer-2026-10")
|
|
516
|
+
|
|
517
|
+
if result.envelope is None:
|
|
518
|
+
refuse(result.decision.reason)
|
|
519
|
+
elif result.decision.requires_approval:
|
|
520
|
+
hold(result.envelope) # the issuing service does this for you
|
|
521
|
+
else:
|
|
522
|
+
send(call, headers={ENVELOPE_HEADER: json.dumps(result.envelope)})
|
|
523
|
+
# or, for a target that speaks JWT: gargoyle.jws.encode_receipt(result.envelope, issuer_private_key)
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
**Open Policy Agent.** `gargoyle.opa.OpaPolicy` asks OPA's data API and
|
|
527
|
+
pins the exact set of policy modules OPA holds, so editing a policy
|
|
528
|
+
invalidates every outstanding receipt until the call is decided again.
|
|
529
|
+
|
|
530
|
+
```python
|
|
531
|
+
from gargoyle.opa import OpaPolicy
|
|
532
|
+
policy = OpaPolicy("http://127.0.0.1:8181", "gargoyle/decision")
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
```rego
|
|
536
|
+
package gargoyle
|
|
537
|
+
default decision := {"allow": false, "reason": "DEFAULT_DENY"}
|
|
538
|
+
decision := {"allow": true, "requires_approval": input.tool == "transfer_funds"} {
|
|
539
|
+
input.principal == "spiffe://example.org/agents/payments"
|
|
540
|
+
input.tool in {"read_balance", "transfer_funds"}
|
|
541
|
+
}
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
OPA unreachable, an undefined document, or a result in no recognized shape
|
|
545
|
+
is a refusal, never an issuance. Any other engine goes behind the same
|
|
546
|
+
two-method interface.
|
|
547
|
+
|
|
548
|
+
**No engine.** `gargoyle.rules.RulesPolicy` decides from a JSON file:
|
|
549
|
+
first matching rule wins, globs on tool name, principal, delegation, the
|
|
550
|
+
caller's groups and roles, and string arguments; actions allow, hold or
|
|
551
|
+
deny; a hold may name `approvals`, `approvers` and `approver_roles`.
|
|
552
|
+
Argument globs that look like paths are matched on a normalized form
|
|
553
|
+
(separators, `..`, letter case, and on Windows the name the OS would
|
|
554
|
+
actually open), so a rule for one folder is not slipped by traversal or
|
|
555
|
+
spelling; `*` spans folders. The pin is the file's hash, so an edit
|
|
556
|
+
invalidates outstanding receipts exactly as an engine change does. This is
|
|
557
|
+
what `gargoyle init` sets up and `gargoyle rules` tests.
|
|
558
|
+
|
|
559
|
+
### Checking a receipt
|
|
560
|
+
|
|
561
|
+
Every executor runs the same check: establish the live policy pin,
|
|
562
|
+
establish the trusted issuer keys, verify the receipt against the call it
|
|
563
|
+
is about to run, claim the `decision_id`. `gargoyle.consumer.Consumer`
|
|
564
|
+
packages it.
|
|
565
|
+
|
|
566
|
+
```python
|
|
567
|
+
from gargoyle.consumer import Consumer
|
|
568
|
+
|
|
569
|
+
consumer = Consumer(
|
|
570
|
+
target_id="https://tools.example.com/target",
|
|
571
|
+
pinned_public_keys={"issuer-2026-10": issuer_public_key},
|
|
572
|
+
record_bytes=open("published_record.json", "rb").read(), # {policy_id, policy_sha256}
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
call = {"tool": name, "args": arguments, "principal": peer_identity}
|
|
576
|
+
verdict = consumer.check(envelope, call)
|
|
577
|
+
if verdict.honored:
|
|
578
|
+
run(name, arguments)
|
|
579
|
+
else:
|
|
580
|
+
refuse(verdict.reason) # REF_VERIFY_* or REF_TARGET_*
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
`peer_identity` is who *you* authenticated as the caller (an mTLS peer, a
|
|
584
|
+
bearer token's subject, an identity a trusted proxy asserted). It is never
|
|
585
|
+
read from the request. A consumer refuses to be built without issuer-key
|
|
586
|
+
trust, and the verifier refuses to run without it, so an unsigned receipt
|
|
587
|
+
is never honored. For a flagged receipt, pass the human's grant as
|
|
588
|
+
`grant=`; the consumer verifies it against the receipt's own decision and
|
|
589
|
+
hold, claims it once, and only then claims the receipt.
|
|
590
|
+
|
|
591
|
+
For a live pin that can be reissued, give the consumer a record source (a
|
|
592
|
+
publisher-signed record with a serial and an expiry) instead of a static
|
|
593
|
+
pin; for issuer keys that can be rotated and revoked, a key record source.
|
|
594
|
+
Both are consulted on every check and fail closed.
|
|
595
|
+
|
|
596
|
+
### Proof that it ran
|
|
597
|
+
|
|
598
|
+
Give a consumer an executor key and it signs an execution receipt after the
|
|
599
|
+
side effect. Anyone holding the issuer's, approver's and executor's public
|
|
600
|
+
keys can then audit one call with no network and no live state:
|
|
601
|
+
|
|
602
|
+
```python
|
|
603
|
+
from gargoyle.execution import verify_chain
|
|
604
|
+
|
|
605
|
+
chain = verify_chain(receipt, execution,
|
|
606
|
+
issuer_public_keys=ISSUER_KEYS, executor_public_keys=EXECUTOR_KEYS,
|
|
607
|
+
grant=grant, approver_public_keys=APPROVER_KEYS,
|
|
608
|
+
outputs=outputs, check_outputs=True)
|
|
609
|
+
chain["accepted"], chain["steps"] # True, [("authorization", ...), ("approval", ...), ("execution", ...)]
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
The receipt, grant and execution receipt verify in Python or TypeScript
|
|
613
|
+
against the same vectors; the evidence log, the witness, plans and the
|
|
614
|
+
older formats verify in Python.
|
|
615
|
+
|
|
616
|
+
### Approval holds without the issuing service
|
|
617
|
+
|
|
618
|
+
When the policy's decision carries `requires_approval`, the receipt is
|
|
619
|
+
issued with `requires_approval: true` and a signed `approval_request_id`.
|
|
620
|
+
No target will run it on its own: the consumer demands a grant for exactly
|
|
621
|
+
that decision and hold, verifies it (provenance, separation of duties,
|
|
622
|
+
binding, expiry), claims it once, and only then claims the receipt. A human
|
|
623
|
+
can run `gargoyle-approver` on a machine that holds the approver private
|
|
624
|
+
key; given the receipt it shows the tool, argument hash, principal, target
|
|
625
|
+
and policy that are inside the issuer's signature, and emits the grant.
|
|
626
|
+
Approver keys should come from a signed key record where the key's role is
|
|
627
|
+
`approver` (`gargoyle.approver_trust`), so an issuer-controlled key can
|
|
628
|
+
never approve.
|
|
629
|
+
|
|
630
|
+
## The shipped consumer and its configuration
|
|
631
|
+
|
|
632
|
+
**MCP middleware** (`python -m gargoyle.mcp_server -- <upstream command>`):
|
|
633
|
+
a stdio MCP server you put in front of any other stdio MCP server. The
|
|
634
|
+
handshake, `tools/list` and everything else are relayed. A `tools/call` is
|
|
635
|
+
forwarded only when `params._meta["gargoyle/envelope"]` carries a valid
|
|
636
|
+
receipt for that tool, those arguments and the principal the server serves
|
|
637
|
+
(and `params._meta["gargoyle/grant"]` a grant, when the receipt is
|
|
638
|
+
flagged); a refused call is answered here and never reaches the upstream.
|
|
639
|
+
`--env FILE` before the `--` reads the settings below from a `KEY=VALUE`
|
|
640
|
+
file (variables already in the environment win), so an MCP entry that
|
|
641
|
+
every local user can read, such as Claude Code's managed
|
|
642
|
+
`managed-mcp.json`, names `middleware.env` instead of carrying the executor
|
|
643
|
+
key. With `GARGOYLE_MCP_ISSUER_URL` and an identity it obtains a receipt
|
|
644
|
+
from the issuing service for any call that carries none, and waits for the
|
|
645
|
+
human's grant when the call is flagged, so clients that cannot attach
|
|
646
|
+
receipts work unchanged. Configuration is by environment:
|
|
647
|
+
|
|
648
|
+
| Variable | Meaning |
|
|
649
|
+
|---|---|
|
|
650
|
+
| `GARGOYLE_MCP_GATE_KEY_ID`, `GARGOYLE_MCP_GATE_PUBLIC_KEY_HEX` | the pinned issuer key |
|
|
651
|
+
| `GARGOYLE_MCP_TARGET_ID` | the target identity receipts bind to |
|
|
652
|
+
| `GARGOYLE_MCP_PRINCIPAL` | the client this server serves |
|
|
653
|
+
| `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 |
|
|
654
|
+
| `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) |
|
|
655
|
+
| `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) |
|
|
656
|
+
| `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 |
|
|
657
|
+
| `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` |
|
|
658
|
+
| `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 |
|
|
659
|
+
| `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 |
|
|
660
|
+
| `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
|
|
661
|
+
| `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 |
|
|
662
|
+
| `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` |
|
|
663
|
+
| `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) |
|
|
664
|
+
| `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 |
|
|
665
|
+
| `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
|
|
666
|
+
| 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 |
|
|
667
|
+
|
|
668
|
+
**Issuing service** (`gargoyle.issuer_service`, run with uvicorn from
|
|
669
|
+
`app_from_env()`, or `gargoyle issuer`): identity token in, receipt out,
|
|
670
|
+
with the hold and grant exchange for flagged calls (`/issue`, `/pending`,
|
|
671
|
+
`/grants`, `/decline`, `/record`, `/keys`, `/approvers`, `/approvers/audit`,
|
|
672
|
+
`/sidedoors`) and the browser
|
|
673
|
+
approval console at `/approve`. Policy from a rules file or OPA; identity
|
|
674
|
+
from a pinned key or, with `GARGOYLE_ENTRA_TENANT`, the tenant's signing
|
|
675
|
+
keys (both at once when a pinned key is also set).
|
|
676
|
+
|
|
677
|
+
| Variable | Meaning |
|
|
678
|
+
|---|---|
|
|
679
|
+
| `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 |
|
|
680
|
+
| `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` |
|
|
681
|
+
| `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 |
|
|
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
|
+
| `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
|
+
| `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_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
|
+
| `GARGOYLE_WITNESS_PATH` | the issuer's signed, hash-chained record of every decision (`gargoyle issuer` sets `witness.jsonl` beside the keys) |
|
|
687
|
+
| `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 |
|
|
688
|
+
| `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled, revoked, sidedoors |
|
|
689
|
+
| `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 |
|
|
690
|
+
|
|
691
|
+
A declined hold carries the approver's reason back to the assistant
|
|
692
|
+
verbatim, and a principal whose same tool and path have been declined
|
|
693
|
+
repeatedly is refused with `REF_ISSUER_DECLINED_RECENTLY` and the earlier
|
|
694
|
+
reason, without a new card.
|
|
695
|
+
|
|
696
|
+
## Modules
|
|
697
|
+
|
|
698
|
+
| Module | Role |
|
|
699
|
+
|---|---|
|
|
700
|
+
| `policy`, `opa`, `rules`, `globs` | the decision interface; the OPA adapter; a policy from a JSON rules file; argument glob matching |
|
|
701
|
+
| `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 |
|
|
702
|
+
| `verifier`, `consumer`, `execution` | the pure target-side check and every refusal code; `Consumer` (pin, keys, verify, claim, attest); execution receipts and the offline chain |
|
|
703
|
+
| `approval`, `approver_cli`, `approver_trust`, `approvers` | grants; the human's terminal tool; approver key trust; the enrolled-approver registry with history and revocation |
|
|
704
|
+
| `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 |
|
|
705
|
+
| `plans` | scoped grants: one approval for a bounded plan |
|
|
706
|
+
| `evidence`, `witness`, `reconcile` | the executor's hash-chained log; the issuer's signed witness; reconciliation and the tree check |
|
|
707
|
+
| `replay_cache`, `pending_store`, `holdstore` | single use for receipts and holds, in memory, SQLite or Redis; the issuer's persistent hold store |
|
|
708
|
+
| `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 |
|
|
709
|
+
| `claude_hook` | the same gate as a Claude Code hook over its own tools |
|
|
710
|
+
| `sidedoors` | discovery mode: every other way into the gated files this host shows |
|
|
711
|
+
| `issuer_service`, `entra_login`, `notify` | the HTTP issuing service and console; `gargoyle login`; the hold webhook |
|
|
712
|
+
| `doctor`, `rulecheck`, `operations` | `gargoyle doctor`; `gargoyle rules`; `gargoyle backup` and `restore` |
|
|
713
|
+
| `emilia`, `emilia_runner` | EMILIA Trust Receipt export and the EMILIA conformance runner |
|
|
714
|
+
| `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 |
|
|
715
|
+
| `cli` | the `gargoyle` command |
|
|
716
|
+
|
|
717
|
+
## Documentation
|
|
718
|
+
|
|
719
|
+
| Document | What it is for |
|
|
720
|
+
|---|---|
|
|
721
|
+
| [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 |
|
|
722
|
+
| [ROADMAP.md](ROADMAP.md) | what was built when, what was found live, and what is next |
|
|
723
|
+
| [SECURITY.md](SECURITY.md) | how to report a problem, what is in scope, what the design does not claim |
|
|
724
|
+
| [docs/guide-hyperv-ubuntu-apache.md](docs/guide-hyperv-ubuntu-apache.md) | the single-instance build, end to end, with troubleshooting |
|
|
725
|
+
| [docs/guide-production-entra.md](docs/guide-production-entra.md) | one tenant, one hosted gate, Entra ID as the control point |
|
|
726
|
+
| [docs/entra-setup.md](docs/entra-setup.md) | the two app registrations, app roles, and the settings that bite |
|
|
727
|
+
| [docs/claude-code-hook.md](docs/claude-code-hook.md) | the gate as a Claude Code hook, and its limits |
|
|
728
|
+
| [docs/rules.md](docs/rules.md) | how a call is decided by a rules file, on one page |
|
|
729
|
+
| [docs/operations.md](docs/operations.md) | what the issuer host holds, backup, restore, restart |
|
|
730
|
+
| [docs/beta-checklist.md](docs/beta-checklist.md) | a first client deployment: set up, tell people, watch, back out |
|
|
731
|
+
| [docs/scenario-client-workstation.md](docs/scenario-client-workstation.md) | the scenario the design answers (2026-10-16) |
|
|
732
|
+
| [docs/comparison-2026-10-20.md](docs/comparison-2026-10-20.md) | Gargoyle against its nearest peers, from primary sources |
|
|
733
|
+
| [docs/interop-emilia.md](docs/interop-emilia.md) | interoperation with the EMILIA Protocol, both directions |
|
|
734
|
+
| [docs/ally-design.md](docs/ally-design.md) | how Gargoyle fits beside governance runtimes and the receipt drafts |
|
|
735
|
+
| [ts/README.md](ts/README.md) | the TypeScript verifier and consumer |
|
|
736
|
+
| [examples/](examples/) | the gated filesystem agent with OPA; a live pilot's rules and the calls they decided |
|
|
737
|
+
|
|
738
|
+
## Install for development
|
|
739
|
+
|
|
740
|
+
pip install -e ".[dev]"
|
|
741
|
+
python -m pytest
|
|
742
|
+
|
|
743
|
+
Python 3.12 or later. The TypeScript verifier and consumer for Node 20+
|
|
744
|
+
lives in [ts/](ts/) (`cd ts && npm install && npm test`); its suite
|
|
745
|
+
recomputes the same conformance vectors byte for byte, so the two
|
|
746
|
+
implementations cannot drift apart silently. Redis is optional (`.[redis]`)
|
|
747
|
+
and only needed to share the replay cache and pending-approval set across
|
|
748
|
+
replicas of the remote gate; `.[entra]` adds MSAL for `gargoyle login`.
|
|
749
|
+
|
|
750
|
+
## What Gargoyle does not do
|
|
751
|
+
|
|
752
|
+
- It does not decide. The policy does, and the receipt is only as good as
|
|
753
|
+
that decision.
|
|
754
|
+
- It does not authenticate principals. The issuer and the target each
|
|
755
|
+
authenticate the caller themselves; Gargoyle compares the two results.
|
|
756
|
+
- It cannot stop a caller that never asks for a receipt. Only a target that
|
|
757
|
+
refuses unattested calls can. On a workstation its user administers, the
|
|
758
|
+
gate constrains the assistant, not the user; changes made around the gate
|
|
759
|
+
are detected by reconciliation, the tree check and discovery, not
|
|
760
|
+
prevented.
|
|
761
|
+
- It does not judge the change. The console proves the approver saw the
|
|
762
|
+
exact arguments and the exact diff and signed; whether the change should
|
|
763
|
+
happen is the approver's call.
|
|
764
|
+
- It does not distribute trust material. Issuer keys, record files, root
|
|
765
|
+
keys and the client token are the deployer's to protect.
|
|
766
|
+
- The approval flow separates duties only if the approver's private key is
|
|
767
|
+
out of the issuing service's reach. That is a deployment property; the
|
|
768
|
+
browser console's non-exportable key is how the reference deployment
|
|
769
|
+
meets it.
|
|
770
|
+
|
|
771
|
+
## License
|
|
772
|
+
|
|
773
|
+
Apache License 2.0; see [LICENSE](LICENSE) and [NOTICE](NOTICE).
|