gargoyle-receipts 0.12.0.dev0__tar.gz → 0.14.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 (83) hide show
  1. {gargoyle_receipts-0.12.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.14.0.dev0}/PKG-INFO +16 -2
  2. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/README.md +15 -1
  3. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/cli.py +57 -1
  4. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/evidence.py +17 -8
  5. gargoyle_receipts-0.14.0.dev0/gargoyle/holdstore.py +116 -0
  6. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/issuer_service.py +66 -8
  7. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/mcp_server.py +9 -3
  8. gargoyle_receipts-0.14.0.dev0/gargoyle/reconcile.py +130 -0
  9. gargoyle_receipts-0.14.0.dev0/gargoyle/witness.py +123 -0
  10. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +16 -2
  11. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +5 -1
  12. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/pyproject.toml +1 -1
  13. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_evidence.py +8 -4
  14. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_issuer_service.py +30 -0
  15. gargoyle_receipts-0.14.0.dev0/tests/test_witness.py +151 -0
  16. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/LICENSE +0 -0
  17. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/NOTICE +0 -0
  18. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/__init__.py +0 -0
  19. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/adapters.py +0 -0
  20. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/approval.py +0 -0
  21. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/approver_cli.py +0 -0
  22. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/approver_trust.py +0 -0
  23. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/approvers.py +0 -0
  24. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/consumer.py +0 -0
  25. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/entra_login.py +0 -0
  26. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/envelope.py +0 -0
  27. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/execution.py +0 -0
  28. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/fs_server.py +0 -0
  29. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/identity.py +0 -0
  30. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/issuer.py +0 -0
  31. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/jws.py +0 -0
  32. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/key_record_source.py +0 -0
  33. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/notify.py +0 -0
  34. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/opa.py +0 -0
  35. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/pending_store.py +0 -0
  36. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/plans.py +0 -0
  37. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/policy.py +0 -0
  38. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/published_record_source.py +0 -0
  39. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/remote.py +0 -0
  40. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/replay_cache.py +0 -0
  41. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/revert.py +0 -0
  42. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/rules.py +0 -0
  43. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
  44. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/state.py +0 -0
  45. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/static/approve.html +0 -0
  46. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/static/approve.js +0 -0
  47. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/verifier.py +0 -0
  48. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  49. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  50. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  51. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  52. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/setup.cfg +0 -0
  53. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_annotations.py +0 -0
  54. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_approver_cli.py +0 -0
  55. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_approvers.py +0 -0
  56. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_canonical.py +0 -0
  57. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_cli.py +0 -0
  58. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_console_js.py +0 -0
  59. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_consumer.py +0 -0
  60. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_entra_identity.py +0 -0
  61. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_entra_login.py +0 -0
  62. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_execution.py +0 -0
  63. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_fs_server.py +0 -0
  64. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_graph.py +0 -0
  65. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_historical.py +0 -0
  66. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_identity.py +0 -0
  67. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_jws.py +0 -0
  68. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_mcp_proxy.py +0 -0
  69. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_mcp_self_issue.py +0 -0
  70. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_mcp_server.py +0 -0
  71. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_notify.py +0 -0
  72. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_opa.py +0 -0
  73. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_plans.py +0 -0
  74. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_policy.py +0 -0
  75. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_published_record.py +0 -0
  76. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_remote.py +0 -0
  77. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_revert.py +0 -0
  78. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_rules.py +0 -0
  79. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_state.py +0 -0
  80. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_stdio_encoding.py +0 -0
  81. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_threshold.py +0 -0
  82. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_typescript_conformance.py +0 -0
  83. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.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.12.0.dev0
3
+ Version: 0.14.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.12.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
62
+ Status: pre-release (`0.14.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
@@ -240,6 +240,17 @@ its initialize handshake, so a fleet with Claude on some desks and ChatGPT
240
240
  on others still has one trail that says who, through what, did what. The
241
241
  receipt itself stays model-agnostic.
242
242
 
243
+ The evidence is the workstation's own statement, and the workstation
244
+ belongs to its user. So the issuer keeps a witness: a signed, hash-chained
245
+ record of every receipt it issued and every grant it accepted, on the
246
+ operator's server. `gargoyle audit --witness` reconciles the two by
247
+ decision id and names what does not agree: a receipt the issuer never
248
+ issued, a held call honored without a grant the issuer saw, a receipt
249
+ issued with no evidence recorded. `--check-tree` hashes the gated folder
250
+ against the evidence's recorded states and names any file changed around
251
+ the gate. On a machine the user administers the gate constrains the
252
+ assistant, not the user; these two checks make the difference visible.
253
+
243
254
  The MCP middleware also keeps an evidence log: one hash-chained line per
244
255
  decision, refusals and declined holds included, so the trail answers "what
245
256
  did the agent try" as well as "what did it do". `gargoyle audit --log`
@@ -368,6 +379,9 @@ cannot attach receipts work unchanged. Configuration is by environment:
368
379
  | `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` |
369
380
  | 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 |
370
381
  | 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 |
382
+ | 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 |
383
+ | issuer `GARGOYLE_WITNESS_PATH` | the issuer's signed, hash-chained record of every decision (`gargoyle issuer` sets `witness.jsonl` beside the keys); `gargoyle audit --witness` reconciles a client's evidence against it, `--check-tree DIR` finds files changed outside the gate |
384
+ | issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
371
385
  | `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 |
372
386
  | `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 |
373
387
  | `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.12.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
24
+ Status: pre-release (`0.14.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
@@ -202,6 +202,17 @@ its initialize handshake, so a fleet with Claude on some desks and ChatGPT
202
202
  on others still has one trail that says who, through what, did what. The
203
203
  receipt itself stays model-agnostic.
204
204
 
205
+ The evidence is the workstation's own statement, and the workstation
206
+ belongs to its user. So the issuer keeps a witness: a signed, hash-chained
207
+ record of every receipt it issued and every grant it accepted, on the
208
+ operator's server. `gargoyle audit --witness` reconciles the two by
209
+ decision id and names what does not agree: a receipt the issuer never
210
+ issued, a held call honored without a grant the issuer saw, a receipt
211
+ issued with no evidence recorded. `--check-tree` hashes the gated folder
212
+ against the evidence's recorded states and names any file changed around
213
+ the gate. On a machine the user administers the gate constrains the
214
+ assistant, not the user; these two checks make the difference visible.
215
+
205
216
  The MCP middleware also keeps an evidence log: one hash-chained line per
206
217
  decision, refusals and declined holds included, so the trail answers "what
207
218
  did the agent try" as well as "what did it do". `gargoyle audit --log`
@@ -330,6 +341,9 @@ cannot attach receipts work unchanged. Configuration is by environment:
330
341
  | `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` |
331
342
  | 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 |
332
343
  | 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 |
344
+ | 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 |
345
+ | issuer `GARGOYLE_WITNESS_PATH` | the issuer's signed, hash-chained record of every decision (`gargoyle issuer` sets `witness.jsonl` beside the keys); `gargoyle audit --witness` reconciles a client's evidence against it, `--check-tree DIR` finds files changed outside the gate |
346
+ | issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
333
347
  | `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 |
334
348
  | `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 |
335
349
  | `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,9 @@ 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_WITNESS_PATH", str(out / "witness.jsonl")) # the issuer's signed record, for reconciliation
294
+ os.environ.setdefault("GARGOYLE_LOG_JSON", "1")
263
295
  app = app_from_env()
264
296
  # The middleware reads the policy pin from a file; write it next to the
265
297
  # config so the client-side entry finds it at the configured path.
@@ -327,7 +359,29 @@ def cmd_audit(args) -> int:
327
359
  failed = sum(1 for r in v["entries"] if not r["accepted"])
328
360
  print("%d entries (%d honored, %d refused), %d failed; chain %s; tail %s"
329
361
  % (v["count"], v["honored"], v["refused"], failed, v["reason"], v["tail_sha256"][:16]))
330
- return 0 if v["accepted"] else 1
362
+ rc = 0 if v["accepted"] else 1
363
+ entries = read_log(args.log)
364
+ if args.witness:
365
+ # The issuer's signed record: the client's evidence must agree with it.
366
+ from gargoyle.reconcile import reconcile
367
+ from gargoyle.witness import read_witness, verify_witness
368
+ w = verify_witness(read_witness(args.witness), issuer_public_keys=trust["issuer_public_keys"])
369
+ print("witness: %d entries, %s, tail %s" % (w["count"], w["reason"], w["tail_sha256"][:16]))
370
+ principal = next((e.get("principal") for e in entries if isinstance(e, dict) and e.get("principal")), None)
371
+ r = reconcile(entries, read_witness(args.witness), principal=principal)
372
+ for f in r["findings"]:
373
+ print(" FINDING %-22s %s" % (f["finding"], " ".join("%s=%s" % (k, str(v)[:40]) for k, v in f.items() if k != "finding")))
374
+ print("reconciled: %d issued by the issuer, %d in evidence, %d finding(s)" % (r["issued"], r["evidence_with_receipt"], len(r["findings"])))
375
+ rc = rc or (0 if (w["accepted"] and r["accepted"]) else 1)
376
+ if args.check_tree:
377
+ # Every file the gate wrote must still be as the gate left it.
378
+ from gargoyle.reconcile import check_tree
379
+ t = check_tree(entries, args.check_tree)
380
+ for f in t["findings"]:
381
+ print(" FINDING %-22s %s%s" % (f["finding"], f["path"], " (missing)" if f.get("missing") else ""))
382
+ print("tree: %d gated file(s) checked, %d untouched by the gate, %d changed outside it" % (t["checked"], t["untouched"], len(t["findings"])))
383
+ rc = rc or (0 if t["accepted"] else 1)
384
+ return rc
331
385
  receipt, execution = json.load(open(args.receipt)), json.load(open(args.execution))
332
386
  grant = json.load(open(args.grant)) if args.grant else None
333
387
  result = verify_chain(receipt, execution, grant=grant, **trust)
@@ -393,6 +447,8 @@ def main(argv=None) -> int:
393
447
  p = sub.add_parser("audit", help="verify a receipt chain offline, or a whole evidence log")
394
448
  p.add_argument("receipt", nargs="?"); p.add_argument("execution", nargs="?"); p.add_argument("grant", nargs="?")
395
449
  p.add_argument("--log", help="the middleware's audit.jsonl: verify every entry")
450
+ p.add_argument("--witness", help="the issuer's witness.jsonl: reconcile the evidence against it")
451
+ p.add_argument("--check-tree", metavar="DIR", help="verify every gated file under DIR is as the gate left it")
396
452
  p.add_argument("--out", default="gargoyle-deploy")
397
453
  p.set_defaults(fn=cmd_audit)
398
454
  args = parser.parse_args(argv)
@@ -48,9 +48,9 @@ from gargoyle.envelope import args_sha256, canonical_json, canonical_json_legacy
48
48
  from gargoyle.execution import verify_chain
49
49
  from gargoyle.verifier import verify_provenance
50
50
 
51
- EVIDENCE_VERSION = "gargoyle-evidence/3" # version 3 adds `client`; hashes over RFC 8785 canonical JSON
51
+ EVIDENCE_VERSION = "gargoyle-evidence/4" # 3 added `client`, 4 adds `ref`; hashes over RFC 8785 canonical JSON
52
52
  EVIDENCE_VERSIONS = {"gargoyle-evidence/1": canonical_json_legacy, "gargoyle-evidence/2": canonical_json,
53
- EVIDENCE_VERSION: canonical_json}
53
+ "gargoyle-evidence/3": canonical_json, EVIDENCE_VERSION: canonical_json}
54
54
  GENESIS = "0" * 64
55
55
 
56
56
  HONORED = "honored"
@@ -71,6 +71,11 @@ _FIELDS = ("evidence_version", "seq", "prev_sha256", "at", "kind", "tool", "args
71
71
  # receipt stays model-agnostic: this is evidence about the session, not a
72
72
  # bound field of the decision.
73
73
  _FIELDS_V3 = _FIELDS + ("client",)
74
+ # Version 4 adds `ref`: what the adapter examined for a state-bound call (a
75
+ # file path, "mailRule:<id>"), or null. With it a folder can be checked
76
+ # against the evidence without any plaintext (gargoyle.reconcile.check_tree).
77
+ _FIELDS_V4 = _FIELDS_V3 + ("ref",)
78
+ _FIELDS_BY_VERSION = {"gargoyle-evidence/3": _FIELDS_V3, "gargoyle-evidence/4": _FIELDS_V4}
74
79
 
75
80
 
76
81
  def entry_sha256(entry: Dict[str, Any]) -> str:
@@ -84,11 +89,13 @@ def entry_sha256(entry: Dict[str, Any]) -> str:
84
89
 
85
90
  def build_entry(*, seq: int, prev_sha256: str, kind: str, tool: str, args: Any, principal: str,
86
91
  reason: str, receipt: Any = None, grant: Any = None, execution: Any = None,
87
- at: Optional[datetime] = None, client: Optional[str] = None) -> Dict[str, Any]:
92
+ at: Optional[datetime] = None, client: Optional[str] = None, ref: Optional[str] = None) -> Dict[str, Any]:
88
93
  if kind not in (HONORED, REFUSED):
89
94
  raise ValueError("kind must be honored or refused")
90
95
  if client is not None and not isinstance(client, str):
91
96
  raise ValueError("client must be a string or None")
97
+ if ref is not None and not isinstance(ref, str):
98
+ raise ValueError("ref must be a string or None")
92
99
  entry = {
93
100
  "evidence_version": EVIDENCE_VERSION,
94
101
  "seq": seq,
@@ -103,6 +110,7 @@ def build_entry(*, seq: int, prev_sha256: str, kind: str, tool: str, args: Any,
103
110
  "grant": grant,
104
111
  "execution": execution,
105
112
  "client": client,
113
+ "ref": ref,
106
114
  }
107
115
  entry["entry_sha256"] = entry_sha256(entry)
108
116
  return entry
@@ -131,14 +139,14 @@ class EvidenceLog:
131
139
 
132
140
  def append(self, kind: str, tool: str, args: Any, principal: str, reason: str, *,
133
141
  receipt: Any = None, grant: Any = None, execution: Any = None,
134
- client: Optional[str] = None) -> Dict[str, Any]:
142
+ client: Optional[str] = None, ref: Optional[str] = None) -> Dict[str, Any]:
135
143
  """Write one entry and return it. Raises OSError if the file cannot
136
144
  be written; the caller decides whether that stops the call."""
137
145
  if self._seq is None:
138
146
  self._load_tail()
139
147
  entry = build_entry(seq=self._seq + 1, prev_sha256=self._tail, kind=kind, tool=tool, args=args,
140
148
  principal=principal, reason=reason, receipt=receipt, grant=grant, execution=execution,
141
- client=client)
149
+ client=client, ref=ref)
142
150
  with open(self.path, "a", encoding="utf-8") as f:
143
151
  f.write(json.dumps(entry, sort_keys=True) + "\n")
144
152
  self._seq, self._tail = entry["seq"], entry["entry_sha256"]
@@ -146,10 +154,11 @@ class EvidenceLog:
146
154
 
147
155
 
148
156
  def _well_formed(entry: Any) -> bool:
149
- expected = _FIELDS_V3 if isinstance(entry, dict) and entry.get("evidence_version") == EVIDENCE_VERSION else _FIELDS
157
+ expected = _FIELDS_BY_VERSION.get(entry.get("evidence_version"), _FIELDS) if isinstance(entry, dict) else _FIELDS
150
158
  return (isinstance(entry, dict) and set(entry) == set(expected)
151
159
  and entry["evidence_version"] in EVIDENCE_VERSIONS
152
160
  and (entry.get("client") is None or isinstance(entry.get("client"), str))
161
+ and (entry.get("ref") is None or isinstance(entry.get("ref"), str))
153
162
  and isinstance(entry["seq"], int) and entry["seq"] >= 1
154
163
  and isinstance(entry["prev_sha256"], str) and len(entry["prev_sha256"]) == 64
155
164
  and isinstance(entry["at"], str) and entry["kind"] in (HONORED, REFUSED)
@@ -192,7 +201,7 @@ def verify_log(
192
201
  rc = rc if isinstance(rc, dict) else {}
193
202
  report.append({"seq": None, "kind": None, "tool": rc.get("tool"), "principal": rc.get("principal"),
194
203
  "accepted": False, "reason": REF_EVIDENCE_UNCHAINED if legacy else REF_EVIDENCE_MALFORMED,
195
- "verdict": None, "released_by": [], "state": None, "client": None, "plan": None})
204
+ "verdict": None, "released_by": [], "state": None, "client": None, "plan": None, "ref": None})
196
205
  all_ok = False
197
206
  continue
198
207
  e = raw
@@ -230,7 +239,7 @@ def verify_log(
230
239
  if ex.get("pre_state_sha256") or ex.get("post_state_sha256") else None)
231
240
  report.append({"seq": e["seq"], "kind": e["kind"], "tool": e["tool"], "principal": e["principal"],
232
241
  "accepted": ok, "reason": reason, "verdict": e["reason"], "released_by": released_by,
233
- "state": state, "client": e.get("client"), "plan": plan_ref})
242
+ "state": state, "client": e.get("client"), "plan": plan_ref, "ref": e.get("ref")})
234
243
  all_ok = all_ok and ok
235
244
  # The chain continues from what the file says, so one bad entry is
236
245
  # reported once rather than cascading into every later one.
@@ -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,6 +40,7 @@ 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
+ import sys
43
44
  from datetime import datetime, timedelta, timezone
44
45
  from pathlib import Path
45
46
  from typing import Any, Callable, Dict, Optional
@@ -53,6 +54,8 @@ 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.witness import WitnessLog
56
59
  from gargoyle.plans import (REF_PLAN_MALFORMED, REF_PLAN_STEP_NOT_PLANNABLE, REF_PLAN_STEP_REFUSED, build_plan,
57
60
  sign_plan, valid_step)
58
61
  from gargoyle.notify import notifier_from_env
@@ -100,17 +103,43 @@ def build_issuer_app(
100
103
  approver_role: str = DEFAULT_APPROVER_ROLE,
101
104
  approver_groups: Optional[list] = None,
102
105
  console_entra: Optional[Dict[str, str]] = None,
106
+ hold_store: Optional[HoldStore] = None,
107
+ log_events: Optional[Callable[[Dict[str, Any]], None]] = None,
108
+ witness: Optional[WitnessLog] = None,
103
109
  ) -> FastAPI:
104
110
  """`on_hold(envelope, args)` is called once for every new hold (gargoyle.notify
105
111
  posts it to a webhook); it is notice, not admission, and its failure
106
112
  never affects the hold."""
107
113
  app = FastAPI(title="Gargoyle issuing service")
108
- app.state.pending: Dict[str, Dict[str, Any]] = {} # approval_request_id -> envelope
109
- app.state.pending_args: Dict[str, Any] = {} # approval_request_id -> the call's arguments
110
- app.state.pending_state: Dict[str, Any] = {} # approval_request_id -> the target's state when held
111
- app.state.grants: Dict[str, Dict[str, Any]] = {} # approval_request_id -> grant
112
- app.state.declined: set = set() # approval_request_ids a human refused
113
- app.state.pending_plans: Dict[str, Dict[str, Any]] = {} # approval_request_id -> signed plan (gargoyle.plans)
114
+ # Everything a human may be in the middle of acting on lives in the hold
115
+ # store (gargoyle.holdstore): SQLite on disk in a deployment, memory in tests.
116
+ store = hold_store if hold_store is not None else HoldStore(None)
117
+ app.state.store = store
118
+ app.state.pending = store.dict("pending") # approval_request_id -> envelope
119
+ app.state.pending_args = store.dict("pending_args") # approval_request_id -> the call's arguments
120
+ app.state.pending_state = store.dict("pending_state") # approval_request_id -> the target's state when held
121
+ app.state.grants = store.dict("grants") # approval_request_id -> [grant, ...]
122
+ app.state.declined = store.set("declined") # approval_request_ids a human refused
123
+ app.state.pending_plans = store.dict("pending_plans") # approval_request_id -> signed plan (gargoyle.plans)
124
+ app.state.started_at = now().isoformat()
125
+
126
+ def event(kind: str, **fields: Any) -> None:
127
+ """One structured line per decision the service makes, for the
128
+ operator's log, and one signed entry in the witness log (gargoyle.witness),
129
+ the independent record the client's evidence is reconciled against.
130
+ Neither is the admission path."""
131
+ if log_events is not None:
132
+ try:
133
+ log_events({"event": kind, "at": now().isoformat(), **fields})
134
+ except Exception:
135
+ pass
136
+ if witness is not None:
137
+ try:
138
+ witness.record(kind, **fields)
139
+ except Exception as exc:
140
+ import sys as _sys
141
+ _sys.stderr.write("gargoyle: witness log unwritable: %s\n" % exc)
142
+ app.state.witness = witness
114
143
  registry = approver_registry if approver_registry is not None else ApproverRegistry(None)
115
144
  static_approvers = dict(approver_public_keys or {})
116
145
 
@@ -166,6 +195,7 @@ def build_issuer_app(
166
195
  result = issue(policy, body["target_url"], interaction, signing_key=signing_key, key_id=key_id,
167
196
  ttl_seconds=ttl_seconds, now=now())
168
197
  if result.envelope is None:
198
+ event("refused", principal=who["principal"], tool=body["tool"], reason=result.decision.reason)
169
199
  return {"issued": False, "reason": result.decision.reason}
170
200
  covered = False
171
201
  plan_id = body.get("plan_id")
@@ -180,6 +210,9 @@ def build_issuer_app(
180
210
  if plan["plan_id"] == plan_id and _complete(arid_p) and match_step(plan, body["tool"], body["args"]) is not None:
181
211
  covered = True
182
212
  break
213
+ event("issued", principal=who["principal"], tool=body["tool"], held=bool(result.envelope["requires_approval"]),
214
+ covered=covered, decision_id=result.envelope.get("decision_id"), decision_sha256=result.envelope["decision_sha256"],
215
+ approval_request_id=result.envelope.get("approval_request_id"), args_sha256=result.envelope["request_context"]["args_sha256"])
183
216
  if result.envelope["requires_approval"] and not covered:
184
217
  arid = result.envelope["approval_request_id"]
185
218
  app.state.pending[arid] = result.envelope
@@ -245,6 +278,7 @@ def build_issuer_app(
245
278
  approval=approval), signing_key, key_id)
246
279
  if held:
247
280
  app.state.pending_plans[plan["approval_request_id"]] = plan
281
+ event("plan", principal=who["principal"], steps=len(norm), held=held, plan_id=plan["plan_id"])
248
282
  return {"issued": True, "plan": plan, "held": held}
249
283
 
250
284
  def _plan_terms(plan):
@@ -306,12 +340,15 @@ def build_issuer_app(
306
340
  return {"accepted": False, "reason": g["reason"]}
307
341
  # The receipt's (or plan's) own terms: distinct approvers, from the named set if any.
308
342
  terms = approval_terms(env) if env else _plan_terms(plan)
309
- have = app.state.grants.setdefault(arid, [])
343
+ have = list(app.state.grants.get(arid, []))
310
344
  if any(h["approver_key_id"] == grant["approver_key_id"] for h in have):
311
345
  return {"accepted": False, "reason": REF_APPROVAL_DUPLICATE_APPROVER}
312
346
  if terms["approvers"] is not None and grant["approver_key_id"] not in terms["approvers"]:
313
347
  return {"accepted": False, "reason": REF_APPROVAL_APPROVER_NOT_ELIGIBLE}
314
348
  have.append(grant)
349
+ app.state.grants[arid] = have # written back: the store persists on assignment
350
+ event("granted", approval_request_id=arid, approver=grant["approver_key_id"], collected=len(have), required=terms["required"],
351
+ hold_kind="plan" if plan else "receipt")
315
352
  return {"accepted": True, "reason": g["reason"], "collected": len(have), "required": terms["required"],
316
353
  "complete": len(have) >= terms["required"]}
317
354
 
@@ -319,7 +356,7 @@ def build_issuer_app(
319
356
  async def decline(request: Request, authorization: Optional[str] = Header(default=None)):
320
357
  """A human's refusal: the hold is dropped, a waiting middleware learns
321
358
  at once. No signature is needed to say no; nothing can run on it."""
322
- authenticate(authorization)
359
+ who = authenticate(authorization)
323
360
  try:
324
361
  body = await request.json()
325
362
  except Exception:
@@ -333,6 +370,7 @@ def build_issuer_app(
333
370
  app.state.pending_state.pop(arid, None)
334
371
  app.state.grants.pop(arid, None)
335
372
  app.state.declined.add(arid)
373
+ event("declined", approval_request_id=arid, by=who["principal"])
336
374
  return {"declined": True, "reason": None}
337
375
 
338
376
  @app.get("/grants/{approval_request_id}")
@@ -369,6 +407,7 @@ def build_issuer_app(
369
407
  if not isinstance(body, dict) or not valid_public_key_hex(body.get("public_key_hex")):
370
408
  raise _refuse(400, REF_ISSUER_BAD_REQUEST)
371
409
  result = registry.enroll(who["principal"], body["public_key_hex"], now=now())
410
+ event("enrolled", approver=who["principal"], replaced=result["replaced"])
372
411
  return {"enrolled": True, "key_id": who["principal"], "replaced": result["replaced"]}
373
412
 
374
413
  @app.get("/me")
@@ -381,6 +420,21 @@ def build_issuer_app(
381
420
  return {"principal": upn, "may_approve": may_enroll(who, role=approver_role, groups=approver_groups) or upn in static_approvers,
382
421
  "enrolled_public_key_hex": registry.public_hex().get(upn)}
383
422
 
423
+ @app.get("/witness")
424
+ async def witness_entries(authorization: Optional[str] = Header(default=None)):
425
+ """The issuer's signed witness log, for reconciliation with a client's evidence."""
426
+ authenticate(authorization)
427
+ return {"entries": list(witness.entries) if witness is not None else []}
428
+
429
+ @app.get("/health")
430
+ async def health():
431
+ """Public, for a monitor: the service is up, which policy it runs,
432
+ how much is waiting, and since when. No secrets."""
433
+ return {"ok": True, "policy": policy.pin(), "issuer_key_id": key_id,
434
+ "pending_receipts": len(app.state.pending), "pending_plans": len(app.state.pending_plans),
435
+ "enrolled_approvers": len(registry.public_hex()), "static_approvers": len(static_approvers),
436
+ "entra": bool(console_entra), "persistent": store.path != ":memory:", "started_at": app.state.started_at}
437
+
384
438
  @app.get("/approve-config")
385
439
  async def approve_config():
386
440
  """Public: how the console signs in. `entra` is null when the site
@@ -455,6 +509,10 @@ def app_from_env() -> FastAPI:
455
509
  audience=os.environ.get(ENV_AUDIENCE) or None,
456
510
  ttl_seconds=int(os.environ.get(ENV_RECEIPT_TTL, DEFAULT_TTL_SECONDS)),
457
511
  on_hold=notifier_from_env(os.environ),
512
+ hold_store=HoldStore(os.environ.get(ENV_HOLDS_PATH) or None),
513
+ witness=(WitnessLog(os.environ["GARGOYLE_WITNESS_PATH"], Ed25519PrivateKey.from_private_bytes(bytes.fromhex(required[ENV_ISSUER_KEY_HEX])),
514
+ required[ENV_ISSUER_KEY_ID]) if os.environ.get("GARGOYLE_WITNESS_PATH") else None),
515
+ log_events=(lambda e: sys.stderr.write(json.dumps(e, sort_keys=True) + "\n")) if os.environ.get("GARGOYLE_LOG_JSON") else None,
458
516
  approver_registry=ApproverRegistry(os.environ.get(ENV_APPROVERS_PATH)),
459
517
  approver_role=os.environ.get(ENV_APPROVER_ROLE, DEFAULT_APPROVER_ROLE),
460
518
  approver_groups=[g.strip() for g in os.environ.get(ENV_APPROVER_GROUPS, "").split(",") if g.strip()] or None,
@@ -364,10 +364,12 @@ def _gate(state: ServerState, params: Dict[str, Any]) -> Dict[str, Any]:
364
364
  """{"executed", "reason", "envelope", "grant", "self_issued"}: the
365
365
  receipt and grant that were honored, whether the client brought them or
366
366
  this server obtained them."""
367
+ gate_ref: Dict[str, Any] = {"ref": None}
368
+
367
369
  def refused(reason, envelope=None):
368
370
  # A refusal keeps the receipt when there was one (a hold the human
369
371
  # declined or let lapse), so the evidence log can show what was asked.
370
- return {"executed": False, "reason": reason, "envelope": envelope, "grant": None, "self_issued": False}
372
+ return {"executed": False, "reason": reason, "envelope": envelope, "grant": None, "self_issued": False, "ref": gate_ref["ref"]}
371
373
  if state.consumer is None:
372
374
  return refused(REF_TARGET_NOT_CONFIGURED)
373
375
 
@@ -385,6 +387,8 @@ def _gate(state: ServerState, params: Dict[str, Any]) -> Dict[str, Any]:
385
387
  # target's current state goes with the request (gargoyle.state), so
386
388
  # a hold is approved against it and bound to it.
387
389
  probed = state.adapter.probe(name, args) if state.adapter is not None else None
390
+ if probed is not None:
391
+ gate_ref["ref"] = probed[0].get("ref")
388
392
  # An approved plan may already cover this call; say so, and the
389
393
  # issuer opens no hold for it.
390
394
  release = _plan_release_for(state, name, args)
@@ -435,9 +439,11 @@ def _gate(state: ServerState, params: Dict[str, Any]) -> Dict[str, Any]:
435
439
  except Exception as exc:
436
440
  sys.stderr.write("gargoyle: snapshot failed: %s\n" % exc)
437
441
 
442
+ if now_probe is not None:
443
+ gate_ref["ref"] = now_probe[0].get("ref")
438
444
  state.executed.append({"tool": name, "args": args}) # THE side effect
439
445
  return {"executed": True, "reason": verdict.reason, "envelope": envelope, "grant": grant,
440
- "self_issued": self_issued}
446
+ "self_issued": self_issued, "ref": gate_ref["ref"]}
441
447
 
442
448
 
443
449
  def _plan_release_for(state: ServerState, tool: str, args: Any) -> Optional[Dict[str, Any]]:
@@ -524,7 +530,7 @@ def _record(state: ServerState, kind: str, gate: Dict[str, Any], params: Dict[st
524
530
  try:
525
531
  state.evidence.append(kind, str(params.get("name")), params.get("arguments", {}), principal,
526
532
  gate["reason"], receipt=gate["envelope"], grant=gate["grant"], execution=execution,
527
- client=state.client)
533
+ client=state.client, ref=gate.get("ref"))
528
534
  except (OSError, ValueError) as exc:
529
535
  sys.stderr.write("gargoyle: evidence log unwritable: %s\n" % exc)
530
536