gargoyle-receipts 0.20.0.dev0__tar.gz → 0.22.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.22.0.dev0}/PKG-INFO +14 -4
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/README.md +13 -3
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/claude_hook.py +30 -15
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/cli.py +79 -5
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/doctor.py +11 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/evidence.py +56 -28
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/issuer_service.py +8 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/mcp_server.py +13 -8
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/reconcile.py +31 -2
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +14 -4
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/pyproject.toml +1 -1
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_approvers.py +18 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_claude_hook.py +58 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_doctor.py +7 -3
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_evidence.py +22 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_revert.py +9 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_review_fixes.py +1 -1
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_witness.py +63 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/__main__.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/adapters.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/approval.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/approvers.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/consumer.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/emilia.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/emilia_runner.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/envelope.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/execution.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/globs.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/holdstore.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/holdtext.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/identity.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/oauth_broker.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/operations.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/plans.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/policy.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/published_record_source.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/remote.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/replay_cache.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/revert.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/rulecheck.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/rules.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/sidedoors.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/state.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/static/approve.html +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/static/approve.js +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/verifier.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle/witness.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_approval_views.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_cli.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_console_js.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_declines.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_emilia_interop.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_emilia_runner.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_entra_identity.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_env_file.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_globs.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_graph.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_hardening_2026_11_01.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_historical.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_issuer_service.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_mcp_self_issue.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_oauth_broker.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_operability.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_operations.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_plans.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_record_follow.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_remote.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_rulecheck.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_rules.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_sidedoors.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_state.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_state_text.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_threshold.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.0.dev0}/tests/test_wire_spec.py +0 -0
{gargoyle_receipts-0.20.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.22.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.22.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.22.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.** 565 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).
|
|
@@ -674,6 +682,8 @@ keys (both at once when a pinned key is also set).
|
|
|
674
682
|
| `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys, with a `revoked` list, re-read when its content changes) and which Entra app role (default `Approver`) or groups may enroll, see every hold and the witness |
|
|
675
683
|
| `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 `persistent` |
|
|
676
684
|
| `GARGOYLE_MAX_OPEN_HOLDS` | how many held receipts and plans one principal may have waiting at once (default 20); the call over the cap is `REF_ISSUER_TOO_MANY_HOLDS` and nothing is stored |
|
|
685
|
+
| `GARGOYLE_DECLINE_LIMIT`, `GARGOYLE_DECLINE_WINDOW_HOURS` | the decline cooldown: after this many declines of the same (principal, tool, path) within the window (defaults 2 and 24), the issuer refuses the next ask with `REF_ISSUER_DECLINED_RECENTLY` instead of holding it again, so an approver is not asked the same question a third time in a day; `0` disables |
|
|
686
|
+
| `GARGOYLE_RECEIPT_TTL_SECONDS` | how long an issued receipt stays presentable (default 300); a held receipt's hold lives up to six times that, capped at an hour |
|
|
677
687
|
| `GARGOYLE_ALLOW_SELF_APPROVAL` | `1` lets a person release their own call; by default the approver may be neither the hold's principal nor the party acted for (`REF_ISSUER_SELF_APPROVAL`). A grant for an expired hold is `REF_ISSUER_HOLD_EXPIRED` and the hold is dropped |
|
|
678
688
|
| `GARGOYLE_WITNESS_PATH` | the issuer's signed, hash-chained record of every decision (`gargoyle issuer` sets `witness.jsonl` beside the keys) |
|
|
679
689
|
| `GARGOYLE_HOLD_WEBHOOK_URL`, `GARGOYLE_APPROVE_URL` | 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 |
|
|
@@ -26,7 +26,7 @@ by whom, and what changed.
|
|
|
26
26
|
|
|
27
27
|
## Status
|
|
28
28
|
|
|
29
|
-
Pre-release `0.
|
|
29
|
+
Pre-release `0.22.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.** 565 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).
|
|
@@ -636,6 +644,8 @@ keys (both at once when a pinned key is also set).
|
|
|
636
644
|
| `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys, with a `revoked` list, re-read when its content changes) and which Entra app role (default `Approver`) or groups may enroll, see every hold and the witness |
|
|
637
645
|
| `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 `persistent` |
|
|
638
646
|
| `GARGOYLE_MAX_OPEN_HOLDS` | how many held receipts and plans one principal may have waiting at once (default 20); the call over the cap is `REF_ISSUER_TOO_MANY_HOLDS` and nothing is stored |
|
|
647
|
+
| `GARGOYLE_DECLINE_LIMIT`, `GARGOYLE_DECLINE_WINDOW_HOURS` | the decline cooldown: after this many declines of the same (principal, tool, path) within the window (defaults 2 and 24), the issuer refuses the next ask with `REF_ISSUER_DECLINED_RECENTLY` instead of holding it again, so an approver is not asked the same question a third time in a day; `0` disables |
|
|
648
|
+
| `GARGOYLE_RECEIPT_TTL_SECONDS` | how long an issued receipt stays presentable (default 300); a held receipt's hold lives up to six times that, capped at an hour |
|
|
639
649
|
| `GARGOYLE_ALLOW_SELF_APPROVAL` | `1` lets a person release their own call; by default the approver may be neither the hold's principal nor the party acted for (`REF_ISSUER_SELF_APPROVAL`). A grant for an expired hold is `REF_ISSUER_HOLD_EXPIRED` and the hold is dropped |
|
|
640
650
|
| `GARGOYLE_WITNESS_PATH` | the issuer's signed, hash-chained record of every decision (`gargoyle issuer` sets `witness.jsonl` beside the keys) |
|
|
641
651
|
| `GARGOYLE_HOLD_WEBHOOK_URL`, `GARGOYLE_APPROVE_URL` | 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 |
|
|
@@ -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,60 @@ 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
|
+
# A missing log is a usage error with the path named, not a traceback: a
|
|
821
|
+
# client whose evidence log was rotated or has not been written yet.
|
|
822
|
+
if getattr(args, "log", None) and not Path(args.log).is_file():
|
|
823
|
+
print("evidence log not found: %s (nothing recorded there yet, or the log was rotated under another name)"
|
|
824
|
+
% Path(args.log).resolve(), file=sys.stderr)
|
|
825
|
+
return 2
|
|
826
|
+
out = _audit_trust_dir(args)
|
|
827
|
+
if out is None:
|
|
828
|
+
print("public-keys.json not found in %s%s: pass --out DIR, the folder that holds public-keys.json (and approvers.json), "
|
|
829
|
+
"on a workstation the one with middleware.env" % (Path(args.out).resolve(),
|
|
830
|
+
(" or beside %s" % Path(args.log).resolve().parent) if getattr(args, "log", None) else ""), file=sys.stderr)
|
|
831
|
+
return 2
|
|
832
|
+
keys, approvers = _trust_hex(out)
|
|
833
|
+
if getattr(args, "issuer", None):
|
|
834
|
+
try:
|
|
835
|
+
issuer_keys, issuer_approvers = _issuer_trust(args.issuer)
|
|
836
|
+
except Exception as exc:
|
|
837
|
+
print("issuer not reachable for keys: %s (%s)" % (args.issuer, exc), file=sys.stderr)
|
|
838
|
+
return 2
|
|
839
|
+
keys["issuer"] = dict(keys.get("issuer", {}), **issuer_keys)
|
|
840
|
+
# The issuer's list is current and complete; a local copy may be older.
|
|
841
|
+
for upn, hexes in issuer_approvers.items():
|
|
842
|
+
have = approvers.get(upn)
|
|
843
|
+
have = have if isinstance(have, list) else ([have] if have else [])
|
|
844
|
+
approvers[upn] = list(hexes) + [h for h in have if h not in hexes]
|
|
794
845
|
one = lambda h: Ed25519PublicKey.from_public_bytes(bytes.fromhex(h))
|
|
795
846
|
load = lambda m: {k: ([one(x) for x in v] if isinstance(v, list) else one(v)) for k, v in m.items()}
|
|
796
847
|
trust = dict(issuer_public_keys=load(keys["issuer"]), executor_public_keys=load(keys["executor"]),
|
|
@@ -821,6 +872,17 @@ def cmd_audit(args) -> int:
|
|
|
821
872
|
failed = sum(1 for r in v["entries"] if not r["accepted"])
|
|
822
873
|
print("%d entries (%d honored, %d refused), %d failed; chain %s; tail %s"
|
|
823
874
|
% (v["count"], v["honored"], v["refused"], failed, v["reason"], v["tail_sha256"][:16]))
|
|
875
|
+
from gargoyle.approval import REF_APPROVAL_KEY_UNKNOWN, REF_APPROVAL_SIGNATURE_INVALID
|
|
876
|
+
bad_sigs = [r for r in v["entries"] if not r["accepted"] and r["reason"] in (REF_APPROVAL_SIGNATURE_INVALID, REF_APPROVAL_KEY_UNKNOWN)]
|
|
877
|
+
if bad_sigs:
|
|
878
|
+
ids = sorted({k for r in bad_sigs for k in (r.get("released_by") or [])})
|
|
879
|
+
unknown = [k for k in ids if k not in approvers]
|
|
880
|
+
stale = [k for k in ids if k in approvers]
|
|
881
|
+
print(" note: %d release(s) failed the grant check%s%s. The approver keys here come from %s; an approver who "
|
|
882
|
+
"re-enrolled a browser key since that copy was made signs with a key it does not list. Copy the issuer's "
|
|
883
|
+
"current approvers.json beside public-keys.json, or pass --issuer URL to fetch every key the issuer has seen."
|
|
884
|
+
% (len(bad_sigs), (": approver id(s) not known here: %s" % ", ".join(unknown)) if unknown else "",
|
|
885
|
+
("; known approver(s) whose listed key does not verify the grant: %s" % ", ".join(stale)) if stale else "", out))
|
|
824
886
|
rc = 0 if v["accepted"] else 1
|
|
825
887
|
entries = read_log(args.log)
|
|
826
888
|
if args.summary:
|
|
@@ -851,14 +913,25 @@ def cmd_audit(args) -> int:
|
|
|
851
913
|
if isinstance(e, dict) and isinstance(e.get("receipt"), dict) and e["receipt"].get("target_url")), None)
|
|
852
914
|
r = reconcile(entries, read_witness(args.witness), principal=principal, target_url=target)
|
|
853
915
|
older = [f for f in r["findings"] if f["finding"] == "PREDATES_WITNESS"]
|
|
916
|
+
before_log = [f for f in r["findings"] if f["finding"] == "PREDATES_EVIDENCE"]
|
|
917
|
+
in_flight = [f for f in r["findings"] if f["finding"] == "IN_FLIGHT"]
|
|
854
918
|
for f in r["findings"]:
|
|
855
|
-
if f["finding"]
|
|
919
|
+
if f["finding"] in ("PREDATES_WITNESS", "PREDATES_EVIDENCE", "IN_FLIGHT"):
|
|
856
920
|
continue
|
|
857
921
|
print(" FINDING %-22s %s" % (f["finding"], " ".join("%s=%s" % (k, str(v)[:40]) for k, v in f.items() if k != "finding")))
|
|
858
922
|
if older:
|
|
859
923
|
print(" note: %d honored row(s) predate the witness (started %s) and cannot be vouched for either way: seq %s"
|
|
860
924
|
% (len(older), (r.get("horizon") or "?")[:19], ", ".join(str(f["seq"]) for f in older[:12]) + (" ..." if len(older) > 12 else "")))
|
|
861
|
-
|
|
925
|
+
if before_log:
|
|
926
|
+
held_n = sum(1 for f in before_log if f.get("held"))
|
|
927
|
+
print(" note: %d receipt(s) the issuer issued to this principal predate this evidence log (first entry %s; %d of them held): "
|
|
928
|
+
"a log that began later cannot be asked about them. If the log was not meant to start then, that is the finding."
|
|
929
|
+
% (len(before_log), (r.get("log_start") or "?")[:19], held_n))
|
|
930
|
+
if in_flight:
|
|
931
|
+
print(" note: %d receipt(s) issued after this log's last entry (%s) have no row yet: in flight when the log was read "
|
|
932
|
+
"(this audit's own gated command, a call still held). Audit again later; one still missing then is MISSING_EVIDENCE."
|
|
933
|
+
% (len(in_flight), (r.get("log_end") or "?")[:19]))
|
|
934
|
+
hard = len(r["findings"]) - len(older) - len(before_log) - len(in_flight)
|
|
862
935
|
print("reconciled: %d issued by the issuer, %d in evidence, %d finding(s)%s"
|
|
863
936
|
% (r["issued"], r["evidence_with_receipt"], hard,
|
|
864
937
|
" (%d issued for other targets, not this gate's)" % r["issued_elsewhere"] if r.get("issued_elsewhere") else ""))
|
|
@@ -1021,7 +1094,8 @@ def main(argv=None) -> int:
|
|
|
1021
1094
|
p.add_argument("--witness", help="the issuer's witness.jsonl: reconcile the evidence against it")
|
|
1022
1095
|
p.add_argument("--check-tree", metavar="DIR", help="verify every gated file under DIR is as the gate left it")
|
|
1023
1096
|
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")
|
|
1097
|
+
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)")
|
|
1098
|
+
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
1099
|
p.set_defaults(fn=cmd_audit)
|
|
1026
1100
|
p = sub.add_parser("emilia", help="export an evidence log's approved calls as EMILIA Trust Receipts")
|
|
1027
1101
|
p.add_argument("--log", required=True, help="the middleware's audit.jsonl")
|
|
@@ -177,6 +177,17 @@ def check_issuer(env: Dict[str, str], ctx: Context) -> Dict[str, Any]:
|
|
|
177
177
|
if isinstance(head, dict):
|
|
178
178
|
detail += ", witness seq %s" % head.get("seq")
|
|
179
179
|
date = None
|
|
180
|
+
try:
|
|
181
|
+
date = r.headers.get("Date") if hasattr(r, "headers") else None
|
|
182
|
+
except Exception:
|
|
183
|
+
date = None
|
|
184
|
+
if h.get("enrolled_approvers") == 1:
|
|
185
|
+
# One browser key between every hold and its release: lost, re-enrolled
|
|
186
|
+
# or on leave, nothing is released until a second approver exists.
|
|
187
|
+
return {"check": Check("issuer", WARN, detail,
|
|
188
|
+
"one approver enrolled: a lost or re-enrolled browser key, or an absence, leaves every hold waiting; enroll a second approver (a deputy role) and test a release by each"),
|
|
189
|
+
"health": h, "date": date}
|
|
190
|
+
date = None
|
|
180
191
|
try:
|
|
181
192
|
date = r.headers.get("Date") if hasattr(r, "headers") else None
|
|
182
193
|
except Exception:
|
|
@@ -127,6 +127,54 @@ def build_entry(*, seq: int, prev_sha256: str, kind: str, tool: str, args: Any,
|
|
|
127
127
|
return entry
|
|
128
128
|
|
|
129
129
|
|
|
130
|
+
LOCK_WAIT_SECONDS = 60.0 # a lock not acquired in this long means a stuck holder; the caller fails closed
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def locked(lock_path: Any, wait_seconds: float = LOCK_WAIT_SECONDS):
|
|
134
|
+
"""An exclusive lock on `lock_path` (POSIX flock, Windows byte-range
|
|
135
|
+
lock), released on exit. Processes that share a file on one workstation,
|
|
136
|
+
the hook's PreToolUse and PostToolUse among them, hold it across their
|
|
137
|
+
read-modify-write so neither loses the other's update. The wait is
|
|
138
|
+
bounded: a holder that never lets go (a hung process) raises OSError
|
|
139
|
+
after `wait_seconds` rather than hanging every later call, and a gate
|
|
140
|
+
that cannot record does not let the call through."""
|
|
141
|
+
import contextlib
|
|
142
|
+
import time as _time
|
|
143
|
+
|
|
144
|
+
@contextlib.contextmanager
|
|
145
|
+
def hold():
|
|
146
|
+
with open(lock_path, "a+b") as lock:
|
|
147
|
+
deadline = _time.monotonic() + wait_seconds
|
|
148
|
+
try:
|
|
149
|
+
import fcntl
|
|
150
|
+
while True:
|
|
151
|
+
try:
|
|
152
|
+
fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
153
|
+
break
|
|
154
|
+
except OSError:
|
|
155
|
+
if _time.monotonic() >= deadline:
|
|
156
|
+
raise OSError("lock not acquired within %.0f s: %s (a stuck holder?)" % (wait_seconds, lock_path))
|
|
157
|
+
_time.sleep(0.02)
|
|
158
|
+
unlock = lambda: fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
|
|
159
|
+
except ImportError: # Windows
|
|
160
|
+
import msvcrt
|
|
161
|
+
lock.seek(0)
|
|
162
|
+
while True:
|
|
163
|
+
try:
|
|
164
|
+
msvcrt.locking(lock.fileno(), msvcrt.LK_NBLCK, 1)
|
|
165
|
+
break
|
|
166
|
+
except OSError:
|
|
167
|
+
if _time.monotonic() >= deadline:
|
|
168
|
+
raise OSError("lock not acquired within %.0f s: %s (a stuck holder?)" % (wait_seconds, lock_path))
|
|
169
|
+
_time.sleep(0.02)
|
|
170
|
+
unlock = lambda: (lock.seek(0), msvcrt.locking(lock.fileno(), msvcrt.LK_UNLCK, 1))
|
|
171
|
+
try:
|
|
172
|
+
yield
|
|
173
|
+
finally:
|
|
174
|
+
unlock()
|
|
175
|
+
return hold()
|
|
176
|
+
|
|
177
|
+
|
|
130
178
|
class EvidenceLog:
|
|
131
179
|
"""Appends entries to a JSON Lines file, continuing the chain found at
|
|
132
180
|
its tail. Several gate processes may share one log (a Claude Code and
|
|
@@ -156,33 +204,8 @@ class EvidenceLog:
|
|
|
156
204
|
self._seq, self._tail = int(last["seq"]), str(last["entry_sha256"])
|
|
157
205
|
|
|
158
206
|
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()
|
|
207
|
+
"""An exclusive, blocking lock on the log's lock file, released on exit."""
|
|
208
|
+
return locked(self.path.with_name(self.path.name + ".lock"))
|
|
186
209
|
|
|
187
210
|
def append(self, kind: str, tool: str, args: Any, principal: str, reason: str, *,
|
|
188
211
|
receipt: Any = None, grant: Any = None, execution: Any = None,
|
|
@@ -287,8 +310,13 @@ def verify_log(
|
|
|
287
310
|
else:
|
|
288
311
|
reason = ACCEPT_ENTRY_VERIFIED
|
|
289
312
|
if e["receipt"] is not None:
|
|
313
|
+
# Provenance only: a refused entry says the receipt was issued and
|
|
314
|
+
# the call did not run. It may be written long after the receipt
|
|
315
|
+
# expired (an honored call nobody came back for is reaped an hour
|
|
316
|
+
# on), so it is checked as of its own issuance, not the entry's.
|
|
317
|
+
issued_at = e["receipt"].get("timestamp_utc") if isinstance(e["receipt"], dict) else None
|
|
290
318
|
p = verify_provenance(e["receipt"], pinned_public_keys=issuer_public_keys,
|
|
291
|
-
now=_parse_at(e["at"]), historical=True)
|
|
319
|
+
now=_parse_at(issued_at if isinstance(issued_at, str) else e["at"]), historical=True)
|
|
292
320
|
ok, reason = p["accepted"], (ACCEPT_ENTRY_VERIFIED if p["accepted"] else p["reason"])
|
|
293
321
|
counts[e["kind"]] += 1
|
|
294
322
|
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,23 @@ 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"))
|
|
67
|
+
# The log's last entry: an issuance newer than it is a call whose evidence
|
|
68
|
+
# row the executor had not written when this log was read (the audit's own
|
|
69
|
+
# gated command, a call still waiting for its approver). Reported as
|
|
70
|
+
# IN_FLIGHT, information, not failure; the next audit settles it.
|
|
71
|
+
last = next((e for e in reversed(evidence) if isinstance(e, dict)), None)
|
|
72
|
+
log_end: Optional[datetime] = _parse(last.get("at")) if last is not None else None
|
|
55
73
|
for e in evidence:
|
|
56
74
|
if not isinstance(e, dict):
|
|
57
75
|
continue
|
|
@@ -100,10 +118,21 @@ def reconcile(evidence: Iterable[Any], witness: Iterable[Any], *, principal: Opt
|
|
|
100
118
|
if target_url is not None and isinstance(w.get("target_url"), str) and w["target_url"] != target_url:
|
|
101
119
|
elsewhere += 1
|
|
102
120
|
continue
|
|
103
|
-
|
|
121
|
+
at_w = _parse(w.get("at"))
|
|
122
|
+
# Every issuance precedes its own evidence entry by a little; only one
|
|
123
|
+
# older than the log's first entry by more than the window predates it.
|
|
124
|
+
if log_start is not None and at_w is not None and (log_start - at_w).total_seconds() > window_seconds:
|
|
125
|
+
kind = "PREDATES_EVIDENCE"
|
|
126
|
+
elif log_end is not None and at_w is not None and at_w > log_end:
|
|
127
|
+
kind = "IN_FLIGHT"
|
|
128
|
+
else:
|
|
129
|
+
kind = "MISSING_EVIDENCE"
|
|
130
|
+
findings.append({"finding": kind, "decision_id": did, "tool": w.get("tool"), "principal": w.get("principal"),
|
|
104
131
|
"held": bool(w.get("held")), "at": w.get("at")})
|
|
105
|
-
hard = [f for f in findings if f["finding"]
|
|
132
|
+
hard = [f for f in findings if f["finding"] not in ("PREDATES_WITNESS", "PREDATES_EVIDENCE", "IN_FLIGHT")]
|
|
106
133
|
return {"accepted": not hard, "findings": findings, "horizon": horizon.isoformat() if horizon else None,
|
|
134
|
+
"log_start": log_start.isoformat() if log_start else None,
|
|
135
|
+
"log_end": log_end.isoformat() if log_end else None,
|
|
107
136
|
"issued": len(issued), "evidence_with_receipt": len(seen_decisions), "issued_elsewhere": elsewhere}
|
|
108
137
|
|
|
109
138
|
|
{gargoyle_receipts-0.20.0.dev0 → gargoyle_receipts-0.22.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.22.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.22.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.** 565 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).
|
|
@@ -674,6 +682,8 @@ keys (both at once when a pinned key is also set).
|
|
|
674
682
|
| `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys, with a `revoked` list, re-read when its content changes) and which Entra app role (default `Approver`) or groups may enroll, see every hold and the witness |
|
|
675
683
|
| `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 `persistent` |
|
|
676
684
|
| `GARGOYLE_MAX_OPEN_HOLDS` | how many held receipts and plans one principal may have waiting at once (default 20); the call over the cap is `REF_ISSUER_TOO_MANY_HOLDS` and nothing is stored |
|
|
685
|
+
| `GARGOYLE_DECLINE_LIMIT`, `GARGOYLE_DECLINE_WINDOW_HOURS` | the decline cooldown: after this many declines of the same (principal, tool, path) within the window (defaults 2 and 24), the issuer refuses the next ask with `REF_ISSUER_DECLINED_RECENTLY` instead of holding it again, so an approver is not asked the same question a third time in a day; `0` disables |
|
|
686
|
+
| `GARGOYLE_RECEIPT_TTL_SECONDS` | how long an issued receipt stays presentable (default 300); a held receipt's hold lives up to six times that, capped at an hour |
|
|
677
687
|
| `GARGOYLE_ALLOW_SELF_APPROVAL` | `1` lets a person release their own call; by default the approver may be neither the hold's principal nor the party acted for (`REF_ISSUER_SELF_APPROVAL`). A grant for an expired hold is `REF_ISSUER_HOLD_EXPIRED` and the hold is dropped |
|
|
678
688
|
| `GARGOYLE_WITNESS_PATH` | the issuer's signed, hash-chained record of every decision (`gargoyle issuer` sets `witness.jsonl` beside the keys) |
|
|
679
689
|
| `GARGOYLE_HOLD_WEBHOOK_URL`, `GARGOYLE_APPROVE_URL` | 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 |
|
|
@@ -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.22.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"
|