gargoyle-receipts 0.20.0.dev0__tar.gz → 0.21.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.20.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.21.0.dev0}/PKG-INFO +12 -4
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/README.md +11 -3
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/claude_hook.py +30 -15
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/cli.py +68 -5
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/evidence.py +39 -28
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/issuer_service.py +8 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/mcp_server.py +13 -8
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/reconcile.py +20 -2
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +12 -4
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/pyproject.toml +1 -1
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_approvers.py +18 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_claude_hook.py +58 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_revert.py +9 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_review_fixes.py +1 -1
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_witness.py +50 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/__main__.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/adapters.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/approval.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/approvers.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/consumer.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/doctor.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/emilia.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/emilia_runner.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/envelope.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/execution.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/globs.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/holdstore.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/holdtext.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/identity.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/oauth_broker.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/operations.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/plans.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/policy.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/published_record_source.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/remote.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/replay_cache.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/revert.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/rulecheck.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/rules.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/sidedoors.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/state.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/static/approve.html +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/static/approve.js +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/verifier.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/witness.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_approval_views.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_cli.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_console_js.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_declines.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_doctor.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_emilia_interop.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_emilia_runner.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_entra_identity.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_env_file.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_evidence.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_globs.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_graph.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_hardening_2026_11_01.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_historical.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_issuer_service.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_mcp_self_issue.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_oauth_broker.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_operability.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_operations.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_plans.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_record_follow.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_remote.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_rulecheck.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_rules.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_sidedoors.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_state.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_state_text.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_threshold.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_wire_spec.py +0 -0
{gargoyle_receipts-0.20.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.21.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.21.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
|
|
@@ -64,7 +64,7 @@ by whom, and what changed.
|
|
|
64
64
|
|
|
65
65
|
## Status
|
|
66
66
|
|
|
67
|
-
Pre-release `0.
|
|
67
|
+
Pre-release `0.21.0.dev0` on PyPI as `gargoyle-receipts`. Wire format
|
|
68
68
|
`gargoyle/3`, evidence log `gargoyle-evidence/4`, witness
|
|
69
69
|
`gargoyle-witness/1`; all three are specified in [SPEC.md](SPEC.md) and may
|
|
70
70
|
still change before a first release.
|
|
@@ -91,7 +91,7 @@ against a fake Graph server); the OAuth broker for hosted connectors
|
|
|
91
91
|
(deployed, metadata and redirect checks verified, no human sign-in through a
|
|
92
92
|
connector yet).
|
|
93
93
|
|
|
94
|
-
**Assurance.**
|
|
94
|
+
**Assurance.** 564 Python tests on Linux and Windows across Python 3.12,
|
|
95
95
|
3.13 and 3.14, at dependency floors and at a lock file; a TypeScript suite
|
|
96
96
|
that recomputes the conformance vectors byte for byte; two outside,
|
|
97
97
|
model-driven security reviews on 2026-10-20 (ten findings, all fixed); the
|
|
@@ -378,6 +378,13 @@ enrollment, revocation and discovery report, on the operator's server.
|
|
|
378
378
|
gargoyle audit --log audit.jsonl --witness witness.jsonl # reconcile by decision id: unwitnessed receipts, releases without a grant, missing evidence
|
|
379
379
|
gargoyle audit --log audit.jsonl --check-tree /srv/shared # hash the gated folder against the recorded states; name files changed around the gate
|
|
380
380
|
gargoyle audit --log audit.jsonl --summary # calls by principal and tool, side-door findings
|
|
381
|
+
gargoyle audit --log audit.jsonl --issuer https://gargoyle.lan # take the issuer's keys and every approver key it has seen from the issuer, not from a copied file
|
|
382
|
+
|
|
383
|
+
The audit needs the public keys (`public-keys.json`, by default beside the
|
|
384
|
+
log or in `--out`). Grants verify against every key an approver has held,
|
|
385
|
+
so an approver who re-enrolled a browser key is still audited; with
|
|
386
|
+
`--issuer` that list comes from the issuer itself and a stale copy of
|
|
387
|
+
`approvers.json` can no longer fail a valid grant.
|
|
381
388
|
|
|
382
389
|
On a machine the user administers the gate constrains the assistant, not
|
|
383
390
|
the user; these checks make the difference visible. SPEC.md section 15
|
|
@@ -661,7 +668,8 @@ receipts work unchanged. Configuration is by environment:
|
|
|
661
668
|
**Issuing service** (`gargoyle.issuer_service`, run with uvicorn from
|
|
662
669
|
`app_from_env()`, or `gargoyle issuer`): identity token in, receipt out,
|
|
663
670
|
with the hold and grant exchange for flagged calls (`/issue`, `/pending`,
|
|
664
|
-
`/grants`, `/decline`, `/record`, `/keys`, `/
|
|
671
|
+
`/grants`, `/decline`, `/record`, `/keys`, `/approvers`, `/approvers/audit`,
|
|
672
|
+
`/sidedoors`) and the browser
|
|
665
673
|
approval console at `/approve`. Policy from a rules file or OPA; identity
|
|
666
674
|
from a pinned key or, with `GARGOYLE_ENTRA_TENANT`, the tenant's signing
|
|
667
675
|
keys (both at once when a pinned key is also set).
|
|
@@ -26,7 +26,7 @@ by whom, and what changed.
|
|
|
26
26
|
|
|
27
27
|
## Status
|
|
28
28
|
|
|
29
|
-
Pre-release `0.
|
|
29
|
+
Pre-release `0.21.0.dev0` on PyPI as `gargoyle-receipts`. Wire format
|
|
30
30
|
`gargoyle/3`, evidence log `gargoyle-evidence/4`, witness
|
|
31
31
|
`gargoyle-witness/1`; all three are specified in [SPEC.md](SPEC.md) and may
|
|
32
32
|
still change before a first release.
|
|
@@ -53,7 +53,7 @@ against a fake Graph server); the OAuth broker for hosted connectors
|
|
|
53
53
|
(deployed, metadata and redirect checks verified, no human sign-in through a
|
|
54
54
|
connector yet).
|
|
55
55
|
|
|
56
|
-
**Assurance.**
|
|
56
|
+
**Assurance.** 564 Python tests on Linux and Windows across Python 3.12,
|
|
57
57
|
3.13 and 3.14, at dependency floors and at a lock file; a TypeScript suite
|
|
58
58
|
that recomputes the conformance vectors byte for byte; two outside,
|
|
59
59
|
model-driven security reviews on 2026-10-20 (ten findings, all fixed); the
|
|
@@ -340,6 +340,13 @@ enrollment, revocation and discovery report, on the operator's server.
|
|
|
340
340
|
gargoyle audit --log audit.jsonl --witness witness.jsonl # reconcile by decision id: unwitnessed receipts, releases without a grant, missing evidence
|
|
341
341
|
gargoyle audit --log audit.jsonl --check-tree /srv/shared # hash the gated folder against the recorded states; name files changed around the gate
|
|
342
342
|
gargoyle audit --log audit.jsonl --summary # calls by principal and tool, side-door findings
|
|
343
|
+
gargoyle audit --log audit.jsonl --issuer https://gargoyle.lan # take the issuer's keys and every approver key it has seen from the issuer, not from a copied file
|
|
344
|
+
|
|
345
|
+
The audit needs the public keys (`public-keys.json`, by default beside the
|
|
346
|
+
log or in `--out`). Grants verify against every key an approver has held,
|
|
347
|
+
so an approver who re-enrolled a browser key is still audited; with
|
|
348
|
+
`--issuer` that list comes from the issuer itself and a stale copy of
|
|
349
|
+
`approvers.json` can no longer fail a valid grant.
|
|
343
350
|
|
|
344
351
|
On a machine the user administers the gate constrains the assistant, not
|
|
345
352
|
the user; these checks make the difference visible. SPEC.md section 15
|
|
@@ -623,7 +630,8 @@ receipts work unchanged. Configuration is by environment:
|
|
|
623
630
|
**Issuing service** (`gargoyle.issuer_service`, run with uvicorn from
|
|
624
631
|
`app_from_env()`, or `gargoyle issuer`): identity token in, receipt out,
|
|
625
632
|
with the hold and grant exchange for flagged calls (`/issue`, `/pending`,
|
|
626
|
-
`/grants`, `/decline`, `/record`, `/keys`, `/
|
|
633
|
+
`/grants`, `/decline`, `/record`, `/keys`, `/approvers`, `/approvers/audit`,
|
|
634
|
+
`/sidedoors`) and the browser
|
|
627
635
|
approval console at `/approve`. Policy from a rules file or OPA; identity
|
|
628
636
|
from a pinned key or, with `GARGOYLE_ENTRA_TENANT`, the tenant's signing
|
|
629
637
|
keys (both at once when a pinned key is also set).
|
|
@@ -116,6 +116,14 @@ class PendingReceipts:
|
|
|
116
116
|
def __init__(self, path: str) -> None:
|
|
117
117
|
self.path = Path(path)
|
|
118
118
|
|
|
119
|
+
def _locked(self):
|
|
120
|
+
"""Pre- and PostToolUse hooks of parallel tool calls run as separate
|
|
121
|
+
processes at once; each read-modify-write holds the lock, or one
|
|
122
|
+
loses the other's entry (a call that ran with no evidence row, or an
|
|
123
|
+
honored one resurrected and reaped as never run)."""
|
|
124
|
+
from gargoyle.evidence import locked
|
|
125
|
+
return locked(self.path.with_name(self.path.name + ".lock"))
|
|
126
|
+
|
|
119
127
|
def _read(self) -> Dict[str, Any]:
|
|
120
128
|
try:
|
|
121
129
|
doc = json.loads(self.path.read_text(encoding="utf-8"))
|
|
@@ -129,26 +137,29 @@ class PendingReceipts:
|
|
|
129
137
|
os.replace(tmp, self.path)
|
|
130
138
|
|
|
131
139
|
def put(self, tool_use_id: str, gate: Dict[str, Any], params: Dict[str, Any], now: float) -> None:
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
140
|
+
with self._locked():
|
|
141
|
+
doc = self._read()
|
|
142
|
+
doc[tool_use_id] = {"envelope": gate["envelope"], "grant": gate["grant"], "self_issued": gate["self_issued"],
|
|
143
|
+
"reason": gate["reason"], "ref": gate.get("ref"), "params": params, "at": now}
|
|
144
|
+
for old in list(doc)[:-PENDING_LIMIT]:
|
|
145
|
+
doc.pop(old, None)
|
|
146
|
+
self._write(doc)
|
|
138
147
|
|
|
139
148
|
def pop(self, tool_use_id: str) -> Optional[Dict[str, Any]]:
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
149
|
+
with self._locked():
|
|
150
|
+
doc = self._read()
|
|
151
|
+
gate = doc.pop(tool_use_id, None)
|
|
152
|
+
if gate is not None:
|
|
153
|
+
self._write(doc)
|
|
144
154
|
return gate
|
|
145
155
|
|
|
146
156
|
def reap(self, now: float, max_age: float) -> List[Dict[str, Any]]:
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
157
|
+
with self._locked():
|
|
158
|
+
doc = self._read()
|
|
159
|
+
stale = [k for k, v in doc.items() if not isinstance(v.get("at"), (int, float)) or now - v["at"] > max_age]
|
|
160
|
+
out = [doc.pop(k) for k in stale]
|
|
161
|
+
if out:
|
|
162
|
+
self._write(doc)
|
|
152
163
|
return out
|
|
153
164
|
|
|
154
165
|
|
|
@@ -256,6 +267,10 @@ def decide(event: Dict[str, Any], state: Any, *, final: bool = False, now: Optio
|
|
|
256
267
|
gate = pending.pop(tool_use_id) if (pending is not None and tool_use_id) else None
|
|
257
268
|
if gate is None:
|
|
258
269
|
return {}
|
|
270
|
+
# The receipt bound the arguments PreToolUse saw; Claude Code may
|
|
271
|
+
# present some tools' input differently afterwards, so the entry is
|
|
272
|
+
# written over what was gated, not what PostToolUse carries.
|
|
273
|
+
params = gate["params"]
|
|
259
274
|
outputs = event.get("tool_response") if kind == POST else {"error": event.get("error")}
|
|
260
275
|
execution = _attest(state, gate["envelope"], outputs, params)
|
|
261
276
|
_record(state, HONORED, gate, params, execution)
|
|
@@ -635,9 +635,11 @@ def _audit_summary(report, raw) -> None:
|
|
|
635
635
|
releases[who] += 1
|
|
636
636
|
clients[r.get("client") or "(client unnamed)"] += 1
|
|
637
637
|
print("summary: %d call(s)%s" % (len(report), (", %s to %s" % (min(ats)[:19], max(ats)[:19])) if ats else ""))
|
|
638
|
-
print(" %-28s %-24s %7s %7s %7s" % ("principal", "tool", "honored", "
|
|
638
|
+
print(" %-28s %-24s %7s %7s %7s" % ("principal", "tool", "honored", "errored", "refused"))
|
|
639
639
|
for (principal, tool), c in sorted(counts.items(), key=lambda kv: (kv[0][0], -sum(kv[1].values()), kv[0][1])):
|
|
640
640
|
print(" %-28s %-24s %7d %7d %7d" % (principal[:28], tool[:24], c.get("honored", 0), c.get("failed", 0), c.get("refused", 0)))
|
|
641
|
+
if any(c.get("failed") for c in counts.values()):
|
|
642
|
+
print(" errored: admitted by the gate, then the upstream reported an error (a missing file, a bad argument); not a verification failure")
|
|
641
643
|
if releases:
|
|
642
644
|
print(" releases: " + ", ".join("%s %d" % (w, n) for w, n in releases.most_common()))
|
|
643
645
|
print(" clients: " + ", ".join("%s %d" % (w, n) for w, n in clients.most_common()))
|
|
@@ -786,11 +788,54 @@ def cmd_restore(args) -> int:
|
|
|
786
788
|
return 0 if (r.get("restored") or (args.verify_only and not r.get("refused"))) else 1
|
|
787
789
|
|
|
788
790
|
|
|
791
|
+
def _issuer_trust(url: str) -> "tuple[Dict[str, str], Dict[str, list]]":
|
|
792
|
+
"""The issuer's current signing keys (GET /keys) and every key each
|
|
793
|
+
enrolled approver has held (GET /approvers/audit), so an audit on a
|
|
794
|
+
workstation needs no copy of approvers.json that can go stale. Both are
|
|
795
|
+
public endpoints. REQUESTS_CA_BUNDLE names the issuer's CA as usual."""
|
|
796
|
+
import requests
|
|
797
|
+
base = url.rstrip("/")
|
|
798
|
+
keys = requests.get(base + "/keys", timeout=10); keys.raise_for_status()
|
|
799
|
+
appr = requests.get(base + "/approvers/audit", timeout=10); appr.raise_for_status()
|
|
800
|
+
return dict(keys.json()), dict(appr.json().get("approvers", {}))
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _audit_trust_dir(args) -> "Path | None":
|
|
804
|
+
"""Where public-keys.json is: --out, else the evidence log's own folder
|
|
805
|
+
(on a workstation the keys sit beside audit.jsonl). None when neither."""
|
|
806
|
+
out = Path(args.out).resolve()
|
|
807
|
+
if (out / "public-keys.json").exists():
|
|
808
|
+
return out
|
|
809
|
+
if getattr(args, "log", None):
|
|
810
|
+
beside = Path(args.log).resolve().parent
|
|
811
|
+
if (beside / "public-keys.json").exists():
|
|
812
|
+
return beside
|
|
813
|
+
return None
|
|
814
|
+
|
|
815
|
+
|
|
789
816
|
def cmd_audit(args) -> int:
|
|
790
817
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
791
818
|
from gargoyle.execution import verify_chain
|
|
792
819
|
|
|
793
|
-
|
|
820
|
+
out = _audit_trust_dir(args)
|
|
821
|
+
if out is None:
|
|
822
|
+
print("public-keys.json not found in %s%s: pass --out DIR, the folder that holds public-keys.json (and approvers.json), "
|
|
823
|
+
"on a workstation the one with middleware.env" % (Path(args.out).resolve(),
|
|
824
|
+
(" or beside %s" % Path(args.log).resolve().parent) if getattr(args, "log", None) else ""), file=sys.stderr)
|
|
825
|
+
return 2
|
|
826
|
+
keys, approvers = _trust_hex(out)
|
|
827
|
+
if getattr(args, "issuer", None):
|
|
828
|
+
try:
|
|
829
|
+
issuer_keys, issuer_approvers = _issuer_trust(args.issuer)
|
|
830
|
+
except Exception as exc:
|
|
831
|
+
print("issuer not reachable for keys: %s (%s)" % (args.issuer, exc), file=sys.stderr)
|
|
832
|
+
return 2
|
|
833
|
+
keys["issuer"] = dict(keys.get("issuer", {}), **issuer_keys)
|
|
834
|
+
# The issuer's list is current and complete; a local copy may be older.
|
|
835
|
+
for upn, hexes in issuer_approvers.items():
|
|
836
|
+
have = approvers.get(upn)
|
|
837
|
+
have = have if isinstance(have, list) else ([have] if have else [])
|
|
838
|
+
approvers[upn] = list(hexes) + [h for h in have if h not in hexes]
|
|
794
839
|
one = lambda h: Ed25519PublicKey.from_public_bytes(bytes.fromhex(h))
|
|
795
840
|
load = lambda m: {k: ([one(x) for x in v] if isinstance(v, list) else one(v)) for k, v in m.items()}
|
|
796
841
|
trust = dict(issuer_public_keys=load(keys["issuer"]), executor_public_keys=load(keys["executor"]),
|
|
@@ -821,6 +866,17 @@ def cmd_audit(args) -> int:
|
|
|
821
866
|
failed = sum(1 for r in v["entries"] if not r["accepted"])
|
|
822
867
|
print("%d entries (%d honored, %d refused), %d failed; chain %s; tail %s"
|
|
823
868
|
% (v["count"], v["honored"], v["refused"], failed, v["reason"], v["tail_sha256"][:16]))
|
|
869
|
+
from gargoyle.approval import REF_APPROVAL_KEY_UNKNOWN, REF_APPROVAL_SIGNATURE_INVALID
|
|
870
|
+
bad_sigs = [r for r in v["entries"] if not r["accepted"] and r["reason"] in (REF_APPROVAL_SIGNATURE_INVALID, REF_APPROVAL_KEY_UNKNOWN)]
|
|
871
|
+
if bad_sigs:
|
|
872
|
+
ids = sorted({k for r in bad_sigs for k in (r.get("released_by") or [])})
|
|
873
|
+
unknown = [k for k in ids if k not in approvers]
|
|
874
|
+
stale = [k for k in ids if k in approvers]
|
|
875
|
+
print(" note: %d release(s) failed the grant check%s%s. The approver keys here come from %s; an approver who "
|
|
876
|
+
"re-enrolled a browser key since that copy was made signs with a key it does not list. Copy the issuer's "
|
|
877
|
+
"current approvers.json beside public-keys.json, or pass --issuer URL to fetch every key the issuer has seen."
|
|
878
|
+
% (len(bad_sigs), (": approver id(s) not known here: %s" % ", ".join(unknown)) if unknown else "",
|
|
879
|
+
("; known approver(s) whose listed key does not verify the grant: %s" % ", ".join(stale)) if stale else "", out))
|
|
824
880
|
rc = 0 if v["accepted"] else 1
|
|
825
881
|
entries = read_log(args.log)
|
|
826
882
|
if args.summary:
|
|
@@ -851,14 +907,20 @@ def cmd_audit(args) -> int:
|
|
|
851
907
|
if isinstance(e, dict) and isinstance(e.get("receipt"), dict) and e["receipt"].get("target_url")), None)
|
|
852
908
|
r = reconcile(entries, read_witness(args.witness), principal=principal, target_url=target)
|
|
853
909
|
older = [f for f in r["findings"] if f["finding"] == "PREDATES_WITNESS"]
|
|
910
|
+
before_log = [f for f in r["findings"] if f["finding"] == "PREDATES_EVIDENCE"]
|
|
854
911
|
for f in r["findings"]:
|
|
855
|
-
if f["finding"]
|
|
912
|
+
if f["finding"] in ("PREDATES_WITNESS", "PREDATES_EVIDENCE"):
|
|
856
913
|
continue
|
|
857
914
|
print(" FINDING %-22s %s" % (f["finding"], " ".join("%s=%s" % (k, str(v)[:40]) for k, v in f.items() if k != "finding")))
|
|
858
915
|
if older:
|
|
859
916
|
print(" note: %d honored row(s) predate the witness (started %s) and cannot be vouched for either way: seq %s"
|
|
860
917
|
% (len(older), (r.get("horizon") or "?")[:19], ", ".join(str(f["seq"]) for f in older[:12]) + (" ..." if len(older) > 12 else "")))
|
|
861
|
-
|
|
918
|
+
if before_log:
|
|
919
|
+
held_n = sum(1 for f in before_log if f.get("held"))
|
|
920
|
+
print(" note: %d receipt(s) the issuer issued to this principal predate this evidence log (first entry %s; %d of them held): "
|
|
921
|
+
"a log that began later cannot be asked about them. If the log was not meant to start then, that is the finding."
|
|
922
|
+
% (len(before_log), (r.get("log_start") or "?")[:19], held_n))
|
|
923
|
+
hard = len(r["findings"]) - len(older) - len(before_log)
|
|
862
924
|
print("reconciled: %d issued by the issuer, %d in evidence, %d finding(s)%s"
|
|
863
925
|
% (r["issued"], r["evidence_with_receipt"], hard,
|
|
864
926
|
" (%d issued for other targets, not this gate's)" % r["issued_elsewhere"] if r.get("issued_elsewhere") else ""))
|
|
@@ -1021,7 +1083,8 @@ def main(argv=None) -> int:
|
|
|
1021
1083
|
p.add_argument("--witness", help="the issuer's witness.jsonl: reconcile the evidence against it")
|
|
1022
1084
|
p.add_argument("--check-tree", metavar="DIR", help="verify every gated file under DIR is as the gate left it")
|
|
1023
1085
|
p.add_argument("--summary", action="store_true", help="after the rows: calls by principal and tool with outcomes, releases, clients")
|
|
1024
|
-
p.add_argument("--out", default="gargoyle-deploy")
|
|
1086
|
+
p.add_argument("--out", default="gargoyle-deploy", help="folder with public-keys.json and approvers.json (default: gargoyle-deploy, else the log's own folder)")
|
|
1087
|
+
p.add_argument("--issuer", metavar="URL", help="fetch the issuer's keys and every approver key it has seen (GET /keys, /approvers/audit) instead of relying on a copied approvers.json")
|
|
1025
1088
|
p.set_defaults(fn=cmd_audit)
|
|
1026
1089
|
p = sub.add_parser("emilia", help="export an evidence log's approved calls as EMILIA Trust Receipts")
|
|
1027
1090
|
p.add_argument("--log", required=True, help="the middleware's audit.jsonl")
|
|
@@ -127,6 +127,37 @@ def build_entry(*, seq: int, prev_sha256: str, kind: str, tool: str, args: Any,
|
|
|
127
127
|
return entry
|
|
128
128
|
|
|
129
129
|
|
|
130
|
+
def locked(lock_path: Any):
|
|
131
|
+
"""An exclusive, blocking lock on `lock_path` (POSIX flock, Windows
|
|
132
|
+
byte-range lock), released on exit. Processes that share a file on one
|
|
133
|
+
workstation, the hook's PreToolUse and PostToolUse among them, hold it
|
|
134
|
+
across their read-modify-write so neither loses the other's update."""
|
|
135
|
+
import contextlib
|
|
136
|
+
|
|
137
|
+
@contextlib.contextmanager
|
|
138
|
+
def hold():
|
|
139
|
+
with open(lock_path, "a+b") as lock:
|
|
140
|
+
try:
|
|
141
|
+
import fcntl
|
|
142
|
+
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
|
143
|
+
unlock = lambda: fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
|
|
144
|
+
except ImportError: # Windows
|
|
145
|
+
import msvcrt
|
|
146
|
+
lock.seek(0)
|
|
147
|
+
while True:
|
|
148
|
+
try:
|
|
149
|
+
msvcrt.locking(lock.fileno(), msvcrt.LK_LOCK, 1) # retries about ten seconds, then raises
|
|
150
|
+
break
|
|
151
|
+
except OSError:
|
|
152
|
+
continue
|
|
153
|
+
unlock = lambda: (lock.seek(0), msvcrt.locking(lock.fileno(), msvcrt.LK_UNLCK, 1))
|
|
154
|
+
try:
|
|
155
|
+
yield
|
|
156
|
+
finally:
|
|
157
|
+
unlock()
|
|
158
|
+
return hold()
|
|
159
|
+
|
|
160
|
+
|
|
130
161
|
class EvidenceLog:
|
|
131
162
|
"""Appends entries to a JSON Lines file, continuing the chain found at
|
|
132
163
|
its tail. Several gate processes may share one log (a Claude Code and
|
|
@@ -156,33 +187,8 @@ class EvidenceLog:
|
|
|
156
187
|
self._seq, self._tail = int(last["seq"]), str(last["entry_sha256"])
|
|
157
188
|
|
|
158
189
|
def _locked(self):
|
|
159
|
-
"""An exclusive, blocking lock on the log's lock file
|
|
160
|
-
|
|
161
|
-
import contextlib
|
|
162
|
-
|
|
163
|
-
@contextlib.contextmanager
|
|
164
|
-
def hold():
|
|
165
|
-
lock_path = self.path.with_name(self.path.name + ".lock")
|
|
166
|
-
with open(lock_path, "a+b") as lock:
|
|
167
|
-
try:
|
|
168
|
-
import fcntl
|
|
169
|
-
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
|
170
|
-
unlock = lambda: fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
|
|
171
|
-
except ImportError: # Windows
|
|
172
|
-
import msvcrt
|
|
173
|
-
lock.seek(0)
|
|
174
|
-
while True:
|
|
175
|
-
try:
|
|
176
|
-
msvcrt.locking(lock.fileno(), msvcrt.LK_LOCK, 1) # retries about ten seconds, then raises
|
|
177
|
-
break
|
|
178
|
-
except OSError:
|
|
179
|
-
continue
|
|
180
|
-
unlock = lambda: (lock.seek(0), msvcrt.locking(lock.fileno(), msvcrt.LK_UNLCK, 1))
|
|
181
|
-
try:
|
|
182
|
-
yield
|
|
183
|
-
finally:
|
|
184
|
-
unlock()
|
|
185
|
-
return hold()
|
|
190
|
+
"""An exclusive, blocking lock on the log's lock file, released on exit."""
|
|
191
|
+
return locked(self.path.with_name(self.path.name + ".lock"))
|
|
186
192
|
|
|
187
193
|
def append(self, kind: str, tool: str, args: Any, principal: str, reason: str, *,
|
|
188
194
|
receipt: Any = None, grant: Any = None, execution: Any = None,
|
|
@@ -287,8 +293,13 @@ def verify_log(
|
|
|
287
293
|
else:
|
|
288
294
|
reason = ACCEPT_ENTRY_VERIFIED
|
|
289
295
|
if e["receipt"] is not None:
|
|
296
|
+
# Provenance only: a refused entry says the receipt was issued and
|
|
297
|
+
# the call did not run. It may be written long after the receipt
|
|
298
|
+
# expired (an honored call nobody came back for is reaped an hour
|
|
299
|
+
# on), so it is checked as of its own issuance, not the entry's.
|
|
300
|
+
issued_at = e["receipt"].get("timestamp_utc") if isinstance(e["receipt"], dict) else None
|
|
290
301
|
p = verify_provenance(e["receipt"], pinned_public_keys=issuer_public_keys,
|
|
291
|
-
now=_parse_at(e["at"]), historical=True)
|
|
302
|
+
now=_parse_at(issued_at if isinstance(issued_at, str) else e["at"]), historical=True)
|
|
292
303
|
ok, reason = p["accepted"], (ACCEPT_ENTRY_VERIFIED if p["accepted"] else p["reason"])
|
|
293
304
|
counts[e["kind"]] += 1
|
|
294
305
|
plan_ref = None
|
|
@@ -777,6 +777,14 @@ def build_issuer_app(
|
|
|
777
777
|
_notice_revocations()
|
|
778
778
|
return {"approvers": registry.public_hex()}
|
|
779
779
|
|
|
780
|
+
@app.get("/approvers/audit")
|
|
781
|
+
async def approvers_audit():
|
|
782
|
+
"""Public, for `gargoyle audit --issuer`: every key each approver has
|
|
783
|
+
held, current first, then replaced and revoked ones. A grant signed
|
|
784
|
+
while a key was trusted still verifies; nothing here admits a key."""
|
|
785
|
+
_notice_revocations()
|
|
786
|
+
return {"approvers": registry.audit_keys_hex()}
|
|
787
|
+
|
|
780
788
|
@app.post("/approvers/enroll")
|
|
781
789
|
async def enroll(request: Request, authorization: Optional[str] = Header(default=None)):
|
|
782
790
|
"""Bind the caller's UPN to the public key their browser generated.
|
|
@@ -888,16 +888,21 @@ def handle_message(state: ServerState, msg: Dict[str, Any]) -> Optional[Dict[str
|
|
|
888
888
|
# then goes through the gate exactly as the assistant's own writes.
|
|
889
889
|
from gargoyle.revert import REF_REVERT_DISABLED, revert_text
|
|
890
890
|
from gargoyle.state import REF_TARGET_STATE_UNREADABLE, StateUnreadable
|
|
891
|
-
if state.snapshots is None:
|
|
892
|
-
return _success(msg_id, {"content": [{"type": "text", "text": revert_text(REF_REVERT_DISABLED)}], "isError": True,
|
|
893
|
-
"_meta": {EXECUTED_META_KEY: False, REASON_META_KEY: REF_REVERT_DISABLED}})
|
|
894
891
|
rargs = params.get("arguments") or {}
|
|
895
|
-
|
|
896
|
-
plan =
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
892
|
+
if state.snapshots is None:
|
|
893
|
+
plan = {"reason": REF_REVERT_DISABLED}
|
|
894
|
+
else:
|
|
895
|
+
try:
|
|
896
|
+
plan = plan_revert(state.snapshots, str(rargs.get("decision_id") or "last"), state.adapter,
|
|
897
|
+
ref=rargs.get("path") if isinstance(rargs.get("path"), str) else None)
|
|
898
|
+
except StateUnreadable:
|
|
899
|
+
plan = {"reason": REF_TARGET_STATE_UNREADABLE}
|
|
900
900
|
if "reason" in plan:
|
|
901
|
+
# Refused before any receipt: the trail still says the assistant
|
|
902
|
+
# asked to undo something and why it could not (the Windows
|
|
903
|
+
# acceptance run of 2026-11-02 found this refusal unrecorded).
|
|
904
|
+
_record(state, "refused", {"reason": plan["reason"], "envelope": None, "grant": None,
|
|
905
|
+
"ref": rargs.get("path") if isinstance(rargs.get("path"), str) else None}, params)
|
|
901
906
|
return _success(msg_id, {"content": [{"type": "text", "text": revert_text(plan["reason"])}], "isError": True,
|
|
902
907
|
"_meta": {EXECUTED_META_KEY: False, REASON_META_KEY: plan["reason"]}})
|
|
903
908
|
params = plan["params"]
|
|
@@ -6,6 +6,7 @@ Findings, never silently fixed:
|
|
|
6
6
|
UNWITNESSED_RECEIPT evidence honors a decision the issuer never issued (forged or foreign)
|
|
7
7
|
UNWITNESSED_RELEASE a held call was honored but the issuer saw no grant for its hold (or plan)
|
|
8
8
|
MISSING_EVIDENCE the issuer issued a receipt to this principal and the client recorded nothing
|
|
9
|
+
PREDATES_EVIDENCE issued more than the window before this log's genesis entry; the log cannot be asked about it (information)
|
|
9
10
|
EVIDENCE_BEFORE_ISSUE the client's entry predates the issuer's issuance
|
|
10
11
|
CHANGED_OUTSIDE_GATE a file under the gated folder is not in the state the last gated write left it
|
|
11
12
|
"""
|
|
@@ -52,6 +53,17 @@ def reconcile(evidence: Iterable[Any], witness: Iterable[Any], *, principal: Opt
|
|
|
52
53
|
findings: List[Dict[str, Any]] = []
|
|
53
54
|
seen_decisions = set()
|
|
54
55
|
honored_at: Dict[Any, Any] = {} # decision_id -> seq of the entry that first honored it
|
|
56
|
+
# The evidence log's own beginning: an issuance older than its first entry
|
|
57
|
+
# is one this log was never in a position to record (a fresh install, a
|
|
58
|
+
# rotated log). Reported, counted, and kept apart from MISSING_EVIDENCE.
|
|
59
|
+
evidence = list(evidence)
|
|
60
|
+
log_start: Optional[datetime] = None
|
|
61
|
+
first = next((e for e in evidence if isinstance(e, dict)), None)
|
|
62
|
+
if first is not None and first.get("seq") == 1 and first.get("prev_sha256") == "0" * 64:
|
|
63
|
+
# Only a log that begins at its genesis entry can say when it began;
|
|
64
|
+
# a log missing its head is a chain failure, and what the head would
|
|
65
|
+
# have held stays MISSING_EVIDENCE.
|
|
66
|
+
log_start = _parse(first.get("at"))
|
|
55
67
|
for e in evidence:
|
|
56
68
|
if not isinstance(e, dict):
|
|
57
69
|
continue
|
|
@@ -100,10 +112,16 @@ def reconcile(evidence: Iterable[Any], witness: Iterable[Any], *, principal: Opt
|
|
|
100
112
|
if target_url is not None and isinstance(w.get("target_url"), str) and w["target_url"] != target_url:
|
|
101
113
|
elsewhere += 1
|
|
102
114
|
continue
|
|
103
|
-
|
|
115
|
+
at_w = _parse(w.get("at"))
|
|
116
|
+
# Every issuance precedes its own evidence entry by a little; only one
|
|
117
|
+
# older than the log's first entry by more than the window predates it.
|
|
118
|
+
kind = ("PREDATES_EVIDENCE" if (log_start is not None and at_w is not None
|
|
119
|
+
and (log_start - at_w).total_seconds() > window_seconds) else "MISSING_EVIDENCE")
|
|
120
|
+
findings.append({"finding": kind, "decision_id": did, "tool": w.get("tool"), "principal": w.get("principal"),
|
|
104
121
|
"held": bool(w.get("held")), "at": w.get("at")})
|
|
105
|
-
hard = [f for f in findings if f["finding"]
|
|
122
|
+
hard = [f for f in findings if f["finding"] not in ("PREDATES_WITNESS", "PREDATES_EVIDENCE")]
|
|
106
123
|
return {"accepted": not hard, "findings": findings, "horizon": horizon.isoformat() if horizon else None,
|
|
124
|
+
"log_start": log_start.isoformat() if log_start else None,
|
|
107
125
|
"issued": len(issued), "evidence_with_receipt": len(seen_decisions), "issued_elsewhere": elsewhere}
|
|
108
126
|
|
|
109
127
|
|
{gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.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.21.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
|
|
@@ -64,7 +64,7 @@ by whom, and what changed.
|
|
|
64
64
|
|
|
65
65
|
## Status
|
|
66
66
|
|
|
67
|
-
Pre-release `0.
|
|
67
|
+
Pre-release `0.21.0.dev0` on PyPI as `gargoyle-receipts`. Wire format
|
|
68
68
|
`gargoyle/3`, evidence log `gargoyle-evidence/4`, witness
|
|
69
69
|
`gargoyle-witness/1`; all three are specified in [SPEC.md](SPEC.md) and may
|
|
70
70
|
still change before a first release.
|
|
@@ -91,7 +91,7 @@ against a fake Graph server); the OAuth broker for hosted connectors
|
|
|
91
91
|
(deployed, metadata and redirect checks verified, no human sign-in through a
|
|
92
92
|
connector yet).
|
|
93
93
|
|
|
94
|
-
**Assurance.**
|
|
94
|
+
**Assurance.** 564 Python tests on Linux and Windows across Python 3.12,
|
|
95
95
|
3.13 and 3.14, at dependency floors and at a lock file; a TypeScript suite
|
|
96
96
|
that recomputes the conformance vectors byte for byte; two outside,
|
|
97
97
|
model-driven security reviews on 2026-10-20 (ten findings, all fixed); the
|
|
@@ -378,6 +378,13 @@ enrollment, revocation and discovery report, on the operator's server.
|
|
|
378
378
|
gargoyle audit --log audit.jsonl --witness witness.jsonl # reconcile by decision id: unwitnessed receipts, releases without a grant, missing evidence
|
|
379
379
|
gargoyle audit --log audit.jsonl --check-tree /srv/shared # hash the gated folder against the recorded states; name files changed around the gate
|
|
380
380
|
gargoyle audit --log audit.jsonl --summary # calls by principal and tool, side-door findings
|
|
381
|
+
gargoyle audit --log audit.jsonl --issuer https://gargoyle.lan # take the issuer's keys and every approver key it has seen from the issuer, not from a copied file
|
|
382
|
+
|
|
383
|
+
The audit needs the public keys (`public-keys.json`, by default beside the
|
|
384
|
+
log or in `--out`). Grants verify against every key an approver has held,
|
|
385
|
+
so an approver who re-enrolled a browser key is still audited; with
|
|
386
|
+
`--issuer` that list comes from the issuer itself and a stale copy of
|
|
387
|
+
`approvers.json` can no longer fail a valid grant.
|
|
381
388
|
|
|
382
389
|
On a machine the user administers the gate constrains the assistant, not
|
|
383
390
|
the user; these checks make the difference visible. SPEC.md section 15
|
|
@@ -661,7 +668,8 @@ receipts work unchanged. Configuration is by environment:
|
|
|
661
668
|
**Issuing service** (`gargoyle.issuer_service`, run with uvicorn from
|
|
662
669
|
`app_from_env()`, or `gargoyle issuer`): identity token in, receipt out,
|
|
663
670
|
with the hold and grant exchange for flagged calls (`/issue`, `/pending`,
|
|
664
|
-
`/grants`, `/decline`, `/record`, `/keys`, `/
|
|
671
|
+
`/grants`, `/decline`, `/record`, `/keys`, `/approvers`, `/approvers/audit`,
|
|
672
|
+
`/sidedoors`) and the browser
|
|
665
673
|
approval console at `/approve`. Policy from a rules file or OPA; identity
|
|
666
674
|
from a pinned key or, with `GARGOYLE_ENTRA_TENANT`, the tenant's signing
|
|
667
675
|
keys (both at once when a pinned key is also set).
|
|
@@ -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.21.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"
|
|
@@ -330,3 +330,21 @@ def test_a_revoke_inside_the_same_timestamp_tick_is_still_noticed(tmp_path):
|
|
|
330
330
|
os.utime(path, ns=(before.st_atime_ns, before.st_mtime_ns)) # the write is invisible by mtime
|
|
331
331
|
assert path.stat().st_mtime_ns == before.st_mtime_ns
|
|
332
332
|
assert "dana@contoso.com" not in reg and reg.revoked_entries()["dana@contoso.com"]["revoked_by"] == "operator:root"
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def test_approvers_audit_endpoint_lists_every_key_an_approver_held(tmp_path):
|
|
336
|
+
"""GET /approvers/audit is what `gargoyle audit --issuer` reads: the current
|
|
337
|
+
key first, then replaced and revoked ones, so grants signed under any of
|
|
338
|
+
them verify; GET /approvers keeps listing only the current, trusted key."""
|
|
339
|
+
reg = ApproverRegistry(str(tmp_path / "approvers.json"))
|
|
340
|
+
app = build_issuer_app(policy=Hold(), signing_key=issuer, key_id="issuer-1", idp_public_keys={"idp-1": idp.public_key()},
|
|
341
|
+
approver_registry=reg)
|
|
342
|
+
c = TestClient(app)
|
|
343
|
+
h = {"Authorization": "Bearer " + token("dana@contoso.com", roles=["Approver"])}
|
|
344
|
+
k1, k2 = Ed25519PrivateKey.generate(), Ed25519PrivateKey.generate()
|
|
345
|
+
assert c.post("/approvers/enroll", json={"public_key_hex": pub_hex(k1)}, headers=h).json()["enrolled"]
|
|
346
|
+
assert c.post("/approvers/revoke", json={"upn": "dana@contoso.com"}, headers=h).json() == {"revoked": True}
|
|
347
|
+
assert c.post("/approvers/enroll", json={"public_key_hex": pub_hex(k2)}, headers=h).json()["enrolled"]
|
|
348
|
+
assert c.get("/approvers").json() == {"approvers": {"dana@contoso.com": pub_hex(k2)}}
|
|
349
|
+
audit = c.get("/approvers/audit").json()["approvers"]
|
|
350
|
+
assert audit["dana@contoso.com"][0] == pub_hex(k2) and set(audit["dana@contoso.com"]) == {pub_hex(k1), pub_hex(k2)}
|
|
@@ -177,3 +177,61 @@ def test_pending_store_is_bounded_and_survives_garbage(tmp_path):
|
|
|
177
177
|
assert len(p._read()) == 200 and "t4" not in p._read() and "t204" in p._read()
|
|
178
178
|
assert len(p.reap(now=1000.0, max_age=800.0)) == 195
|
|
179
179
|
assert p.pop("t204")["params"]["name"] == "Read" and p.pop("t204") is None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_post_event_input_differs_from_pre_and_the_entry_still_matches_the_receipt(tmp_path, monkeypatch):
|
|
183
|
+
# Claude Code presents some tools' input differently in PostToolUse than
|
|
184
|
+
# in PreToolUse (seen live with Artifact). The receipt bound what the gate
|
|
185
|
+
# saw; the honored entry must hash those arguments or the audit reports
|
|
186
|
+
# REF_EVIDENCE_RECEIPT_MISSING for a call that was gated correctly.
|
|
187
|
+
out, project, env, state, client = deployment(tmp_path, monkeypatch)
|
|
188
|
+
decide(event(PRE, "Bash", {"command": "true"}, "toolu_1"), state())
|
|
189
|
+
decide(event(POST, "Bash", {"command": "true", "description": "added afterwards"}, "toolu_1", tool_response={"stdout": ""}), state())
|
|
190
|
+
entries = read_log(env["GARGOYLE_MCP_AUDIT_LOG"])
|
|
191
|
+
assert [(e["kind"], e["tool"]) for e in entries] == [("honored", "Bash")]
|
|
192
|
+
assert entries[0]["args_sha256"] == entries[0]["receipt"]["request_context"]["args_sha256"]
|
|
193
|
+
assert main(["audit", "--log", env["GARGOYLE_MCP_AUDIT_LOG"], "--out", str(out)]) == 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_reaped_entry_written_after_the_receipt_expired_still_verifies(tmp_path, monkeypatch):
|
|
197
|
+
# An honored receipt nobody came back for is reaped REAP_SECONDS later,
|
|
198
|
+
# long after its TTL. The refused entry carries it as provenance of what
|
|
199
|
+
# was gated; it is checked as of issuance, not as of the entry.
|
|
200
|
+
import datetime as dt
|
|
201
|
+
from gargoyle import evidence as ev
|
|
202
|
+
out, project, env, state, client = deployment(tmp_path, monkeypatch)
|
|
203
|
+
decide(event(PRE, "Bash", {"command": "true"}, "toolu_1"), state(), now=1000.0)
|
|
204
|
+
later = dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=REAP_SECONDS + 1)
|
|
205
|
+
|
|
206
|
+
class Clock(dt.datetime):
|
|
207
|
+
@classmethod
|
|
208
|
+
def now(cls, tz=None):
|
|
209
|
+
return later if tz is not None else later.replace(tzinfo=None)
|
|
210
|
+
monkeypatch.setattr(ev, "datetime", Clock)
|
|
211
|
+
decide(event(PRE, "Read", {"file_path": str(project / "a.txt")}, "toolu_2"), state(), now=1000.0 + REAP_SECONDS + 1)
|
|
212
|
+
monkeypatch.undo()
|
|
213
|
+
entries = read_log(env["GARGOYLE_MCP_AUDIT_LOG"])
|
|
214
|
+
reaped = [e for e in entries if e["reason"] == REF_HOOK_NOT_RUN]
|
|
215
|
+
assert len(reaped) == 1 and reaped[0]["at"] > reaped[0]["receipt"]["not_after"]
|
|
216
|
+
# The audit exits 1 when any entry fails verification.
|
|
217
|
+
assert main(["audit", "--log", env["GARGOYLE_MCP_AUDIT_LOG"], "--out", str(out)]) == 0
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_pending_store_survives_concurrent_hooks(tmp_path):
|
|
221
|
+
# Parallel tool calls run their Pre and Post hooks as separate processes
|
|
222
|
+
# at once. Without the lock a put can overwrite a concurrent pop (the
|
|
223
|
+
# honored receipt comes back and is reaped as never run) or a concurrent
|
|
224
|
+
# put (a call that ran leaves no evidence row).
|
|
225
|
+
import threading
|
|
226
|
+
p = PendingReceipts(str(tmp_path / "audit.hook-pending.json"))
|
|
227
|
+
gate = {"envelope": {"decision_id": "d"}, "grant": None, "self_issued": True, "reason": "ok", "ref": None}
|
|
228
|
+
ids = ["t%d" % i for i in range(120)]
|
|
229
|
+
threads = [threading.Thread(target=p.put, args=(i, gate, {"name": "Bash", "arguments": {}}, 1.0)) for i in ids]
|
|
230
|
+
for t in threads: t.start()
|
|
231
|
+
for t in threads: t.join()
|
|
232
|
+
assert sorted(p._read()) == sorted(ids)
|
|
233
|
+
popped = []
|
|
234
|
+
threads = [threading.Thread(target=lambda i=i: popped.append(p.pop(i))) for i in ids]
|
|
235
|
+
for t in threads: t.start()
|
|
236
|
+
for t in threads: t.join()
|
|
237
|
+
assert len(popped) == 120 and all(g is not None for g in popped) and p._read() == {}
|
|
@@ -137,5 +137,14 @@ def test_revert_restores_an_approved_write_through_the_gate(tmp_path):
|
|
|
137
137
|
assert call(state, 6, "write_file", {"path": str(new), "content": "x"})["result"]["_meta"][EXECUTED_META_KEY]
|
|
138
138
|
r = call(state, 7, REVERT_TOOL, {"decision_id": "last"})
|
|
139
139
|
assert r["result"]["_meta"][REASON_META_KEY] == REF_REVERT_NEEDS_DELETE and new.exists()
|
|
140
|
+
|
|
141
|
+
# Both refusals are in the trail: the assistant asked to undo, and why it could not.
|
|
142
|
+
log = read_log(str(tmp_path / "audit.jsonl"))
|
|
143
|
+
refused = [(e["tool"], e["reason"]) for e in log if e["kind"] == "refused"]
|
|
144
|
+
assert refused == [(REVERT_TOOL, REF_REVERT_STATE_CHANGED), (REVERT_TOOL, REF_REVERT_NEEDS_DELETE)]
|
|
145
|
+
assert log[-1]["receipt"] is None and log[-1]["principal"]
|
|
146
|
+
v = verify_log(log, issuer_public_keys={"issuer-1": issuer.public_key()},
|
|
147
|
+
executor_public_keys={"executor-1": executor.public_key()}, approver_public_keys={"approver-1": approver.public_key()})
|
|
148
|
+
assert v["accepted"] and v["refused"] == 2
|
|
140
149
|
finally:
|
|
141
150
|
up.close()
|
|
@@ -134,7 +134,7 @@ def test_every_path_after_the_claim_leaves_evidence(tmp_path, capsys):
|
|
|
134
134
|
assert report["accepted"] and [e["verdict"] for e in report["entries"]][:3] == [TOOL_ERROR, UPSTREAM_ERROR, UPSTREAM_UNAVAILABLE]
|
|
135
135
|
_audit_summary(report["entries"], log)
|
|
136
136
|
out = capsys.readouterr().out
|
|
137
|
-
assert "
|
|
137
|
+
assert "errored" in out and "upstream reported an error" in out and "%-24s %7d %7d %7d" % ("write_file", 1, 3, 0) in out
|
|
138
138
|
# The spent receipts are spent: a retry of the failed one is a replay.
|
|
139
139
|
again = handle_message(state, _call(envs[0], {"path": "/gargoyle-test-root/f0"}, 5))
|
|
140
140
|
assert again["result"]["_meta"][REASON_META_KEY] == REF_VERIFY_REPLAY
|
|
@@ -133,6 +133,15 @@ def test_honest_evidence_reconciles_and_tampering_is_found(tmp_path):
|
|
|
133
133
|
# 3. A receipt the issuer issued but the client never recorded: drop the client's read entry.
|
|
134
134
|
r = reconcile([e for e in evidence if e["tool"] != "read_file"], witness, principal=PRINCIPAL)
|
|
135
135
|
assert [f["finding"] for f in r["findings"]] == ["MISSING_EVIDENCE"] and r["findings"][0]["tool"] == "read_file"
|
|
136
|
+
# An issuance older than the log's first entry is one this log could never have recorded:
|
|
137
|
+
# information with a count, not a failure (a fresh install, a rotated log).
|
|
138
|
+
ancient = dict([w for w in witness if w["event"] == "issued"][0], decision_id="d-ancient",
|
|
139
|
+
at=(datetime.fromisoformat(evidence[0]["at"]) - timedelta(days=2)).isoformat())
|
|
140
|
+
r = reconcile(evidence, witness + [ancient], principal=PRINCIPAL)
|
|
141
|
+
assert r["accepted"] and [f["finding"] for f in r["findings"]] == ["PREDATES_EVIDENCE"] and r["log_start"] == evidence[0]["at"]
|
|
142
|
+
later = dict(ancient, decision_id="d-later", at=(datetime.fromisoformat(evidence[0]["at"]) + timedelta(seconds=1)).isoformat())
|
|
143
|
+
r = reconcile(evidence, witness + [later], principal=PRINCIPAL)
|
|
144
|
+
assert not r["accepted"] and [f["finding"] for f in r["findings"]] == ["MISSING_EVIDENCE"]
|
|
136
145
|
|
|
137
146
|
# The tree: as the gate left it, then edited behind its back, then deleted.
|
|
138
147
|
t = check_tree(evidence, str(root))
|
|
@@ -231,3 +240,44 @@ def test_reconciliation_is_scoped_to_the_evidence_logs_target(tmp_path):
|
|
|
231
240
|
assert [f["finding"] for f in r["findings"]] == ["MISSING_EVIDENCE"]
|
|
232
241
|
r = reconcile(evidence, witness + [legacy], principal=PRINCIPAL, target_url=TARGET) # no target on record: reported
|
|
233
242
|
assert [f["finding"] for f in r["findings"]] == ["MISSING_EVIDENCE"] and r["issued_elsewhere"] == 0
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_audit_finds_its_keys_beside_the_log_and_can_take_approver_keys_from_the_issuer(tmp_path, capsys, monkeypatch):
|
|
246
|
+
"""A workstation's keys sit beside audit.jsonl; a wrong --out is not a
|
|
247
|
+
traceback. A grant signed by a key the local approvers copy does not list
|
|
248
|
+
fails with a note that says so; --issuer fetches the issuer's full list."""
|
|
249
|
+
root = tmp_path / "root"; root.mkdir(); (root / "n.md").write_text("one\n", encoding="utf-8", newline="\n")
|
|
250
|
+
state, up, c, h, w = _rig(tmp_path, root)
|
|
251
|
+
try:
|
|
252
|
+
assert call(state, 2, "write_file", {"path": str(root / "n.md"), "content": "two\n"})["_meta"][EXECUTED_META_KEY]
|
|
253
|
+
finally:
|
|
254
|
+
up.close()
|
|
255
|
+
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
|
256
|
+
hexpub = lambda k: k.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
|
|
257
|
+
from gargoyle.cli import main
|
|
258
|
+
log = str(tmp_path / "audit.jsonl")
|
|
259
|
+
# No keys anywhere: a usage error, not a traceback.
|
|
260
|
+
monkeypatch.chdir(tmp_path)
|
|
261
|
+
assert main(["audit", "--log", log]) == 2
|
|
262
|
+
assert "public-keys.json not found" in capsys.readouterr().err
|
|
263
|
+
# Keys beside the log, --out left at its default: found.
|
|
264
|
+
(tmp_path / "public-keys.json").write_text(json.dumps({"issuer": {"issuer-1": hexpub(issuer)}, "executor": {"executor-1": hexpub(executor)},
|
|
265
|
+
"approver": {"approver-1": hexpub(approver)}}))
|
|
266
|
+
assert main(["audit", "--log", log]) == 0
|
|
267
|
+
# The local copy does not know the approver's key: the release fails, and the note says what to do.
|
|
268
|
+
(tmp_path / "public-keys.json").write_text(json.dumps({"issuer": {"issuer-1": hexpub(issuer)}, "executor": {"executor-1": hexpub(executor)},
|
|
269
|
+
"approver": {}}))
|
|
270
|
+
assert main(["audit", "--log", log]) == 1
|
|
271
|
+
text = capsys.readouterr().out
|
|
272
|
+
assert "REF_APPROVAL_KEY_UNKNOWN" in text and "not known here: approver-1" in text and "--issuer URL" in text
|
|
273
|
+
# The copy knows the approver under an older key (the Windows run of 2026-11-02): named as stale, same remedy.
|
|
274
|
+
(tmp_path / "public-keys.json").write_text(json.dumps({"issuer": {"issuer-1": hexpub(issuer)}, "executor": {"executor-1": hexpub(executor)},
|
|
275
|
+
"approver": {"approver-1": hexpub(Ed25519PrivateKey.generate())}}))
|
|
276
|
+
assert main(["audit", "--log", log]) == 1
|
|
277
|
+
text = capsys.readouterr().out
|
|
278
|
+
assert "REF_APPROVAL_SIGNATURE_INVALID" in text and "listed key does not verify the grant: approver-1" in text
|
|
279
|
+
# --issuer: the issuer's list (current key plus history) stands in for the copy.
|
|
280
|
+
import gargoyle.cli as cli
|
|
281
|
+
monkeypatch.setattr(cli, "_issuer_trust", lambda url: ({"issuer-1": hexpub(issuer)}, {"approver-1": [hexpub(Ed25519PrivateKey.generate()), hexpub(approver)]}))
|
|
282
|
+
assert main(["audit", "--log", log, "--issuer", "https://issuer.test"]) == 0
|
|
283
|
+
assert "0 failed" in capsys.readouterr().out
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.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
|
|
File without changes
|
{gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/published_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.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/gargoyle/specs/graph-reference.json
RENAMED
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.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
|
{gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_approval_views.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
|
{gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_emilia_interop.py
RENAMED
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.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
|
{gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_hardening_2026_11_01.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_issuer_service.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_published_record.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
|
{gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_stdio_encoding.py
RENAMED
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.21.0.dev0}/tests/test_typescript_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|