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.
Files changed (65) hide show
  1. {gargoyle_receipts-0.4.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.5.0.dev0}/PKG-INFO +13 -3
  2. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/README.md +10 -2
  3. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/cli.py +43 -0
  4. gargoyle_receipts-0.5.0.dev0/gargoyle/entra_login.py +137 -0
  5. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/identity.py +7 -3
  6. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/issuer_service.py +7 -3
  7. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/mcp_server.py +30 -12
  8. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +13 -3
  9. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +2 -0
  10. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle_receipts.egg-info/requires.txt +3 -0
  11. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/pyproject.toml +2 -1
  12. gargoyle_receipts-0.5.0.dev0/tests/test_entra_login.py +126 -0
  13. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_identity.py +6 -0
  14. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/LICENSE +0 -0
  15. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/NOTICE +0 -0
  16. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/__init__.py +0 -0
  17. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/approval.py +0 -0
  18. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/approver_cli.py +0 -0
  19. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/approver_trust.py +0 -0
  20. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/consumer.py +0 -0
  21. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/envelope.py +0 -0
  22. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/evidence.py +0 -0
  23. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/execution.py +0 -0
  24. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/fs_server.py +0 -0
  25. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/issuer.py +0 -0
  26. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/jws.py +0 -0
  27. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/key_record_source.py +0 -0
  28. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/notify.py +0 -0
  29. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/opa.py +0 -0
  30. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/pending_store.py +0 -0
  31. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/policy.py +0 -0
  32. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/published_record_source.py +0 -0
  33. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/replay_cache.py +0 -0
  34. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/rules.py +0 -0
  35. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/static/approve.html +0 -0
  36. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/static/approve.js +0 -0
  37. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle/verifier.py +0 -0
  38. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  39. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  40. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  41. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/setup.cfg +0 -0
  42. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_annotations.py +0 -0
  43. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_approver_cli.py +0 -0
  44. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_canonical.py +0 -0
  45. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_cli.py +0 -0
  46. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_console_js.py +0 -0
  47. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_consumer.py +0 -0
  48. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_entra_identity.py +0 -0
  49. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_evidence.py +0 -0
  50. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_execution.py +0 -0
  51. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_fs_server.py +0 -0
  52. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_issuer_service.py +0 -0
  53. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_jws.py +0 -0
  54. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_mcp_proxy.py +0 -0
  55. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_mcp_self_issue.py +0 -0
  56. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_mcp_server.py +0 -0
  57. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_notify.py +0 -0
  58. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_opa.py +0 -0
  59. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_policy.py +0 -0
  60. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_published_record.py +0 -0
  61. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_rules.py +0 -0
  62. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_stdio_encoding.py +0 -0
  63. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_threshold.py +0 -0
  64. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_typescript_conformance.py +0 -0
  65. {gargoyle_receipts-0.4.0.dev0 → gargoyle_receipts-0.5.0.dev0}/tests/test_wire_spec.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.4.0.dev0
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
- Status: pre-release (`0.4.0.dev0`). The wire format is `gargoyle/3` and may
60
+ [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](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
- Status: pre-release (`0.4.0.dev0`). The wire format is `gargoyle/3` and may
22
+ [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](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: Optional[str] = None, now: Optional[datetime] = None) -> Dict[str, Any]:
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
- allowed = aud if isinstance(aud, list) else [aud]
94
- if audience not in allowed:
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
- idp_public_keys=(entra_keys(tenant) if tenant else
291
- {required[ENV_IDP_KEY_ID]:
292
- Ed25519PublicKey.from_public_bytes(bytes.fromhex(required[ENV_IDP_PUBLIC_KEY_HEX]))}),
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" # the evidence log: one hash-chained JSON line per decision, honored or refused
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: str, *, http: Any = None, approval_timeout: float = 300.0,
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._headers = {"Authorization": "Bearer " + token}
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 and
577
- GARGOYLE_MCP_CLIENT_TOKEN are both set; None otherwise. One without the
578
- other is a configuration error and raises."""
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
- if not url and not token:
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 (url and token):
585
- raise RuntimeError("%s and %s go together" % (ENV_ISSUER_URL, ENV_CLIENT_TOKEN))
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.4.0.dev0
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
- Status: pre-release (`0.4.0.dev0`). The wire format is `gargoyle/3` and may
60
+ [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](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
@@ -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
@@ -8,5 +8,8 @@ httpx>=0.27
8
8
  pytest>=8
9
9
  redis
10
10
 
11
+ [entra]
12
+ msal>=1.28
13
+
11
14
  [redis]
12
15
  redis
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gargoyle-receipts"
7
- version = "0.4.0.dev0"
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