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.
- {gargoyle_receipts-0.12.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.13.0.dev0}/PKG-INFO +4 -2
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/README.md +3 -1
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/cli.py +31 -0
- gargoyle_receipts-0.13.0.dev0/gargoyle/holdstore.py +116 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/issuer_service.py +46 -8
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +4 -2
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +1 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/pyproject.toml +1 -1
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_issuer_service.py +30 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/adapters.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/approval.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/approvers.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/consumer.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/envelope.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/evidence.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/execution.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/identity.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/mcp_server.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/plans.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/policy.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/published_record_source.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/remote.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/replay_cache.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/revert.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/rules.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/state.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/static/approve.html +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/static/approve.js +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/verifier.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_approvers.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_cli.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_console_js.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_entra_identity.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_evidence.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_graph.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_historical.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_mcp_self_issue.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_plans.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_remote.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_revert.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_rules.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_state.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_threshold.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_wire_spec.py +0 -0
{gargoyle_receipts-0.12.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.13.0.dev0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gargoyle-receipts
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.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
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
61
61
|
|
|
62
|
-
Status: pre-release (`0.
|
|
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
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
23
23
|
|
|
24
|
-
Status: pre-release (`0.
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
app.state.
|
|
112
|
-
app.state.
|
|
113
|
-
app.state.
|
|
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.
|
|
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,
|
{gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gargoyle-receipts
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.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
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
61
61
|
|
|
62
|
-
Status: pre-release (`0.
|
|
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) |
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gargoyle-receipts"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.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"
|
{gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_issuer_service.py
RENAMED
|
@@ -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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/key_record_source.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/published_record_source.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/specs/graph-reference.json
RENAMED
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/static/approve.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_entra_identity.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_mcp_self_issue.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_published_record.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_stdio_encoding.py
RENAMED
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_typescript_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|