gargoyle-receipts 0.11.0.dev0__tar.gz → 0.13.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 (80) hide show
  1. {gargoyle_receipts-0.11.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.13.0.dev0}/PKG-INFO +17 -2
  2. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/README.md +16 -1
  3. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/cli.py +33 -0
  4. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/consumer.py +21 -1
  5. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/evidence.py +9 -4
  6. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/execution.py +9 -2
  7. gargoyle_receipts-0.13.0.dev0/gargoyle/holdstore.py +116 -0
  8. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/issuer_service.py +141 -18
  9. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/mcp_server.py +105 -9
  10. gargoyle_receipts-0.13.0.dev0/gargoyle/plans.py +209 -0
  11. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/policy.py +1 -0
  12. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/rules.py +6 -2
  13. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/static/approve.html +32 -0
  14. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +17 -2
  15. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +3 -0
  16. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/pyproject.toml +1 -1
  17. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_issuer_service.py +30 -0
  18. gargoyle_receipts-0.13.0.dev0/tests/test_plans.py +187 -0
  19. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/LICENSE +0 -0
  20. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/NOTICE +0 -0
  21. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/__init__.py +0 -0
  22. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/adapters.py +0 -0
  23. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/approval.py +0 -0
  24. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/approver_cli.py +0 -0
  25. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/approver_trust.py +0 -0
  26. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/approvers.py +0 -0
  27. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/entra_login.py +0 -0
  28. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/envelope.py +0 -0
  29. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/fs_server.py +0 -0
  30. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/identity.py +0 -0
  31. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/issuer.py +0 -0
  32. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/jws.py +0 -0
  33. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/key_record_source.py +0 -0
  34. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/notify.py +0 -0
  35. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/opa.py +0 -0
  36. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/pending_store.py +0 -0
  37. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/published_record_source.py +0 -0
  38. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/remote.py +0 -0
  39. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/replay_cache.py +0 -0
  40. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/revert.py +0 -0
  41. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
  42. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/state.py +0 -0
  43. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/static/approve.js +0 -0
  44. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/verifier.py +0 -0
  45. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  46. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  47. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  48. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  49. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/setup.cfg +0 -0
  50. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_annotations.py +0 -0
  51. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_approver_cli.py +0 -0
  52. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_approvers.py +0 -0
  53. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_canonical.py +0 -0
  54. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_cli.py +0 -0
  55. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_console_js.py +0 -0
  56. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_consumer.py +0 -0
  57. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_entra_identity.py +0 -0
  58. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_entra_login.py +0 -0
  59. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_evidence.py +0 -0
  60. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_execution.py +0 -0
  61. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_fs_server.py +0 -0
  62. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_graph.py +0 -0
  63. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_historical.py +0 -0
  64. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_identity.py +0 -0
  65. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_jws.py +0 -0
  66. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_mcp_proxy.py +0 -0
  67. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_mcp_self_issue.py +0 -0
  68. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_mcp_server.py +0 -0
  69. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_notify.py +0 -0
  70. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_opa.py +0 -0
  71. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_policy.py +0 -0
  72. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_published_record.py +0 -0
  73. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_remote.py +0 -0
  74. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_revert.py +0 -0
  75. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_rules.py +0 -0
  76. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_state.py +0 -0
  77. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_stdio_encoding.py +0 -0
  78. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_threshold.py +0 -0
  79. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_typescript_conformance.py +0 -0
  80. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.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.11.0.dev0
3
+ Version: 0.13.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
@@ -59,7 +59,7 @@ your MCP server and lets through only what both agreed to.
59
59
 
60
60
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
61
61
 
62
- Status: pre-release (`0.11.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
62
+ Status: pre-release (`0.13.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
63
63
  still change before the first release; it is specified in [SPEC.md](SPEC.md).
64
64
 
65
65
  ## What a receipt gives you
@@ -292,6 +292,19 @@ and `gargoyle audit` verifies them later offline.
292
292
  and through the alternative with OPA in Docker for sites that run a policy
293
293
  engine.
294
294
 
295
+ ## Scoped grants
296
+
297
+ One card per held call is right for a mailbox rule and wrong for a long
298
+ task. The assistant can ask for a plan (`gargoyle_plan`): a list of steps,
299
+ each a tool with exact arguments or argument patterns, and a note saying
300
+ why. The issuer judges every step under the policy, signs the plan, and
301
+ holds it as one card; the approver signs once; each later call matching
302
+ an unused step runs without a card of its own until the plan expires.
303
+ Every step is still bound, claimed once, and recorded with the plan and
304
+ step that released it. The rules file decides what may be planned
305
+ (`"plan": false` keeps a tool individual; sends should be), because a
306
+ plan approves scope rather than content.
307
+
295
308
  ## Microsoft 365 and other targets: adapters
296
309
 
297
310
  What a held call changes is the adapter's business (`gargoyle.adapters`):
@@ -355,6 +368,8 @@ cannot attach receipts work unchanged. Configuration is by environment:
355
368
  | `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` |
356
369
  | issuer `GARGOYLE_ENTRA_CLIENT_ID`, `GARGOYLE_ENTRA_SCOPE` | lets the console sign approvers in with Entra (code flow with PKCE, no library); the browser generates a non-exportable key and enrolls it under the UPN |
357
370
  | issuer `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys) and which Entra app role (default `Approver`) or groups may enroll |
371
+ | issuer `GARGOYLE_HOLDS_PATH` | SQLite file for holds, grants, declines and plans, so a restart loses nothing a person is acting on (`gargoyle issuer` sets `holds.sqlite` beside the keys); `GET /health` reports it |
372
+ | issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
358
373
  | `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 |
359
374
  | `GARGOYLE_MCP_STATE_PROBE` | `filesystem` (default) binds a held file write to the file's current state: the approver sees the change as a diff, the call refuses if the file moved before the run (`REF_VERIFY_STATE_MISMATCH`), and the execution receipt records pre and post state; `none` disables |
360
375
  | `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
@@ -21,7 +21,7 @@ your MCP server and lets through only what both agreed to.
21
21
 
22
22
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
23
23
 
24
- Status: pre-release (`0.11.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
24
+ Status: pre-release (`0.13.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
25
25
  still change before the first release; it is specified in [SPEC.md](SPEC.md).
26
26
 
27
27
  ## What a receipt gives you
@@ -254,6 +254,19 @@ and `gargoyle audit` verifies them later offline.
254
254
  and through the alternative with OPA in Docker for sites that run a policy
255
255
  engine.
256
256
 
257
+ ## Scoped grants
258
+
259
+ One card per held call is right for a mailbox rule and wrong for a long
260
+ task. The assistant can ask for a plan (`gargoyle_plan`): a list of steps,
261
+ each a tool with exact arguments or argument patterns, and a note saying
262
+ why. The issuer judges every step under the policy, signs the plan, and
263
+ holds it as one card; the approver signs once; each later call matching
264
+ an unused step runs without a card of its own until the plan expires.
265
+ Every step is still bound, claimed once, and recorded with the plan and
266
+ step that released it. The rules file decides what may be planned
267
+ (`"plan": false` keeps a tool individual; sends should be), because a
268
+ plan approves scope rather than content.
269
+
257
270
  ## Microsoft 365 and other targets: adapters
258
271
 
259
272
  What a held call changes is the adapter's business (`gargoyle.adapters`):
@@ -317,6 +330,8 @@ cannot attach receipts work unchanged. Configuration is by environment:
317
330
  | `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` |
318
331
  | issuer `GARGOYLE_ENTRA_CLIENT_ID`, `GARGOYLE_ENTRA_SCOPE` | lets the console sign approvers in with Entra (code flow with PKCE, no library); the browser generates a non-exportable key and enrolls it under the UPN |
319
332
  | issuer `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys) and which Entra app role (default `Approver`) or groups may enroll |
333
+ | issuer `GARGOYLE_HOLDS_PATH` | SQLite file for holds, grants, declines and plans, so a restart loses nothing a person is acting on (`gargoyle issuer` sets `holds.sqlite` beside the keys); `GET /health` reports it |
334
+ | issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
320
335
  | `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 |
321
336
  | `GARGOYLE_MCP_STATE_PROBE` | `filesystem` (default) binds a held file write to the file's current state: the approver sees the change as a diff, the call refuses if the file moved before the run (`REF_VERIFY_STATE_MISMATCH`), and the execution receipt records pre and post state; `none` disables |
322
337
  | `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
@@ -26,6 +26,8 @@ policy engine, a container runtime, or any client changes.
26
26
  approver's machine); with --approvers N also approver-2.env ...
27
27
  approver-web.json the same key and token in the form the browser approval
28
28
  console loads (private; same machine as approver.env)
29
+ gargoyle-issuer.service, apache-gargoyle.conf
30
+ a systemd unit and an Apache virtual host for the issuer
29
31
  mcp-config.json the server entry to paste into the MCP client
30
32
  public-keys.json issuer, approver and executor public keys, for audits
31
33
 
@@ -147,6 +149,33 @@ def cmd_init(args) -> int:
147
149
  "issuer": {"issuer-1": issuer_pub}, "approver": {a[0]: a[3] for a in approvers},
148
150
  "executor": {"executor-1": executor_pub}, "identity_provider": {"idp-1": idp_pub},
149
151
  }, indent=2) + "\n")
152
+ _write(out / "gargoyle-issuer.service", """[Unit]
153
+ Description=Gargoyle issuing service
154
+ After=network-online.target
155
+
156
+ [Service]
157
+ User=gargoyle
158
+ WorkingDirectory=%(out)s
159
+ ExecStart=%(python)s -m gargoyle.cli issuer --out %(out)s --host 127.0.0.1 --port %(port)d
160
+ Restart=on-failure
161
+ RestartSec=3
162
+
163
+ [Install]
164
+ WantedBy=multi-user.target
165
+ """ % {"out": str(out), "python": sys.executable, "port": args.port})
166
+ _write(out / "apache-gargoyle.conf", """# Apache in front of the issuer: TLS from a trusted CA, one virtual host.
167
+ <VirtualHost *:443>
168
+ ServerName %(host)s
169
+ SSLEngine on
170
+ SSLCertificateFile /etc/ssl/certs/gargoyle.crt
171
+ SSLCertificateKeyFile /etc/ssl/private/gargoyle.key
172
+ ProxyPreserveHost On
173
+ ProxyPass / http://127.0.0.1:%(port)d/
174
+ ProxyPassReverse / http://127.0.0.1:%(port)d/
175
+ ErrorLog ${APACHE_LOG_DIR}/gargoyle-error.log
176
+ CustomLog ${APACHE_LOG_DIR}/gargoyle-access.log combined
177
+ </VirtualHost>
178
+ """ % {"host": (args.issuer_url or "gargoyle.example").replace("https://", "").replace("http://", "").split("/")[0].split(":")[0], "port": args.port})
150
179
  upstream = (["npx", "-y", "@modelcontextprotocol/server-filesystem", allowed] if args.upstream == "npx-filesystem"
151
180
  else [client_python, "-m", "gargoyle.fs_server", allowed])
152
181
  _write(out / "mcp-config.json", json.dumps({"mcpServers": {"filesystem-gargoyle": {
@@ -260,6 +289,8 @@ def cmd_issuer(args) -> int:
260
289
  out = Path(args.out).resolve()
261
290
  os.environ.update(_read_env(out / "issuer.env"))
262
291
  os.environ.setdefault("GARGOYLE_APPROVERS_PATH", str(out / "approvers.json")) # enrolled approvers live beside the keys
292
+ os.environ.setdefault("GARGOYLE_HOLDS_PATH", str(out / "holds.sqlite")) # holds, grants, declines, plans survive restarts
293
+ os.environ.setdefault("GARGOYLE_LOG_JSON", "1")
263
294
  app = app_from_env()
264
295
  # The middleware reads the policy pin from a file; write it next to the
265
296
  # config so the client-side entry finds it at the configured path.
@@ -311,6 +342,8 @@ def cmd_audit(args) -> int:
311
342
  why = (" (%s)" % r["verdict"]) if r["kind"] == "refused" and r["verdict"] else ""
312
343
  if r.get("released_by"):
313
344
  why += " released by " + ", ".join(r["released_by"])
345
+ if r.get("plan"):
346
+ why += " (plan %s step %d)" % (str(r["plan"].get("plan_id") or "?")[:8], r["plan"].get("step", -1) + 1)
314
347
  if r.get("state"):
315
348
  # A state-bound call: the target as it was approved, and as the run left it.
316
349
  pre, post = (r["state"].get("pre") or "")[:8], (r["state"].get("post") or "")[:8]
@@ -273,6 +273,7 @@ class Consumer:
273
273
  *,
274
274
  grant: Any = None,
275
275
  now: Optional[datetime] = None,
276
+ plan_release: Any = None,
276
277
  ) -> Verdict:
277
278
  """Decide whether to honor `envelope` for `call` ({"tool", "args",
278
279
  "principal"}, the principal being the one this consumer
@@ -321,7 +322,26 @@ class Consumer:
321
322
  # inside the signed region, so they are the issuer's word, not the
322
323
  # caller's. The grant is checked and claimed before the receipt is
323
324
  # claimed, so a refused grant leaves the receipt unspent.
324
- if envelope["requires_approval"]:
325
+ if envelope["requires_approval"] and plan_release is not None:
326
+ # A scoped grant (gargoyle.plans): the plan's step is claimed once
327
+ # in place of a per-call grant id.
328
+ from gargoyle.plans import verify_plan_release
329
+ approver_keys = self._approver_keys(key_record_view, envelope.get("issuer_key_id"), now)
330
+ if not approver_keys:
331
+ return Verdict(False, REF_TARGET_NO_APPROVER_TRUST)
332
+ r = verify_plan_release(plan_release, envelope, call, issuer_public_keys=pinned_public_keys,
333
+ approver_public_keys=approver_keys, now=now, clock_skew=self.clock_skew)
334
+ if not r["accepted"]:
335
+ return Verdict(False, r["reason"])
336
+ plan = plan_release["plan"]
337
+ try:
338
+ plan_exp = datetime.fromisoformat(plan["not_after"]) + self.clock_skew
339
+ except (ValueError, TypeError, KeyError):
340
+ plan_exp = None
341
+ if not self.replay_cache.check_and_claim("plan:%s:%d" % (plan["plan_id"], r["step"]), plan_exp, now=now):
342
+ from gargoyle.plans import REF_PLAN_STEP_CONSUMED
343
+ return Verdict(False, REF_PLAN_STEP_CONSUMED)
344
+ elif envelope["requires_approval"]:
325
345
  grants = as_grant_list(grant)
326
346
  if not grants:
327
347
  return Verdict(False, REF_TARGET_APPROVAL_REQUIRED)
@@ -192,7 +192,7 @@ def verify_log(
192
192
  rc = rc if isinstance(rc, dict) else {}
193
193
  report.append({"seq": None, "kind": None, "tool": rc.get("tool"), "principal": rc.get("principal"),
194
194
  "accepted": False, "reason": REF_EVIDENCE_UNCHAINED if legacy else REF_EVIDENCE_MALFORMED,
195
- "verdict": None, "released_by": [], "state": None, "client": None})
195
+ "verdict": None, "released_by": [], "state": None, "client": None, "plan": None})
196
196
  all_ok = False
197
197
  continue
198
198
  e = raw
@@ -218,14 +218,19 @@ def verify_log(
218
218
  now=_parse_at(e["at"]), historical=True)
219
219
  ok, reason = p["accepted"], (ACCEPT_ENTRY_VERIFIED if p["accepted"] else p["reason"])
220
220
  counts[e["kind"]] += 1
221
- released_by = [g.get("approver_key_id") for g in (e["grant"] if isinstance(e["grant"], list) else [e["grant"]])
222
- if isinstance(g, dict)] if e["grant"] is not None else []
221
+ plan_ref = None
222
+ grant_set = e["grant"]
223
+ if isinstance(grant_set, dict) and "plan" in grant_set and "step" in grant_set:
224
+ plan_ref = {"plan_id": (grant_set["plan"] or {}).get("plan_id"), "step": grant_set["step"]}
225
+ grant_set = grant_set.get("grants")
226
+ released_by = [g.get("approver_key_id") for g in (grant_set if isinstance(grant_set, list) else [grant_set])
227
+ if isinstance(g, dict)] if grant_set is not None else []
223
228
  ex = e["execution"] if isinstance(e["execution"], dict) else {}
224
229
  state = ({"pre": ex.get("pre_state_sha256"), "post": ex.get("post_state_sha256")}
225
230
  if ex.get("pre_state_sha256") or ex.get("post_state_sha256") else None)
226
231
  report.append({"seq": e["seq"], "kind": e["kind"], "tool": e["tool"], "principal": e["principal"],
227
232
  "accepted": ok, "reason": reason, "verdict": e["reason"], "released_by": released_by,
228
- "state": state, "client": e.get("client")})
233
+ "state": state, "client": e.get("client"), "plan": plan_ref})
229
234
  all_ok = all_ok and ok
230
235
  # The chain continues from what the file says, so one bad entry is
231
236
  # reported once rather than cascading into every later one.
@@ -218,10 +218,17 @@ def verify_chain(
218
218
  return {"accepted": False, "reason": provenance["reason"], "steps": steps}
219
219
 
220
220
  if envelope["requires_approval"]:
221
- if not as_grant_list(grant):
221
+ from gargoyle.plans import is_plan_release, verify_plan_release
222
+ if is_plan_release(grant):
223
+ # Released by a scoped grant: the plan, its grants and the step,
224
+ # re-matched against the arguments the release recorded.
225
+ g = verify_plan_release(grant, envelope, {}, issuer_public_keys=issuer_public_keys,
226
+ approver_public_keys=approver_public_keys or {}, now=executed_at)
227
+ elif not as_grant_list(grant):
222
228
  steps.append(("approval", REF_EXEC_GRANT_MISSING))
223
229
  return {"accepted": False, "reason": REF_EXEC_GRANT_MISSING, "steps": steps}
224
- g = verify_grants(grant, envelope=envelope, approver_public_keys=approver_public_keys or {}, now=executed_at)
230
+ else:
231
+ g = verify_grants(grant, envelope=envelope, approver_public_keys=approver_public_keys or {}, now=executed_at)
225
232
  steps.append(("approval", g["reason"]))
226
233
  if not g["accepted"]:
227
234
  return {"accepted": False, "reason": g["reason"], "steps": steps}
@@ -0,0 +1,116 @@
1
+ """Persistent issuer state: holds, grants, declines and plans survive a restart.
2
+
3
+ The issuing service keeps little state, but what it keeps is what a human
4
+ is in the middle of acting on. In a lab the service was restarted a dozen
5
+ times in two days and each restart dropped the holds in flight; the
6
+ middleware then reported a timeout and the assistant retried. A client's
7
+ approver would see a card that did nothing. This module backs each of the
8
+ service's maps with a SQLite table (standard library, one file, one
9
+ process), so a restart changes nothing a person can see. With no path the
10
+ store is in memory, which is what the tests use.
11
+
12
+ Dict semantics are kept on purpose: the service code reads as before,
13
+ and a value is written back on assignment, never by mutating what a read
14
+ returned.
15
+ """
16
+
17
+ import json
18
+ import sqlite3
19
+ import threading
20
+ from collections.abc import MutableMapping, MutableSet
21
+ from typing import Any, Iterator, Optional
22
+
23
+ ENV_HOLDS_PATH = "GARGOYLE_HOLDS_PATH"
24
+
25
+
26
+ class HoldStore:
27
+ def __init__(self, path: Optional[str] = None) -> None:
28
+ self.path = path or ":memory:"
29
+ self._db = sqlite3.connect(self.path, check_same_thread=False, isolation_level=None)
30
+ self._lock = threading.RLock()
31
+ with self._lock:
32
+ self._db.execute("PRAGMA journal_mode=WAL") if self.path != ":memory:" else None
33
+ self._db.execute("CREATE TABLE IF NOT EXISTS kv (ns TEXT NOT NULL, k TEXT NOT NULL, v TEXT NOT NULL, PRIMARY KEY (ns, k))")
34
+
35
+ def dict(self, ns: str) -> "PersistentDict":
36
+ return PersistentDict(self, ns)
37
+
38
+ def set(self, ns: str) -> "PersistentSet":
39
+ return PersistentSet(self, ns)
40
+
41
+ # ---- primitives ----
42
+ def _get(self, ns: str, k: str) -> Any:
43
+ with self._lock:
44
+ row = self._db.execute("SELECT v FROM kv WHERE ns=? AND k=?", (ns, k)).fetchone()
45
+ return json.loads(row[0]) if row else None
46
+
47
+ def _has(self, ns: str, k: str) -> bool:
48
+ with self._lock:
49
+ return self._db.execute("SELECT 1 FROM kv WHERE ns=? AND k=?", (ns, k)).fetchone() is not None
50
+
51
+ def _put(self, ns: str, k: str, v: Any) -> None:
52
+ with self._lock:
53
+ self._db.execute("INSERT OR REPLACE INTO kv (ns, k, v) VALUES (?, ?, ?)", (ns, k, json.dumps(v, sort_keys=True)))
54
+
55
+ def _delete(self, ns: str, k: str) -> bool:
56
+ with self._lock:
57
+ cur = self._db.execute("DELETE FROM kv WHERE ns=? AND k=?", (ns, k))
58
+ return cur.rowcount > 0
59
+
60
+ def _keys(self, ns: str) -> list:
61
+ with self._lock:
62
+ return [r[0] for r in self._db.execute("SELECT k FROM kv WHERE ns=? ORDER BY rowid", (ns,)).fetchall()]
63
+
64
+ def _count(self, ns: str) -> int:
65
+ with self._lock:
66
+ return self._db.execute("SELECT COUNT(*) FROM kv WHERE ns=?", (ns,)).fetchone()[0]
67
+
68
+ def close(self) -> None:
69
+ with self._lock:
70
+ self._db.close()
71
+
72
+
73
+ class PersistentDict(MutableMapping):
74
+ def __init__(self, store: HoldStore, ns: str) -> None:
75
+ self._s, self._ns = store, ns
76
+
77
+ def __getitem__(self, k: str) -> Any:
78
+ if not self._s._has(self._ns, k):
79
+ raise KeyError(k)
80
+ return self._s._get(self._ns, k)
81
+
82
+ def __setitem__(self, k: str, v: Any) -> None:
83
+ self._s._put(self._ns, k, v)
84
+
85
+ def __delitem__(self, k: str) -> None:
86
+ if not self._s._delete(self._ns, k):
87
+ raise KeyError(k)
88
+
89
+ def __contains__(self, k: object) -> bool:
90
+ return isinstance(k, str) and self._s._has(self._ns, k)
91
+
92
+ def __iter__(self) -> Iterator[str]:
93
+ return iter(self._s._keys(self._ns))
94
+
95
+ def __len__(self) -> int:
96
+ return self._s._count(self._ns)
97
+
98
+
99
+ class PersistentSet(MutableSet):
100
+ def __init__(self, store: HoldStore, ns: str) -> None:
101
+ self._s, self._ns = store, ns
102
+
103
+ def __contains__(self, k: object) -> bool:
104
+ return isinstance(k, str) and self._s._has(self._ns, k)
105
+
106
+ def __iter__(self) -> Iterator[str]:
107
+ return iter(self._s._keys(self._ns))
108
+
109
+ def __len__(self) -> int:
110
+ return self._s._count(self._ns)
111
+
112
+ def add(self, k: str) -> None:
113
+ self._s._put(self._ns, k, True)
114
+
115
+ def discard(self, k: str) -> None:
116
+ self._s._delete(self._ns, k)
@@ -40,7 +40,8 @@ it again when the call runs. The pending set is in memory: one instance.
40
40
 
41
41
  import json
42
42
  import os
43
- from datetime import datetime, timezone
43
+ import sys
44
+ from datetime import datetime, timedelta, timezone
44
45
  from pathlib import Path
45
46
  from typing import Any, Callable, Dict, Optional
46
47
 
@@ -53,6 +54,9 @@ from gargoyle.approval import (REF_APPROVAL_APPROVER_NOT_ELIGIBLE, REF_APPROVAL_
53
54
  verify_grant)
54
55
  from gargoyle.envelope import approval_terms
55
56
  from gargoyle.state import state_sha256, valid_description
57
+ from gargoyle.holdstore import ENV_HOLDS_PATH, HoldStore
58
+ from gargoyle.plans import (REF_PLAN_MALFORMED, REF_PLAN_STEP_NOT_PLANNABLE, REF_PLAN_STEP_REFUSED, build_plan,
59
+ sign_plan, valid_step)
56
60
  from gargoyle.notify import notifier_from_env
57
61
  from gargoyle.approvers import (DEFAULT_APPROVER_ROLE, ENV_APPROVER_GROUPS, ENV_APPROVER_ROLE, ENV_APPROVERS_PATH,
58
62
  ApproverRegistry, may_enroll, valid_public_key_hex)
@@ -98,16 +102,33 @@ def build_issuer_app(
98
102
  approver_role: str = DEFAULT_APPROVER_ROLE,
99
103
  approver_groups: Optional[list] = None,
100
104
  console_entra: Optional[Dict[str, str]] = None,
105
+ hold_store: Optional[HoldStore] = None,
106
+ log_events: Optional[Callable[[Dict[str, Any]], None]] = None,
101
107
  ) -> FastAPI:
102
108
  """`on_hold(envelope, args)` is called once for every new hold (gargoyle.notify
103
109
  posts it to a webhook); it is notice, not admission, and its failure
104
110
  never affects the hold."""
105
111
  app = FastAPI(title="Gargoyle issuing service")
106
- app.state.pending: Dict[str, Dict[str, Any]] = {} # approval_request_id -> envelope
107
- app.state.pending_args: Dict[str, Any] = {} # approval_request_id -> the call's arguments
108
- app.state.pending_state: Dict[str, Any] = {} # approval_request_id -> the target's state when held
109
- app.state.grants: Dict[str, Dict[str, Any]] = {} # approval_request_id -> grant
110
- app.state.declined: set = set() # approval_request_ids a human refused
112
+ # Everything a human may be in the middle of acting on lives in the hold
113
+ # store (gargoyle.holdstore): SQLite on disk in a deployment, memory in tests.
114
+ store = hold_store if hold_store is not None else HoldStore(None)
115
+ app.state.store = store
116
+ app.state.pending = store.dict("pending") # approval_request_id -> envelope
117
+ app.state.pending_args = store.dict("pending_args") # approval_request_id -> the call's arguments
118
+ app.state.pending_state = store.dict("pending_state") # approval_request_id -> the target's state when held
119
+ app.state.grants = store.dict("grants") # approval_request_id -> [grant, ...]
120
+ app.state.declined = store.set("declined") # approval_request_ids a human refused
121
+ app.state.pending_plans = store.dict("pending_plans") # approval_request_id -> signed plan (gargoyle.plans)
122
+ app.state.started_at = now().isoformat()
123
+
124
+ def event(kind: str, **fields: Any) -> None:
125
+ """One structured line per decision the service makes, for the
126
+ operator's log; never the admission path."""
127
+ if log_events is not None:
128
+ try:
129
+ log_events({"event": kind, "at": now().isoformat(), **fields})
130
+ except Exception:
131
+ pass
111
132
  registry = approver_registry if approver_registry is not None else ApproverRegistry(None)
112
133
  static_approvers = dict(approver_public_keys or {})
113
134
 
@@ -163,8 +184,24 @@ def build_issuer_app(
163
184
  result = issue(policy, body["target_url"], interaction, signing_key=signing_key, key_id=key_id,
164
185
  ttl_seconds=ttl_seconds, now=now())
165
186
  if result.envelope is None:
187
+ event("refused", principal=who["principal"], tool=body["tool"], reason=result.decision.reason)
166
188
  return {"issued": False, "reason": result.decision.reason}
167
- if result.envelope["requires_approval"]:
189
+ covered = False
190
+ plan_id = body.get("plan_id")
191
+ if result.envelope["requires_approval"] and isinstance(plan_id, str):
192
+ # The executor says an approved plan covers this call. No card then:
193
+ # the executor enforces the plan (step single use, grants, expiry)
194
+ # and the console should not show a hold nobody needs to act on.
195
+ # A false claim costs nothing here: without a valid release the
196
+ # executor refuses the call.
197
+ from gargoyle.plans import match_step
198
+ for arid_p, plan in app.state.pending_plans.items():
199
+ if plan["plan_id"] == plan_id and _complete(arid_p) and match_step(plan, body["tool"], body["args"]) is not None:
200
+ covered = True
201
+ break
202
+ event("issued", principal=who["principal"], tool=body["tool"], held=bool(result.envelope["requires_approval"]),
203
+ covered=covered, decision_id=result.envelope.get("decision_id"))
204
+ if result.envelope["requires_approval"] and not covered:
168
205
  arid = result.envelope["approval_request_id"]
169
206
  app.state.pending[arid] = result.envelope
170
207
  app.state.pending_args[arid] = body["args"]
@@ -178,11 +215,76 @@ def build_issuer_app(
178
215
  sys.stderr.write("gargoyle: on_hold failed: %s\n" % exc)
179
216
  return {"issued": True, "envelope": result.envelope}
180
217
 
218
+ @app.post("/plans")
219
+ async def post_plan(request: Request, authorization: Optional[str] = Header(default=None)):
220
+ """A scoped grant request: every step judged by the policy; the plan
221
+ signed and held when any step would be. Steps with exact arguments
222
+ are judged on them; a pattern step is judged with its patterns as the
223
+ argument values, so a broad pattern fails a narrow rule."""
224
+ who = authenticate(authorization)
225
+ try:
226
+ body = await request.json()
227
+ except Exception:
228
+ raise _refuse(400, REF_ISSUER_BAD_REQUEST)
229
+ steps = body.get("steps") if isinstance(body, dict) else None
230
+ if (not isinstance(body, dict) or not isinstance(body.get("target_url"), str) or not isinstance(steps, list)
231
+ or not steps or len(steps) > 200 or not all(valid_step(s) or ("args" in s and isinstance(s.get("tool"), str)) for s in steps)
232
+ or not isinstance(body.get("note", ""), str)):
233
+ raise _refuse(400, REF_ISSUER_BAD_REQUEST)
234
+ from gargoyle.envelope import args_sha256
235
+ norm, held, required, approvers = [], False, 1, set()
236
+ for s in steps:
237
+ if "args" in s:
238
+ judged_args, step = s["args"], {"tool": s["tool"], "args_sha256": args_sha256(s["args"])}
239
+ elif "args_sha256" in s:
240
+ return {"issued": False, "reason": REF_PLAN_MALFORMED} # a hash alone cannot be judged
241
+ else:
242
+ judged_args, step = dict(s["match"]), {"tool": s["tool"], "match": dict(s["match"])}
243
+ interaction = {"tool": s["tool"], "args": judged_args, "principal": who["principal"],
244
+ "groups": who.get("groups", []), "roles": who.get("roles", [])}
245
+ try:
246
+ d = policy.decide(body["target_url"], interaction)
247
+ except Exception:
248
+ return {"issued": False, "reason": REF_PLAN_STEP_REFUSED}
249
+ if not d.eligible:
250
+ return {"issued": False, "reason": REF_PLAN_STEP_REFUSED, "step": len(norm)}
251
+ if d.requires_approval:
252
+ if not d.plannable:
253
+ return {"issued": False, "reason": REF_PLAN_STEP_NOT_PLANNABLE, "step": len(norm)}
254
+ held = True
255
+ required = max(required, d.approvals_required)
256
+ if d.approvers:
257
+ approvers.update(d.approvers)
258
+ norm.append(step)
259
+ import uuid
260
+ approval = None
261
+ if required > 1 or approvers:
262
+ approval = {"required": required, **({"approvers": sorted(approvers)} if approvers else {})}
263
+ plan = sign_plan(build_plan(plan_id=str(uuid.uuid4()), approval_request_id=str(uuid.uuid4()), target_url=body["target_url"],
264
+ principal=who["principal"], policy_pin=policy.pin(), steps=norm,
265
+ not_after=now() + timedelta(seconds=min(ttl_seconds * 6, 3600)), note=body.get("note", ""),
266
+ approval=approval), signing_key, key_id)
267
+ if held:
268
+ app.state.pending_plans[plan["approval_request_id"]] = plan
269
+ event("plan", principal=who["principal"], steps=len(norm), held=held, plan_id=plan["plan_id"])
270
+ return {"issued": True, "plan": plan, "held": held}
271
+
272
+ def _plan_terms(plan):
273
+ return approval_terms({"approval": plan["approval"]} if "approval" in plan else {})
274
+
181
275
  @app.get("/pending")
182
276
  async def pending(authorization: Optional[str] = Header(default=None)):
183
277
  authenticate(authorization)
184
278
  current = now()
185
279
  live = {}
280
+ for arid, plan in list(app.state.pending_plans.items()):
281
+ if datetime.fromisoformat(plan["not_after"]) <= current:
282
+ del app.state.pending_plans[arid]
283
+ app.state.grants.pop(arid, None)
284
+ elif len(app.state.grants.get(arid, [])) < _plan_terms(plan)["required"]:
285
+ have = app.state.grants.get(arid, [])
286
+ live[arid] = {"plan": plan, "collected": len(have), "required": _plan_terms(plan)["required"],
287
+ "approvers": [g["approver_key_id"] for g in have]}
186
288
  for arid, env in list(app.state.pending.items()):
187
289
  if datetime.fromisoformat(env["not_after"]) <= current:
188
290
  del app.state.pending[arid] # an expired hold can never be released
@@ -199,7 +301,10 @@ def build_issuer_app(
199
301
 
200
302
  def _complete(arid: str) -> bool:
201
303
  env = app.state.pending.get(arid)
202
- return env is not None and len(app.state.grants.get(arid, [])) >= approval_terms(env)["required"]
304
+ if env is None:
305
+ plan = app.state.pending_plans.get(arid)
306
+ return plan is not None and len(app.state.grants.get(arid, [])) >= _plan_terms(plan)["required"]
307
+ return len(app.state.grants.get(arid, [])) >= approval_terms(env)["required"]
203
308
 
204
309
  @app.post("/grants")
205
310
  async def post_grant(request: Request, authorization: Optional[str] = Header(default=None)):
@@ -210,24 +315,28 @@ def build_issuer_app(
210
315
  raise _refuse(400, REF_ISSUER_BAD_REQUEST)
211
316
  arid = grant.get("approval_request_id") if isinstance(grant, dict) else None
212
317
  env = app.state.pending.get(arid) if isinstance(arid, str) else None
213
- if env is None:
318
+ plan = app.state.pending_plans.get(arid) if isinstance(arid, str) else None
319
+ if env is None and plan is None:
214
320
  return {"accepted": False, "reason": REF_ISSUER_UNKNOWN_HOLD}
215
321
  trusted = approver_keys()
216
322
  if not trusted:
217
323
  return {"accepted": False, "reason": REF_ISSUER_NO_APPROVER_TRUST}
218
- g = verify_grant(grant, expected_decision_sha256=env["decision_sha256"],
324
+ g = verify_grant(grant, expected_decision_sha256=env["decision_sha256"] if env else plan["plan_sha256"],
219
325
  expected_approval_request_id=arid, approver_public_keys=trusted,
220
326
  gate_key_id=key_id, now=now())
221
327
  if not g["accepted"]:
222
328
  return {"accepted": False, "reason": g["reason"]}
223
- # The receipt's own terms: distinct approvers, from the named set if any.
224
- terms = approval_terms(env)
225
- have = app.state.grants.setdefault(arid, [])
329
+ # The receipt's (or plan's) own terms: distinct approvers, from the named set if any.
330
+ terms = approval_terms(env) if env else _plan_terms(plan)
331
+ have = list(app.state.grants.get(arid, []))
226
332
  if any(h["approver_key_id"] == grant["approver_key_id"] for h in have):
227
333
  return {"accepted": False, "reason": REF_APPROVAL_DUPLICATE_APPROVER}
228
334
  if terms["approvers"] is not None and grant["approver_key_id"] not in terms["approvers"]:
229
335
  return {"accepted": False, "reason": REF_APPROVAL_APPROVER_NOT_ELIGIBLE}
230
336
  have.append(grant)
337
+ app.state.grants[arid] = have # written back: the store persists on assignment
338
+ event("granted", approval_request_id=arid, approver=grant["approver_key_id"], collected=len(have), required=terms["required"],
339
+ hold_kind="plan" if plan else "receipt")
231
340
  return {"accepted": True, "reason": g["reason"], "collected": len(have), "required": terms["required"],
232
341
  "complete": len(have) >= terms["required"]}
233
342
 
@@ -235,31 +344,33 @@ def build_issuer_app(
235
344
  async def decline(request: Request, authorization: Optional[str] = Header(default=None)):
236
345
  """A human's refusal: the hold is dropped, a waiting middleware learns
237
346
  at once. No signature is needed to say no; nothing can run on it."""
238
- authenticate(authorization)
347
+ who = authenticate(authorization)
239
348
  try:
240
349
  body = await request.json()
241
350
  except Exception:
242
351
  raise _refuse(400, REF_ISSUER_BAD_REQUEST)
243
352
  arid = body.get("approval_request_id") if isinstance(body, dict) else None
244
- if not isinstance(arid, str) or arid not in app.state.pending:
353
+ if not isinstance(arid, str) or (arid not in app.state.pending and arid not in app.state.pending_plans):
245
354
  return {"declined": False, "reason": REF_ISSUER_UNKNOWN_HOLD}
246
- del app.state.pending[arid]
355
+ app.state.pending_plans.pop(arid, None)
356
+ app.state.pending.pop(arid, None)
247
357
  app.state.pending_args.pop(arid, None)
248
358
  app.state.pending_state.pop(arid, None)
249
359
  app.state.grants.pop(arid, None)
250
360
  app.state.declined.add(arid)
361
+ event("declined", approval_request_id=arid, by=who["principal"])
251
362
  return {"declined": True, "reason": None}
252
363
 
253
364
  @app.get("/grants/{approval_request_id}")
254
365
  async def get_grant(approval_request_id: str, authorization: Optional[str] = Header(default=None)):
255
366
  authenticate(authorization)
256
- env = app.state.pending.get(approval_request_id)
367
+ env = app.state.pending.get(approval_request_id) or app.state.pending_plans.get(approval_request_id)
257
368
  have = app.state.grants.get(approval_request_id, [])
258
369
  complete = _complete(approval_request_id)
259
370
  return {"pending": env is not None,
260
371
  "grant": have[0] if complete and len(have) == 1 else None,
261
372
  "grants": list(have) if complete else [],
262
- "collected": len(have), "required": approval_terms(env)["required"] if env else None,
373
+ "collected": len(have), "required": (_plan_terms(env) if env and "plan_version" in env else approval_terms(env))["required"] if env else None,
263
374
  "declined": approval_request_id in app.state.declined}
264
375
 
265
376
  @app.get("/approvers")
@@ -284,6 +395,7 @@ def build_issuer_app(
284
395
  if not isinstance(body, dict) or not valid_public_key_hex(body.get("public_key_hex")):
285
396
  raise _refuse(400, REF_ISSUER_BAD_REQUEST)
286
397
  result = registry.enroll(who["principal"], body["public_key_hex"], now=now())
398
+ event("enrolled", approver=who["principal"], replaced=result["replaced"])
287
399
  return {"enrolled": True, "key_id": who["principal"], "replaced": result["replaced"]}
288
400
 
289
401
  @app.get("/me")
@@ -296,6 +408,15 @@ def build_issuer_app(
296
408
  return {"principal": upn, "may_approve": may_enroll(who, role=approver_role, groups=approver_groups) or upn in static_approvers,
297
409
  "enrolled_public_key_hex": registry.public_hex().get(upn)}
298
410
 
411
+ @app.get("/health")
412
+ async def health():
413
+ """Public, for a monitor: the service is up, which policy it runs,
414
+ how much is waiting, and since when. No secrets."""
415
+ return {"ok": True, "policy": policy.pin(), "issuer_key_id": key_id,
416
+ "pending_receipts": len(app.state.pending), "pending_plans": len(app.state.pending_plans),
417
+ "enrolled_approvers": len(registry.public_hex()), "static_approvers": len(static_approvers),
418
+ "entra": bool(console_entra), "persistent": store.path != ":memory:", "started_at": app.state.started_at}
419
+
299
420
  @app.get("/approve-config")
300
421
  async def approve_config():
301
422
  """Public: how the console signs in. `entra` is null when the site
@@ -370,6 +491,8 @@ def app_from_env() -> FastAPI:
370
491
  audience=os.environ.get(ENV_AUDIENCE) or None,
371
492
  ttl_seconds=int(os.environ.get(ENV_RECEIPT_TTL, DEFAULT_TTL_SECONDS)),
372
493
  on_hold=notifier_from_env(os.environ),
494
+ hold_store=HoldStore(os.environ.get(ENV_HOLDS_PATH) or None),
495
+ log_events=(lambda e: sys.stderr.write(json.dumps(e, sort_keys=True) + "\n")) if os.environ.get("GARGOYLE_LOG_JSON") else None,
373
496
  approver_registry=ApproverRegistry(os.environ.get(ENV_APPROVERS_PATH)),
374
497
  approver_role=os.environ.get(ENV_APPROVER_ROLE, DEFAULT_APPROVER_ROLE),
375
498
  approver_groups=[g.strip() for g in os.environ.get(ENV_APPROVER_GROUPS, "").split(",") if g.strip()] or None,