gargoyle-receipts 0.4.0.dev0__tar.gz → 0.5.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.4.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.5.0.dev0}/PKG-INFO +13 -3
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/README.md +10 -2
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/cli.py +43 -0
- gargoyle_receipts-0.5.0.dev0/gargoyle/entra_login.py +137 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/identity.py +7 -3
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/issuer_service.py +7 -3
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/mcp_server.py +30 -12
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +13 -3
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +2 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle_receipts.egg-info/requires.txt +3 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/pyproject.toml +2 -1
- gargoyle_receipts-0.5.0.dev0/tests/test_entra_login.py +126 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_identity.py +6 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/approval.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/consumer.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/envelope.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/evidence.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/execution.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/policy.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/published_record_source.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/replay_cache.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/rules.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/static/approve.html +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/static/approve.js +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/verifier.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_cli.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_console_js.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_entra_identity.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_evidence.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_issuer_service.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_mcp_self_issue.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_rules.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_threshold.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_wire_spec.py +0 -0
{gargoyle_receipts-0.4.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.5.0.dev0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gargoyle-receipts
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0.dev0
|
|
4
4
|
Summary: Signed, single-use, argument-bound receipts and human-approval holds for agent tool calls.
|
|
5
5
|
Author: Justin LaPorte
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -28,6 +28,8 @@ Requires-Dist: uvicorn>=0.30
|
|
|
28
28
|
Requires-Dist: requests>=2.32
|
|
29
29
|
Provides-Extra: redis
|
|
30
30
|
Requires-Dist: redis; extra == "redis"
|
|
31
|
+
Provides-Extra: entra
|
|
32
|
+
Requires-Dist: msal>=1.28; extra == "entra"
|
|
31
33
|
Provides-Extra: dev
|
|
32
34
|
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
33
35
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
@@ -55,7 +57,9 @@ is included); the caller is authenticated by your transport (the
|
|
|
55
57
|
proxy-verified client certificate, by default); Gargoyle sits in front of
|
|
56
58
|
your MCP server and lets through only what both agreed to.
|
|
57
59
|
|
|
58
|
-
|
|
60
|
+
[](https://pypi.org/project/gargoyle-receipts/)
|
|
61
|
+
|
|
62
|
+
Status: pre-release (`0.5.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
|
|
59
63
|
still change before the first release; it is specified in [SPEC.md](SPEC.md).
|
|
60
64
|
|
|
61
65
|
## What a receipt gives you
|
|
@@ -242,6 +246,8 @@ chat channel can approve anything.
|
|
|
242
246
|
|
|
243
247
|
## Deploy it
|
|
244
248
|
|
|
249
|
+
pip install gargoyle-receipts
|
|
250
|
+
|
|
245
251
|
Three commands, no policy engine, no containers, no client changes:
|
|
246
252
|
|
|
247
253
|
gargoyle init /path/the/agent/may/touch # keys, config, rules.json
|
|
@@ -290,6 +296,8 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
290
296
|
| `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) |
|
|
291
297
|
| `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) |
|
|
292
298
|
| `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 |
|
|
299
|
+
| issuer `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 |
|
|
300
|
+
| `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` |
|
|
293
301
|
| `GARGOYLE_HOLD_WEBHOOK_URL`, `GARGOYLE_APPROVE_URL` | issuer: 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 |
|
|
294
302
|
| `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 |
|
|
295
303
|
|
|
@@ -298,7 +306,9 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
298
306
|
exchange for flagged calls (`/issue`, `/pending`, `/grants`, `/decline`,
|
|
299
307
|
`/record`, `/keys`) and the browser approval console at `/approve`. Policy
|
|
300
308
|
from a rules file or OPA; identity from a pinned key or, with
|
|
301
|
-
`GARGOYLE_ENTRA_TENANT`, the tenant's signing keys
|
|
309
|
+
`GARGOYLE_ENTRA_TENANT`, the tenant's signing keys (both at once when a
|
|
310
|
+
pinned key is also set, so init-minted approver tokens keep working while
|
|
311
|
+
users move to `gargoyle login`).
|
|
302
312
|
|
|
303
313
|
|
|
304
314
|
## Approval holds
|
|
@@ -19,7 +19,9 @@ is included); the caller is authenticated by your transport (the
|
|
|
19
19
|
proxy-verified client certificate, by default); Gargoyle sits in front of
|
|
20
20
|
your MCP server and lets through only what both agreed to.
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
[](https://pypi.org/project/gargoyle-receipts/)
|
|
23
|
+
|
|
24
|
+
Status: pre-release (`0.5.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
|
|
23
25
|
still change before the first release; it is specified in [SPEC.md](SPEC.md).
|
|
24
26
|
|
|
25
27
|
## What a receipt gives you
|
|
@@ -206,6 +208,8 @@ chat channel can approve anything.
|
|
|
206
208
|
|
|
207
209
|
## Deploy it
|
|
208
210
|
|
|
211
|
+
pip install gargoyle-receipts
|
|
212
|
+
|
|
209
213
|
Three commands, no policy engine, no containers, no client changes:
|
|
210
214
|
|
|
211
215
|
gargoyle init /path/the/agent/may/touch # keys, config, rules.json
|
|
@@ -254,6 +258,8 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
254
258
|
| `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) |
|
|
255
259
|
| `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) |
|
|
256
260
|
| `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 |
|
|
261
|
+
| issuer `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 |
|
|
262
|
+
| `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` |
|
|
257
263
|
| `GARGOYLE_HOLD_WEBHOOK_URL`, `GARGOYLE_APPROVE_URL` | issuer: 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 |
|
|
258
264
|
| `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 |
|
|
259
265
|
|
|
@@ -262,7 +268,9 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
262
268
|
exchange for flagged calls (`/issue`, `/pending`, `/grants`, `/decline`,
|
|
263
269
|
`/record`, `/keys`) and the browser approval console at `/approve`. Policy
|
|
264
270
|
from a rules file or OPA; identity from a pinned key or, with
|
|
265
|
-
`GARGOYLE_ENTRA_TENANT`, the tenant's signing keys
|
|
271
|
+
`GARGOYLE_ENTRA_TENANT`, the tenant's signing keys (both at once when a
|
|
272
|
+
pinned key is also set, so init-minted approver tokens keep working while
|
|
273
|
+
users move to `gargoyle login`).
|
|
266
274
|
|
|
267
275
|
|
|
268
276
|
## Approval holds
|
|
@@ -9,6 +9,8 @@ policy engine, a container runtime, or any client changes.
|
|
|
9
9
|
gargoyle issuer ... [--ssl-certfile CRT --ssl-keyfile KEY] serve TLS directly (no reverse proxy)
|
|
10
10
|
gargoyle issuer [--out DIR] [--port 8080] run the issuing service
|
|
11
11
|
gargoyle approve [--out DIR] [--watch SECONDS] sit and approve held calls
|
|
12
|
+
gargoyle login --tenant T --client-id C --scope S sign this workstation's user in with
|
|
13
|
+
Entra ID; the middleware then acts as them
|
|
12
14
|
gargoyle audit <receipt.json> <execution.json> [grant.json]
|
|
13
15
|
gargoyle audit --log audit.jsonl verify the middleware's evidence log
|
|
14
16
|
|
|
@@ -112,6 +114,8 @@ def cmd_init(args) -> int:
|
|
|
112
114
|
"GARGOYLE_ISSUER_AUDIENCE": "gargoyle-issuer",
|
|
113
115
|
"GARGOYLE_APPROVER_KEY_ID": approver_ids, "GARGOYLE_APPROVER_PUBLIC_KEY_HEX": approver_pubs,
|
|
114
116
|
"GARGOYLE_RULES_PATH": str(rules), "GARGOYLE_RECEIPT_TTL_SECONDS": "600",
|
|
117
|
+
**({"GARGOYLE_ENTRA_TENANT": args.entra_tenant} if args.entra_tenant else {}),
|
|
118
|
+
**({"GARGOYLE_ISSUER_AUDIENCE": args.entra_audience} if args.entra_audience else {}),
|
|
115
119
|
}), private=True)
|
|
116
120
|
middleware_env = {
|
|
117
121
|
"GARGOYLE_MCP_GATE_KEY_ID": "issuer-1", "GARGOYLE_MCP_GATE_PUBLIC_KEY_HEX": issuer_pub,
|
|
@@ -154,6 +158,36 @@ def cmd_init(args) -> int:
|
|
|
154
158
|
return 0
|
|
155
159
|
|
|
156
160
|
|
|
161
|
+
def cmd_login(args) -> int:
|
|
162
|
+
"""Device-code sign-in; then middleware.env (and mcp-config.json when
|
|
163
|
+
present) switch from the shared init token to this user's Entra token,
|
|
164
|
+
and the principal becomes their UPN."""
|
|
165
|
+
from gargoyle.entra_login import (ENV_ENTRA_CLIENT_ID, ENV_ENTRA_SCOPE, ENV_ENTRA_TENANT, ENV_ENTRA_TOKEN_CACHE,
|
|
166
|
+
device_code_login)
|
|
167
|
+
out = Path(args.out).resolve()
|
|
168
|
+
client_dir = Path(args.client_config_dir or out)
|
|
169
|
+
cache = client_dir / "entra-token-cache.json"
|
|
170
|
+
result = device_code_login(args.tenant, args.client_id, args.scope, str(cache))
|
|
171
|
+
principal = result["principal"]
|
|
172
|
+
env_path = out / "middleware.env"
|
|
173
|
+
env = _read_env(env_path) if env_path.exists() else {}
|
|
174
|
+
env.pop("GARGOYLE_MCP_CLIENT_TOKEN", None)
|
|
175
|
+
env.update({ENV_ENTRA_TENANT: args.tenant, ENV_ENTRA_CLIENT_ID: args.client_id, ENV_ENTRA_SCOPE: args.scope,
|
|
176
|
+
ENV_ENTRA_TOKEN_CACHE: str(cache), "GARGOYLE_MCP_PRINCIPAL": principal})
|
|
177
|
+
_write(env_path, _env_lines(env), private=True)
|
|
178
|
+
mcp = out / "mcp-config.json"
|
|
179
|
+
if mcp.exists():
|
|
180
|
+
doc = json.loads(mcp.read_text())
|
|
181
|
+
for server in doc.get("mcpServers", {}).values():
|
|
182
|
+
if "GARGOYLE_MCP_TARGET_ID" in server.get("env", {}):
|
|
183
|
+
server["env"].pop("GARGOYLE_MCP_CLIENT_TOKEN", None)
|
|
184
|
+
server["env"].update({k: env[k] for k in (ENV_ENTRA_TENANT, ENV_ENTRA_CLIENT_ID, ENV_ENTRA_SCOPE,
|
|
185
|
+
ENV_ENTRA_TOKEN_CACHE, "GARGOYLE_MCP_PRINCIPAL")})
|
|
186
|
+
_write(mcp, json.dumps(doc, indent=2) + "\n", private=True)
|
|
187
|
+
print("signed in as %s; the middleware now acts as that user (restart the MCP client)" % principal)
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
|
|
157
191
|
def cmd_issuer(args) -> int:
|
|
158
192
|
import uvicorn
|
|
159
193
|
from gargoyle.issuer_service import app_from_env
|
|
@@ -236,7 +270,16 @@ def main(argv=None) -> int:
|
|
|
236
270
|
help="how many approver keys to create (approver-1..N); a rule with \"approvals\": 2 then needs two of them")
|
|
237
271
|
p.add_argument("--upstream", choices=["npx-filesystem", "python-fs"], default="npx-filesystem",
|
|
238
272
|
help="the filesystem MCP server behind the gate: the official one via npx, or gargoyle.fs_server")
|
|
273
|
+
p.add_argument("--entra-tenant", help="trust this Entra ID tenant's tokens at the issuer (users then run `gargoyle login`)")
|
|
274
|
+
p.add_argument("--entra-audience", help="the issuer API app's client id or app id URI, required in Entra tokens")
|
|
239
275
|
p.set_defaults(fn=cmd_init)
|
|
276
|
+
p = sub.add_parser("login", help="sign this workstation's user in with Entra ID (device code)")
|
|
277
|
+
p.add_argument("--tenant", required=True, help="tenant id or domain")
|
|
278
|
+
p.add_argument("--client-id", required=True, help="the public client app registration")
|
|
279
|
+
p.add_argument("--scope", required=True, help="the issuer API scope, e.g. api://<api-client-id>/access")
|
|
280
|
+
p.add_argument("--out", default="gargoyle-deploy", help="where middleware.env and mcp-config.json live")
|
|
281
|
+
p.add_argument("--client-config-dir", help="where the token cache goes (default: --out)")
|
|
282
|
+
p.set_defaults(fn=cmd_login)
|
|
240
283
|
p = sub.add_parser("issuer", help="run the issuing service on the rules file")
|
|
241
284
|
p.add_argument("--out", default="gargoyle-deploy")
|
|
242
285
|
p.add_argument("--host", default="127.0.0.1"); p.add_argument("--port", type=int, default=8080)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Entra ID login for the client: the middleware's token names a person.
|
|
2
|
+
|
|
3
|
+
`gargoyle init` mints one shared client token. For a team, each workstation
|
|
4
|
+
should present its own user's identity, so that every receipt's principal
|
|
5
|
+
and every audit row names who asked. `gargoyle login` runs the Entra ID
|
|
6
|
+
device-code flow once (the user signs in from a browser on any device,
|
|
7
|
+
with the tenant's MFA), and stores MSAL's token cache in a private file.
|
|
8
|
+
The middleware then obtains a current access token from that cache before
|
|
9
|
+
each call to the issuer; MSAL refreshes it silently with the refresh token
|
|
10
|
+
until the tenant's policy says to sign in again.
|
|
11
|
+
|
|
12
|
+
Entra app registration (once per tenant):
|
|
13
|
+
- an API app for the issuer, exposing one scope (for example
|
|
14
|
+
api://<api-client-id>/access); the issuer's GARGOYLE_ISSUER_AUDIENCE is
|
|
15
|
+
that app's client id (v2 tokens) or its app id URI (v1 tokens);
|
|
16
|
+
- a public client app for `gargoyle login` with "Allow public client
|
|
17
|
+
flows" on, granted that scope.
|
|
18
|
+
|
|
19
|
+
The issuer verifies the token against the tenant's published keys
|
|
20
|
+
(GARGOYLE_ENTRA_TENANT) and takes preferred_username, the UPN, as the
|
|
21
|
+
principal; groups and app roles in the token are inputs to the rules.
|
|
22
|
+
|
|
23
|
+
MSAL is an optional dependency: pip install "gargoyle-receipts[entra]".
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import stat
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
31
|
+
|
|
32
|
+
ENV_ENTRA_TENANT = "GARGOYLE_MCP_ENTRA_TENANT"
|
|
33
|
+
ENV_ENTRA_CLIENT_ID = "GARGOYLE_MCP_ENTRA_CLIENT_ID"
|
|
34
|
+
ENV_ENTRA_SCOPE = "GARGOYLE_MCP_ENTRA_SCOPE"
|
|
35
|
+
ENV_ENTRA_TOKEN_CACHE = "GARGOYLE_MCP_ENTRA_TOKEN_CACHE"
|
|
36
|
+
|
|
37
|
+
AUTHORITY = "https://login.microsoftonline.com/{tenant}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class LoginRequired(Exception):
|
|
41
|
+
"""No usable account in the cache: run `gargoyle login`."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _msal():
|
|
45
|
+
try:
|
|
46
|
+
import msal
|
|
47
|
+
except ImportError as exc: # pragma: no cover
|
|
48
|
+
raise RuntimeError('Entra login needs msal: pip install "gargoyle-receipts[entra]"') from exc
|
|
49
|
+
return msal
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TokenCache:
|
|
53
|
+
"""MSAL's serializable cache, persisted to a private file."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, path: str, msal_module: Any = None) -> None:
|
|
56
|
+
self.path = Path(path)
|
|
57
|
+
self._msal = msal_module or _msal()
|
|
58
|
+
self.cache = self._msal.SerializableTokenCache()
|
|
59
|
+
if self.path.exists():
|
|
60
|
+
self.cache.deserialize(self.path.read_text(encoding="utf-8"))
|
|
61
|
+
|
|
62
|
+
def save(self) -> None:
|
|
63
|
+
if self.cache.has_state_changed:
|
|
64
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
self.path.write_text(self.cache.serialize(), encoding="utf-8")
|
|
66
|
+
try:
|
|
67
|
+
os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
|
|
68
|
+
except OSError:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _app(tenant: str, client_id: str, cache: TokenCache, app_factory: Optional[Callable] = None) -> Any:
|
|
73
|
+
if app_factory is not None:
|
|
74
|
+
return app_factory(client_id=client_id, authority=AUTHORITY.format(tenant=tenant), token_cache=cache.cache)
|
|
75
|
+
return _msal().PublicClientApplication(client_id, authority=AUTHORITY.format(tenant=tenant), token_cache=cache.cache)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def device_code_login(tenant: str, client_id: str, scope: str, cache_path: str, *,
|
|
79
|
+
app_factory: Optional[Callable] = None, msal_module: Any = None,
|
|
80
|
+
prompt: Callable[[str], None] = print) -> Dict[str, Any]:
|
|
81
|
+
"""Sign a user in by device code and store the cache. Returns
|
|
82
|
+
{"principal": <UPN>, "expires_in": seconds, "cache_path": str}.
|
|
83
|
+
Raises RuntimeError with Entra's error text when the sign-in fails."""
|
|
84
|
+
cache = TokenCache(cache_path, msal_module)
|
|
85
|
+
app = _app(tenant, client_id, cache, app_factory)
|
|
86
|
+
flow = app.initiate_device_flow(scopes=[scope])
|
|
87
|
+
if "user_code" not in flow:
|
|
88
|
+
raise RuntimeError("device flow could not start: %s" % flow.get("error_description", flow))
|
|
89
|
+
prompt(flow["message"])
|
|
90
|
+
result = app.acquire_token_by_device_flow(flow)
|
|
91
|
+
cache.save()
|
|
92
|
+
if "access_token" not in result:
|
|
93
|
+
raise RuntimeError("sign-in failed: %s" % result.get("error_description", result.get("error", result)))
|
|
94
|
+
claims = result.get("id_token_claims") or {}
|
|
95
|
+
principal = claims.get("preferred_username") or claims.get("upn") or claims.get("oid")
|
|
96
|
+
if not principal:
|
|
97
|
+
raise RuntimeError("the token names no user (no preferred_username, upn or oid)")
|
|
98
|
+
return {"principal": principal, "expires_in": result.get("expires_in"), "cache_path": str(cache.path)}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class EntraTokenSource:
|
|
102
|
+
"""What the middleware calls for a bearer token: the cached account's
|
|
103
|
+
current access token, refreshed silently by MSAL when needed."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, tenant: str, client_id: str, scope: str, cache_path: str, *,
|
|
106
|
+
app_factory: Optional[Callable] = None, msal_module: Any = None) -> None:
|
|
107
|
+
self.scope = scope
|
|
108
|
+
self._cache = TokenCache(cache_path, msal_module)
|
|
109
|
+
self._app = _app(tenant, client_id, self._cache, app_factory)
|
|
110
|
+
|
|
111
|
+
def token(self) -> str:
|
|
112
|
+
accounts: List[Dict[str, Any]] = self._app.get_accounts()
|
|
113
|
+
if not accounts:
|
|
114
|
+
raise LoginRequired("no Entra account in %s; run: gargoyle login" % self._cache.path)
|
|
115
|
+
result = self._app.acquire_token_silent([self.scope], account=accounts[0]) or {}
|
|
116
|
+
self._cache.save()
|
|
117
|
+
if "access_token" not in result:
|
|
118
|
+
raise LoginRequired("Entra token could not be refreshed (%s); run: gargoyle login"
|
|
119
|
+
% result.get("error_description", result.get("error", "no token")))
|
|
120
|
+
return result["access_token"]
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def principal(self) -> Optional[str]:
|
|
124
|
+
accounts = self._app.get_accounts()
|
|
125
|
+
return accounts[0].get("username") if accounts else None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def token_source_from_env(environ: Dict[str, str]) -> Optional[EntraTokenSource]:
|
|
129
|
+
"""An EntraTokenSource when the four GARGOYLE_MCP_ENTRA_* variables are
|
|
130
|
+
set, None when none is; a partial set raises."""
|
|
131
|
+
keys = (ENV_ENTRA_TENANT, ENV_ENTRA_CLIENT_ID, ENV_ENTRA_SCOPE, ENV_ENTRA_TOKEN_CACHE)
|
|
132
|
+
values = [environ.get(k) for k in keys]
|
|
133
|
+
if not any(values):
|
|
134
|
+
return None
|
|
135
|
+
if not all(values):
|
|
136
|
+
raise RuntimeError("set all of %s together" % ", ".join(keys))
|
|
137
|
+
return EntraTokenSource(*values)
|
|
@@ -77,7 +77,7 @@ def _innermost_actor(act: Any) -> Optional[str]:
|
|
|
77
77
|
|
|
78
78
|
|
|
79
79
|
def principal_from_token(token: str, public_keys: Dict[str, Any], *,
|
|
80
|
-
audience:
|
|
80
|
+
audience: Any = None, now: Optional[datetime] = None) -> Dict[str, Any]:
|
|
81
81
|
"""{"principal", "on_behalf_of" (or None), "claims", "reason": None} for
|
|
82
82
|
a trusted identity token, or {"principal": None, "reason": code}."""
|
|
83
83
|
res = decode(token, public_keys, allowed_algs=IDENTITY_ALGS)
|
|
@@ -89,9 +89,13 @@ def principal_from_token(token: str, public_keys: Dict[str, Any], *,
|
|
|
89
89
|
if not isinstance(exp, int) or isinstance(exp, bool) or current.timestamp() >= exp:
|
|
90
90
|
return {"principal": None, "reason": REF_JWS_EXPIRED}
|
|
91
91
|
if audience is not None:
|
|
92
|
+
# One audience, or several (a list, or a comma-separated string):
|
|
93
|
+
# the issuer accepts init-minted tokens and a tenant's tokens side
|
|
94
|
+
# by side while a site moves from one to the other.
|
|
92
95
|
aud = claims.get("aud")
|
|
93
|
-
|
|
94
|
-
if audience
|
|
96
|
+
presented = aud if isinstance(aud, list) else [aud]
|
|
97
|
+
accepted = audience if isinstance(audience, (list, tuple)) else [a.strip() for a in str(audience).split(",")]
|
|
98
|
+
if not any(a in presented for a in accepted if a):
|
|
95
99
|
return {"principal": None, "reason": REF_JWS_AUDIENCE_MISMATCH}
|
|
96
100
|
|
|
97
101
|
# OIDC providers' `sub` is opaque; the UPN is the identity people and
|
|
@@ -258,6 +258,7 @@ def app_from_env() -> FastAPI:
|
|
|
258
258
|
|
|
259
259
|
required = {k: os.environ.get(k) for k in (ENV_ISSUER_KEY_ID, ENV_ISSUER_KEY_HEX)}
|
|
260
260
|
tenant = os.environ.get(ENV_ENTRA_TENANT)
|
|
261
|
+
pinned_idp = os.environ.get(ENV_IDP_KEY_ID) and os.environ.get(ENV_IDP_PUBLIC_KEY_HEX)
|
|
261
262
|
if not tenant:
|
|
262
263
|
required.update({k: os.environ.get(k) for k in (ENV_IDP_KEY_ID, ENV_IDP_PUBLIC_KEY_HEX)})
|
|
263
264
|
missing = [k for k, v in required.items() if not v]
|
|
@@ -287,9 +288,12 @@ def app_from_env() -> FastAPI:
|
|
|
287
288
|
policy=policy,
|
|
288
289
|
signing_key=Ed25519PrivateKey.from_private_bytes(bytes.fromhex(required[ENV_ISSUER_KEY_HEX])),
|
|
289
290
|
key_id=required[ENV_ISSUER_KEY_ID],
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
291
|
+
# Both may be trusted at once: the tenant's keys for users who ran
|
|
292
|
+
# `gargoyle login`, the pinned key for tokens `gargoyle init` minted
|
|
293
|
+
# (the approver's, until approvers log in with Entra too).
|
|
294
|
+
idp_public_keys={**(entra_keys(tenant) if tenant else {}),
|
|
295
|
+
**({os.environ[ENV_IDP_KEY_ID]: Ed25519PublicKey.from_public_bytes(bytes.fromhex(os.environ[ENV_IDP_PUBLIC_KEY_HEX]))}
|
|
296
|
+
if pinned_idp else {})},
|
|
293
297
|
approver_public_keys=approver,
|
|
294
298
|
audience=os.environ.get(ENV_AUDIENCE) or None,
|
|
295
299
|
ttl_seconds=int(os.environ.get(ENV_RECEIPT_TTL, DEFAULT_TTL_SECONDS)),
|
|
@@ -96,7 +96,8 @@ ENV_ISSUER_URL = "GARGOYLE_MCP_ISSUER_URL"
|
|
|
96
96
|
ENV_CLIENT_TOKEN = "GARGOYLE_MCP_CLIENT_TOKEN"
|
|
97
97
|
ENV_APPROVAL_TIMEOUT = "GARGOYLE_MCP_APPROVAL_TIMEOUT_SECONDS"
|
|
98
98
|
ENV_POLL_SECONDS = "GARGOYLE_MCP_POLL_SECONDS"
|
|
99
|
-
ENV_AUDIT_LOG = "GARGOYLE_MCP_AUDIT_LOG"
|
|
99
|
+
ENV_AUDIT_LOG = "GARGOYLE_MCP_AUDIT_LOG"
|
|
100
|
+
REF_TARGET_LOGIN_REQUIRED = "REF_TARGET_LOGIN_REQUIRED" # Entra login mode and no usable token: run `gargoyle login` # the evidence log: one hash-chained JSON line per decision, honored or refused
|
|
100
101
|
|
|
101
102
|
# Self-issue refusals.
|
|
102
103
|
REF_TARGET_ISSUER_UNAVAILABLE = "REF_TARGET_ISSUER_UNAVAILABLE"
|
|
@@ -110,22 +111,28 @@ class IssuerClient:
|
|
|
110
111
|
passes a TestClient). Every failure is a refusal reason, never an
|
|
111
112
|
exception out of the gate."""
|
|
112
113
|
|
|
113
|
-
def __init__(self, url: str, token:
|
|
114
|
+
def __init__(self, url: str, token: Any, *, http: Any = None, approval_timeout: float = 300.0,
|
|
114
115
|
poll_seconds: float = 2.0, request_timeout: float = 10.0, sleep=time.sleep) -> None:
|
|
116
|
+
"""`token` is the bearer token string, or an object with a token()
|
|
117
|
+
method that returns the current one (gargoyle.entra_login.EntraTokenSource)."""
|
|
115
118
|
import requests
|
|
116
119
|
self.url = url.rstrip("/")
|
|
117
|
-
self.
|
|
120
|
+
self._token = token
|
|
118
121
|
self._http = http if http is not None else requests.Session()
|
|
119
122
|
self.approval_timeout = approval_timeout
|
|
120
123
|
self.poll_seconds = poll_seconds
|
|
121
124
|
self.request_timeout = request_timeout
|
|
122
125
|
self._sleep = sleep
|
|
123
126
|
|
|
127
|
+
def _headers(self) -> Dict[str, str]:
|
|
128
|
+
token = self._token.token() if hasattr(self._token, "token") else self._token
|
|
129
|
+
return {"Authorization": "Bearer " + token}
|
|
130
|
+
|
|
124
131
|
def _post(self, path: str, body: Dict[str, Any]) -> Any:
|
|
125
|
-
return self._http.post(self.url + path, json=body, headers=self._headers, timeout=self.request_timeout)
|
|
132
|
+
return self._http.post(self.url + path, json=body, headers=self._headers(), timeout=self.request_timeout)
|
|
126
133
|
|
|
127
134
|
def _get(self, path: str) -> Any:
|
|
128
|
-
return self._http.get(self.url + path, headers=self._headers, timeout=self.request_timeout)
|
|
135
|
+
return self._http.get(self.url + path, headers=self._headers(), timeout=self.request_timeout)
|
|
129
136
|
|
|
130
137
|
def issue(self, target_url: str, tool: str, args: Any) -> Dict[str, Any]:
|
|
131
138
|
"""{"envelope": ...} or {"envelope": None, "reason": ...}. The cause of
|
|
@@ -134,6 +141,13 @@ class IssuerClient:
|
|
|
134
141
|
generic."""
|
|
135
142
|
try:
|
|
136
143
|
r = self._post("/issue", {"target_url": target_url, "tool": tool, "args": args})
|
|
144
|
+
except Exception as exc:
|
|
145
|
+
if type(exc).__name__ == "LoginRequired":
|
|
146
|
+
sys.stderr.write("gargoyle: %s\n" % exc)
|
|
147
|
+
return {"envelope": None, "reason": REF_TARGET_LOGIN_REQUIRED}
|
|
148
|
+
sys.stderr.write("gargoyle: issuer unreachable at %s: %s: %s\n" % (self.url, type(exc).__name__, str(exc)[:300]))
|
|
149
|
+
return {"envelope": None, "reason": REF_TARGET_ISSUER_UNAVAILABLE}
|
|
150
|
+
try:
|
|
137
151
|
if r.status_code != 200:
|
|
138
152
|
detail = r.json().get("detail", {}) if r.headers.get("content-type", "").startswith("application/json") else {}
|
|
139
153
|
sys.stderr.write("gargoyle: issuer answered %s: %s\n" % (r.status_code, r.text[:300]))
|
|
@@ -573,17 +587,21 @@ def utf8_stdio(stdin=None, stdout=None):
|
|
|
573
587
|
|
|
574
588
|
|
|
575
589
|
def issuer_client_from_env() -> Optional[IssuerClient]:
|
|
576
|
-
"""Self-issue mode when GARGOYLE_MCP_ISSUER_URL
|
|
577
|
-
GARGOYLE_MCP_CLIENT_TOKEN
|
|
578
|
-
|
|
590
|
+
"""Self-issue mode when GARGOYLE_MCP_ISSUER_URL is set together with
|
|
591
|
+
either GARGOYLE_MCP_CLIENT_TOKEN (a fixed token from `gargoyle init`) or
|
|
592
|
+
the GARGOYLE_MCP_ENTRA_* set written by `gargoyle login` (a per-user
|
|
593
|
+
token refreshed from MSAL's cache). None when none is set; a URL without
|
|
594
|
+
a token source, or the reverse, raises."""
|
|
579
595
|
import os
|
|
596
|
+
from gargoyle.entra_login import token_source_from_env
|
|
580
597
|
|
|
581
598
|
url, token = os.environ.get(ENV_ISSUER_URL), os.environ.get(ENV_CLIENT_TOKEN)
|
|
582
|
-
|
|
599
|
+
source = token_source_from_env(os.environ)
|
|
600
|
+
if not url and not token and source is None:
|
|
583
601
|
return None
|
|
584
|
-
if not
|
|
585
|
-
raise RuntimeError("%s
|
|
586
|
-
return IssuerClient(url, token,
|
|
602
|
+
if not url or (not token and source is None):
|
|
603
|
+
raise RuntimeError("%s goes with %s or the GARGOYLE_MCP_ENTRA_* set" % (ENV_ISSUER_URL, ENV_CLIENT_TOKEN))
|
|
604
|
+
return IssuerClient(url, source if source is not None else token,
|
|
587
605
|
approval_timeout=float(os.environ.get(ENV_APPROVAL_TIMEOUT, "300")),
|
|
588
606
|
poll_seconds=float(os.environ.get(ENV_POLL_SECONDS, "2")))
|
|
589
607
|
|
{gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gargoyle-receipts
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0.dev0
|
|
4
4
|
Summary: Signed, single-use, argument-bound receipts and human-approval holds for agent tool calls.
|
|
5
5
|
Author: Justin LaPorte
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -28,6 +28,8 @@ Requires-Dist: uvicorn>=0.30
|
|
|
28
28
|
Requires-Dist: requests>=2.32
|
|
29
29
|
Provides-Extra: redis
|
|
30
30
|
Requires-Dist: redis; extra == "redis"
|
|
31
|
+
Provides-Extra: entra
|
|
32
|
+
Requires-Dist: msal>=1.28; extra == "entra"
|
|
31
33
|
Provides-Extra: dev
|
|
32
34
|
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
33
35
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
@@ -55,7 +57,9 @@ is included); the caller is authenticated by your transport (the
|
|
|
55
57
|
proxy-verified client certificate, by default); Gargoyle sits in front of
|
|
56
58
|
your MCP server and lets through only what both agreed to.
|
|
57
59
|
|
|
58
|
-
|
|
60
|
+
[](https://pypi.org/project/gargoyle-receipts/)
|
|
61
|
+
|
|
62
|
+
Status: pre-release (`0.5.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
|
|
59
63
|
still change before the first release; it is specified in [SPEC.md](SPEC.md).
|
|
60
64
|
|
|
61
65
|
## What a receipt gives you
|
|
@@ -242,6 +246,8 @@ chat channel can approve anything.
|
|
|
242
246
|
|
|
243
247
|
## Deploy it
|
|
244
248
|
|
|
249
|
+
pip install gargoyle-receipts
|
|
250
|
+
|
|
245
251
|
Three commands, no policy engine, no containers, no client changes:
|
|
246
252
|
|
|
247
253
|
gargoyle init /path/the/agent/may/touch # keys, config, rules.json
|
|
@@ -290,6 +296,8 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
290
296
|
| `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) |
|
|
291
297
|
| `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) |
|
|
292
298
|
| `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 |
|
|
299
|
+
| issuer `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 |
|
|
300
|
+
| `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` |
|
|
293
301
|
| `GARGOYLE_HOLD_WEBHOOK_URL`, `GARGOYLE_APPROVE_URL` | issuer: 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 |
|
|
294
302
|
| `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 |
|
|
295
303
|
|
|
@@ -298,7 +306,9 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
298
306
|
exchange for flagged calls (`/issue`, `/pending`, `/grants`, `/decline`,
|
|
299
307
|
`/record`, `/keys`) and the browser approval console at `/approve`. Policy
|
|
300
308
|
from a rules file or OPA; identity from a pinned key or, with
|
|
301
|
-
`GARGOYLE_ENTRA_TENANT`, the tenant's signing keys
|
|
309
|
+
`GARGOYLE_ENTRA_TENANT`, the tenant's signing keys (both at once when a
|
|
310
|
+
pinned key is also set, so init-minted approver tokens keep working while
|
|
311
|
+
users move to `gargoyle login`).
|
|
302
312
|
|
|
303
313
|
|
|
304
314
|
## Approval holds
|
{gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt
RENAMED
|
@@ -8,6 +8,7 @@ gargoyle/approver_cli.py
|
|
|
8
8
|
gargoyle/approver_trust.py
|
|
9
9
|
gargoyle/cli.py
|
|
10
10
|
gargoyle/consumer.py
|
|
11
|
+
gargoyle/entra_login.py
|
|
11
12
|
gargoyle/envelope.py
|
|
12
13
|
gargoyle/evidence.py
|
|
13
14
|
gargoyle/execution.py
|
|
@@ -41,6 +42,7 @@ tests/test_cli.py
|
|
|
41
42
|
tests/test_console_js.py
|
|
42
43
|
tests/test_consumer.py
|
|
43
44
|
tests/test_entra_identity.py
|
|
45
|
+
tests/test_entra_login.py
|
|
44
46
|
tests/test_evidence.py
|
|
45
47
|
tests/test_execution.py
|
|
46
48
|
tests/test_fs_server.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gargoyle-receipts"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0.dev0"
|
|
8
8
|
description = "Signed, single-use, argument-bound receipts and human-approval holds for agent tool calls."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -34,6 +34,7 @@ dependencies = [
|
|
|
34
34
|
|
|
35
35
|
[project.optional-dependencies]
|
|
36
36
|
redis = ["redis"]
|
|
37
|
+
entra = ["msal>=1.28"]
|
|
37
38
|
dev = ["httpx>=0.27", "pytest>=8", "redis"]
|
|
38
39
|
|
|
39
40
|
[project.urls]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""`gargoyle login`: the middleware's token names a person. MSAL is faked:
|
|
2
|
+
a device flow that signs in alice, a cache that persists, a silent refresh
|
|
3
|
+
that works until the account is gone."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from gargoyle.entra_login import EntraTokenSource, LoginRequired, device_code_login, token_source_from_env
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FakeCache:
|
|
15
|
+
def __init__(self): self.state = "{}"; self.has_state_changed = False
|
|
16
|
+
def deserialize(self, text): self.state = text
|
|
17
|
+
def serialize(self): return self.state
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FakeMsal:
|
|
21
|
+
SerializableTokenCache = FakeCache
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FakeApp:
|
|
25
|
+
"""Enough of msal.PublicClientApplication for the two flows."""
|
|
26
|
+
sign_in_user = "alice@contoso.com"
|
|
27
|
+
refresh_ok = True
|
|
28
|
+
|
|
29
|
+
def __init__(self, client_id, authority, token_cache):
|
|
30
|
+
self.client_id, self.authority, self.cache = client_id, authority, token_cache
|
|
31
|
+
self.calls = []
|
|
32
|
+
|
|
33
|
+
def initiate_device_flow(self, scopes):
|
|
34
|
+
self.calls.append(("device_flow", scopes))
|
|
35
|
+
return {"user_code": "ABCD-1234", "message": "To sign in, use a web browser to open https://microsoft.com/devicelogin and enter the code ABCD-1234"}
|
|
36
|
+
|
|
37
|
+
def acquire_token_by_device_flow(self, flow):
|
|
38
|
+
self.cache.state = json.dumps({"accounts": [self.sign_in_user]}); self.cache.has_state_changed = True
|
|
39
|
+
return {"access_token": "at-1", "expires_in": 3600, "id_token_claims": {"preferred_username": self.sign_in_user}}
|
|
40
|
+
|
|
41
|
+
def get_accounts(self):
|
|
42
|
+
return [{"username": u} for u in json.loads(self.cache.state).get("accounts", [])]
|
|
43
|
+
|
|
44
|
+
def acquire_token_silent(self, scopes, account):
|
|
45
|
+
self.calls.append(("silent", scopes, account["username"]))
|
|
46
|
+
return {"access_token": "at-refreshed"} if self.refresh_ok else {"error": "interaction_required", "error_description": "MFA"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_device_code_login_persists_the_cache_and_names_the_user(tmp_path, capsys):
|
|
50
|
+
cache = tmp_path / "cache.json"
|
|
51
|
+
shown = []
|
|
52
|
+
r = device_code_login("contoso.onmicrosoft.com", "client-1", "api://issuer/access", str(cache),
|
|
53
|
+
app_factory=FakeApp, msal_module=FakeMsal, prompt=shown.append)
|
|
54
|
+
assert r["principal"] == "alice@contoso.com" and r["expires_in"] == 3600
|
|
55
|
+
assert "ABCD-1234" in shown[0]
|
|
56
|
+
assert json.loads(cache.read_text())["accounts"] == ["alice@contoso.com"]
|
|
57
|
+
if os.name == "posix":
|
|
58
|
+
assert oct(cache.stat().st_mode & 0o777) == "0o600"
|
|
59
|
+
|
|
60
|
+
# The source refreshes silently from that cache, and knows who it is.
|
|
61
|
+
src = EntraTokenSource("contoso.onmicrosoft.com", "client-1", "api://issuer/access", str(cache),
|
|
62
|
+
app_factory=FakeApp, msal_module=FakeMsal)
|
|
63
|
+
assert src.principal == "alice@contoso.com" and src.token() == "at-refreshed"
|
|
64
|
+
assert src._app.calls[-1] == ("silent", ["api://issuer/access"], "alice@contoso.com")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_no_account_or_failed_refresh_is_login_required(tmp_path):
|
|
68
|
+
src = EntraTokenSource("t", "c", "s", str(tmp_path / "empty.json"), app_factory=FakeApp, msal_module=FakeMsal)
|
|
69
|
+
with pytest.raises(LoginRequired):
|
|
70
|
+
src.token()
|
|
71
|
+
cache = tmp_path / "cache.json"
|
|
72
|
+
device_code_login("t", "c", "s", str(cache), app_factory=FakeApp, msal_module=FakeMsal, prompt=lambda m: None)
|
|
73
|
+
|
|
74
|
+
class Stale(FakeApp):
|
|
75
|
+
refresh_ok = False
|
|
76
|
+
with pytest.raises(LoginRequired, match="MFA"):
|
|
77
|
+
EntraTokenSource("t", "c", "s", str(cache), app_factory=Stale, msal_module=FakeMsal).token()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_middleware_refuses_with_login_required_and_uses_a_fresh_token_per_call(tmp_path):
|
|
81
|
+
from gargoyle.mcp_server import REF_TARGET_LOGIN_REQUIRED, IssuerClient
|
|
82
|
+
cache = tmp_path / "cache.json"
|
|
83
|
+
device_code_login("t", "c", "s", str(cache), app_factory=FakeApp, msal_module=FakeMsal, prompt=lambda m: None)
|
|
84
|
+
seen = []
|
|
85
|
+
|
|
86
|
+
class Http:
|
|
87
|
+
def post(self, url, json=None, headers=None, timeout=None):
|
|
88
|
+
seen.append(headers["Authorization"])
|
|
89
|
+
return type("R", (), {"status_code": 200, "headers": {}, "json": lambda self: {"issued": True, "envelope": {"x": 1}}})()
|
|
90
|
+
src = EntraTokenSource("t", "c", "s", str(cache), app_factory=FakeApp, msal_module=FakeMsal)
|
|
91
|
+
client = IssuerClient("http://i", src, http=Http())
|
|
92
|
+
assert client.issue("mcp://x", "read_file", {})["envelope"] == {"x": 1}
|
|
93
|
+
assert seen == ["Bearer at-refreshed"]
|
|
94
|
+
empty = EntraTokenSource("t", "c", "s", str(tmp_path / "none.json"), app_factory=FakeApp, msal_module=FakeMsal)
|
|
95
|
+
assert IssuerClient("http://i", empty, http=Http()).issue("mcp://x", "read_file", {})["reason"] == REF_TARGET_LOGIN_REQUIRED
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_token_source_from_env_requires_the_whole_set():
|
|
99
|
+
assert token_source_from_env({}) is None
|
|
100
|
+
with pytest.raises(RuntimeError):
|
|
101
|
+
token_source_from_env({"GARGOYLE_MCP_ENTRA_TENANT": "t"})
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_login_command_rewrites_the_client_config(tmp_path, monkeypatch, capsys):
|
|
105
|
+
from gargoyle import cli
|
|
106
|
+
from gargoyle import entra_login
|
|
107
|
+
out = tmp_path / "deploy"
|
|
108
|
+
assert cli.main(["init", str(tmp_path / "p"), "--out", str(out), "--entra-tenant", "contoso.onmicrosoft.com",
|
|
109
|
+
"--entra-audience", "api-client-id"]) == 0
|
|
110
|
+
ienv = cli._read_env(out / "issuer.env")
|
|
111
|
+
assert ienv["GARGOYLE_ENTRA_TENANT"] == "contoso.onmicrosoft.com" and ienv["GARGOYLE_ISSUER_AUDIENCE"] == "api-client-id"
|
|
112
|
+
before = cli._read_env(out / "middleware.env"); assert "GARGOYLE_MCP_CLIENT_TOKEN" in before
|
|
113
|
+
monkeypatch.setattr(entra_login, "_msal", lambda: FakeMsal)
|
|
114
|
+
monkeypatch.setattr(entra_login, "_app", lambda tenant, client_id, cache, app_factory=None: FakeApp(client_id, tenant, cache.cache))
|
|
115
|
+
assert cli.main(["login", "--tenant", "contoso.onmicrosoft.com", "--client-id", "pub-client", "--scope", "api://api-client-id/access",
|
|
116
|
+
"--out", str(out)]) == 0
|
|
117
|
+
env = cli._read_env(out / "middleware.env")
|
|
118
|
+
assert "GARGOYLE_MCP_CLIENT_TOKEN" not in env
|
|
119
|
+
assert env["GARGOYLE_MCP_PRINCIPAL"] == "alice@contoso.com" and env["GARGOYLE_MCP_ENTRA_CLIENT_ID"] == "pub-client"
|
|
120
|
+
assert env["GARGOYLE_MCP_ENTRA_TOKEN_CACHE"] == str(out / "entra-token-cache.json")
|
|
121
|
+
mcp = json.loads((out / "mcp-config.json").read_text())["mcpServers"]["filesystem-gargoyle"]["env"]
|
|
122
|
+
assert "GARGOYLE_MCP_CLIENT_TOKEN" not in mcp and mcp["GARGOYLE_MCP_PRINCIPAL"] == "alice@contoso.com"
|
|
123
|
+
assert "signed in as alice@contoso.com" in capsys.readouterr().out
|
|
124
|
+
# Everything the middleware needs to pick the Entra source is there.
|
|
125
|
+
for k in ("GARGOYLE_MCP_ENTRA_TENANT", "GARGOYLE_MCP_ENTRA_CLIENT_ID", "GARGOYLE_MCP_ENTRA_SCOPE", "GARGOYLE_MCP_ENTRA_TOKEN_CACHE"):
|
|
126
|
+
assert env[k] and mcp[k] == env[k]
|
|
@@ -61,3 +61,9 @@ def test_identity_flows_into_the_receipt():
|
|
|
61
61
|
"on_behalf_of": who["on_behalf_of"]}, signing_key=key, key_id="k1")
|
|
62
62
|
assert r.envelope["request_context"]["principal"] == "agent-7"
|
|
63
63
|
assert r.envelope["request_context"]["on_behalf_of"] == "user-42"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_several_audiences_are_accepted_as_a_list_or_comma_string():
|
|
67
|
+
assert principal_from_token(token(sub="agent-7", aud="gargoyle-issuer"), IDP, audience="gargoyle-issuer,88d5", now=NOW)["reason"] is None
|
|
68
|
+
assert principal_from_token(token(sub="agent-7", aud="88d5"), IDP, audience="gargoyle-issuer, 88d5", now=NOW)["reason"] is None
|
|
69
|
+
assert principal_from_token(token(sub="agent-7", aud="other"), IDP, audience=["gargoyle-issuer", "88d5"], now=NOW)["reason"] == REF_JWS_AUDIENCE_MISMATCH
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/published_record_source.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_published_record.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_typescript_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|