gargoyle-receipts 0.12.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.12.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.13.0.dev0}/PKG-INFO +4 -2
  2. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/README.md +3 -1
  3. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/cli.py +31 -0
  4. gargoyle_receipts-0.13.0.dev0/gargoyle/holdstore.py +116 -0
  5. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/issuer_service.py +46 -8
  6. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +4 -2
  7. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +1 -0
  8. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/pyproject.toml +1 -1
  9. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_issuer_service.py +30 -0
  10. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/LICENSE +0 -0
  11. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/NOTICE +0 -0
  12. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/__init__.py +0 -0
  13. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/adapters.py +0 -0
  14. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/approval.py +0 -0
  15. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/approver_cli.py +0 -0
  16. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/approver_trust.py +0 -0
  17. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/approvers.py +0 -0
  18. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/consumer.py +0 -0
  19. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/entra_login.py +0 -0
  20. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/envelope.py +0 -0
  21. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/evidence.py +0 -0
  22. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/execution.py +0 -0
  23. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/fs_server.py +0 -0
  24. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/identity.py +0 -0
  25. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/issuer.py +0 -0
  26. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/jws.py +0 -0
  27. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/key_record_source.py +0 -0
  28. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/mcp_server.py +0 -0
  29. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/notify.py +0 -0
  30. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/opa.py +0 -0
  31. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/pending_store.py +0 -0
  32. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/plans.py +0 -0
  33. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/policy.py +0 -0
  34. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/published_record_source.py +0 -0
  35. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/remote.py +0 -0
  36. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/replay_cache.py +0 -0
  37. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/revert.py +0 -0
  38. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/rules.py +0 -0
  39. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
  40. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/state.py +0 -0
  41. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/static/approve.html +0 -0
  42. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/static/approve.js +0 -0
  43. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/verifier.py +0 -0
  44. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  45. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  46. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  47. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  48. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/setup.cfg +0 -0
  49. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_annotations.py +0 -0
  50. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_approver_cli.py +0 -0
  51. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_approvers.py +0 -0
  52. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_canonical.py +0 -0
  53. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_cli.py +0 -0
  54. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_console_js.py +0 -0
  55. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_consumer.py +0 -0
  56. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_entra_identity.py +0 -0
  57. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_entra_login.py +0 -0
  58. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_evidence.py +0 -0
  59. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_execution.py +0 -0
  60. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_fs_server.py +0 -0
  61. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_graph.py +0 -0
  62. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_historical.py +0 -0
  63. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_identity.py +0 -0
  64. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_jws.py +0 -0
  65. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_mcp_proxy.py +0 -0
  66. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_mcp_self_issue.py +0 -0
  67. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_mcp_server.py +0 -0
  68. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_notify.py +0 -0
  69. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_opa.py +0 -0
  70. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_plans.py +0 -0
  71. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_policy.py +0 -0
  72. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_published_record.py +0 -0
  73. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_remote.py +0 -0
  74. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_revert.py +0 -0
  75. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_rules.py +0 -0
  76. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_state.py +0 -0
  77. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_stdio_encoding.py +0 -0
  78. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_threshold.py +0 -0
  79. {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_typescript_conformance.py +0 -0
  80. {gargoyle_receipts-0.12.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.12.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.12.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
@@ -368,6 +368,8 @@ cannot attach receipts work unchanged. Configuration is by environment:
368
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` |
369
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 |
370
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 |
371
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 |
372
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 |
373
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.12.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
@@ -330,6 +330,8 @@ cannot attach receipts work unchanged. Configuration is by environment:
330
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` |
331
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 |
332
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 |
333
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 |
334
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 |
335
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.
@@ -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,7 @@ 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
56
58
  from gargoyle.plans import (REF_PLAN_MALFORMED, REF_PLAN_STEP_NOT_PLANNABLE, REF_PLAN_STEP_REFUSED, build_plan,
57
59
  sign_plan, valid_step)
58
60
  from gargoyle.notify import notifier_from_env
@@ -100,17 +102,33 @@ def build_issuer_app(
100
102
  approver_role: str = DEFAULT_APPROVER_ROLE,
101
103
  approver_groups: Optional[list] = None,
102
104
  console_entra: Optional[Dict[str, str]] = None,
105
+ hold_store: Optional[HoldStore] = None,
106
+ log_events: Optional[Callable[[Dict[str, Any]], None]] = None,
103
107
  ) -> FastAPI:
104
108
  """`on_hold(envelope, args)` is called once for every new hold (gargoyle.notify
105
109
  posts it to a webhook); it is notice, not admission, and its failure
106
110
  never affects the hold."""
107
111
  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)
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
114
132
  registry = approver_registry if approver_registry is not None else ApproverRegistry(None)
115
133
  static_approvers = dict(approver_public_keys or {})
116
134
 
@@ -166,6 +184,7 @@ def build_issuer_app(
166
184
  result = issue(policy, body["target_url"], interaction, signing_key=signing_key, key_id=key_id,
167
185
  ttl_seconds=ttl_seconds, now=now())
168
186
  if result.envelope is None:
187
+ event("refused", principal=who["principal"], tool=body["tool"], reason=result.decision.reason)
169
188
  return {"issued": False, "reason": result.decision.reason}
170
189
  covered = False
171
190
  plan_id = body.get("plan_id")
@@ -180,6 +199,8 @@ def build_issuer_app(
180
199
  if plan["plan_id"] == plan_id and _complete(arid_p) and match_step(plan, body["tool"], body["args"]) is not None:
181
200
  covered = True
182
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"))
183
204
  if result.envelope["requires_approval"] and not covered:
184
205
  arid = result.envelope["approval_request_id"]
185
206
  app.state.pending[arid] = result.envelope
@@ -245,6 +266,7 @@ def build_issuer_app(
245
266
  approval=approval), signing_key, key_id)
246
267
  if held:
247
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"])
248
270
  return {"issued": True, "plan": plan, "held": held}
249
271
 
250
272
  def _plan_terms(plan):
@@ -306,12 +328,15 @@ def build_issuer_app(
306
328
  return {"accepted": False, "reason": g["reason"]}
307
329
  # The receipt's (or plan's) own terms: distinct approvers, from the named set if any.
308
330
  terms = approval_terms(env) if env else _plan_terms(plan)
309
- have = app.state.grants.setdefault(arid, [])
331
+ have = list(app.state.grants.get(arid, []))
310
332
  if any(h["approver_key_id"] == grant["approver_key_id"] for h in have):
311
333
  return {"accepted": False, "reason": REF_APPROVAL_DUPLICATE_APPROVER}
312
334
  if terms["approvers"] is not None and grant["approver_key_id"] not in terms["approvers"]:
313
335
  return {"accepted": False, "reason": REF_APPROVAL_APPROVER_NOT_ELIGIBLE}
314
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")
315
340
  return {"accepted": True, "reason": g["reason"], "collected": len(have), "required": terms["required"],
316
341
  "complete": len(have) >= terms["required"]}
317
342
 
@@ -319,7 +344,7 @@ def build_issuer_app(
319
344
  async def decline(request: Request, authorization: Optional[str] = Header(default=None)):
320
345
  """A human's refusal: the hold is dropped, a waiting middleware learns
321
346
  at once. No signature is needed to say no; nothing can run on it."""
322
- authenticate(authorization)
347
+ who = authenticate(authorization)
323
348
  try:
324
349
  body = await request.json()
325
350
  except Exception:
@@ -333,6 +358,7 @@ def build_issuer_app(
333
358
  app.state.pending_state.pop(arid, None)
334
359
  app.state.grants.pop(arid, None)
335
360
  app.state.declined.add(arid)
361
+ event("declined", approval_request_id=arid, by=who["principal"])
336
362
  return {"declined": True, "reason": None}
337
363
 
338
364
  @app.get("/grants/{approval_request_id}")
@@ -369,6 +395,7 @@ def build_issuer_app(
369
395
  if not isinstance(body, dict) or not valid_public_key_hex(body.get("public_key_hex")):
370
396
  raise _refuse(400, REF_ISSUER_BAD_REQUEST)
371
397
  result = registry.enroll(who["principal"], body["public_key_hex"], now=now())
398
+ event("enrolled", approver=who["principal"], replaced=result["replaced"])
372
399
  return {"enrolled": True, "key_id": who["principal"], "replaced": result["replaced"]}
373
400
 
374
401
  @app.get("/me")
@@ -381,6 +408,15 @@ def build_issuer_app(
381
408
  return {"principal": upn, "may_approve": may_enroll(who, role=approver_role, groups=approver_groups) or upn in static_approvers,
382
409
  "enrolled_public_key_hex": registry.public_hex().get(upn)}
383
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
+
384
420
  @app.get("/approve-config")
385
421
  async def approve_config():
386
422
  """Public: how the console signs in. `entra` is null when the site
@@ -455,6 +491,8 @@ def app_from_env() -> FastAPI:
455
491
  audience=os.environ.get(ENV_AUDIENCE) or None,
456
492
  ttl_seconds=int(os.environ.get(ENV_RECEIPT_TTL, DEFAULT_TTL_SECONDS)),
457
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,
458
496
  approver_registry=ApproverRegistry(os.environ.get(ENV_APPROVERS_PATH)),
459
497
  approver_role=os.environ.get(ENV_APPROVER_ROLE, DEFAULT_APPROVER_ROLE),
460
498
  approver_groups=[g.strip() for g in os.environ.get(ENV_APPROVER_GROUPS, "").split(",") if g.strip()] or None,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.12.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.12.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
@@ -368,6 +368,8 @@ cannot attach receipts work unchanged. Configuration is by environment:
368
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` |
369
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 |
370
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 |
371
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 |
372
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 |
373
375
  | `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
@@ -15,6 +15,7 @@ gargoyle/envelope.py
15
15
  gargoyle/evidence.py
16
16
  gargoyle/execution.py
17
17
  gargoyle/fs_server.py
18
+ gargoyle/holdstore.py
18
19
  gargoyle/identity.py
19
20
  gargoyle/issuer.py
20
21
  gargoyle/issuer_service.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gargoyle-receipts"
7
- version = "0.12.0.dev0"
7
+ version = "0.13.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"
@@ -166,3 +166,33 @@ def test_record_and_keys_are_public(client):
166
166
  assert client.get("/record").json() == PIN
167
167
  from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
168
168
  assert client.get("/keys").json() == {"issuer-1": issuer.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()}
169
+
170
+
171
+ def test_holds_grants_and_declines_survive_a_restart(tmp_path):
172
+ """The same SQLite file, a new app: what a human was acting on is still there."""
173
+ from gargoyle.holdstore import HoldStore
174
+ path = str(tmp_path / "holds.sqlite")
175
+ events = []
176
+
177
+ def app_on(path):
178
+ return build_issuer_app(policy=ToolPolicy(), signing_key=issuer, key_id="issuer-1", idp_public_keys={"idp-1": idp.public_key()},
179
+ approver_public_keys={"approver-alice": approver.public_key()}, hold_store=HoldStore(path),
180
+ log_events=events.append, now=lambda: NOW)
181
+ c1 = TestClient(app_on(path))
182
+ env = c1.post("/issue", json={"target_url": TARGET, "tool": "write_file", "args": {"path": "a.txt", "content": "x"}}, headers=auth()).json()["envelope"]
183
+ arid = env["approval_request_id"]
184
+ h = c1.get("/health").json()
185
+ assert h["ok"] and h["pending_receipts"] == 1 and h["persistent"] is True and h["policy"] == PIN
186
+ c2 = TestClient(app_on(path)) # restart
187
+ assert c2.get("/health").json()["pending_receipts"] == 1
188
+ assert c2.get("/pending", headers=auth()).json()["pending"][0]["envelope"]["approval_request_id"] == arid
189
+ g = sign_grant(build_grant(decision_sha256=env["decision_sha256"], approval_request_id=arid, grant_id="g-1",
190
+ not_after=NOW + timedelta(minutes=10)), approver, "approver-alice")
191
+ assert c2.post("/grants", json=g, headers=auth()).json()["accepted"]
192
+ c3 = TestClient(app_on(path)) # restart again: the grant is still there
193
+ assert c3.get("/grants/" + arid, headers=auth()).json()["grant"]["approver_key_id"] == "approver-alice"
194
+ assert c3.post("/decline", json={"approval_request_id": arid}, headers=auth()).json()["declined"] is True
195
+ c4 = TestClient(app_on(path))
196
+ assert c4.get("/grants/" + arid, headers=auth()).json()["declined"] is True
197
+ assert [e["event"] for e in events] == ["issued", "granted", "declined"]
198
+ assert events[1]["approver"] == "approver-alice" and events[1]["hold_kind"] == "receipt"