gargoyle-receipts 0.13.0.dev0__tar.gz → 0.14.1.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.13.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.14.1.dev0}/PKG-INFO +14 -2
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/README.md +13 -1
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/cli.py +32 -1
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/evidence.py +17 -8
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/issuer_service.py +22 -2
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/mcp_server.py +9 -3
- gargoyle_receipts-0.14.1.dev0/gargoyle/reconcile.py +130 -0
- gargoyle_receipts-0.14.1.dev0/gargoyle/witness.py +128 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0/gargoyle_receipts.egg-info}/PKG-INFO +14 -2
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +4 -1
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/pyproject.toml +1 -1
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_evidence.py +8 -4
- gargoyle_receipts-0.14.1.dev0/tests/test_witness.py +165 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/adapters.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/approval.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/approvers.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/consumer.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/envelope.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/execution.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/holdstore.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/identity.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/plans.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/policy.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/published_record_source.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/remote.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/replay_cache.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/revert.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/rules.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/specs/graph-reference.json +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/state.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/static/approve.html +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/static/approve.js +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/verifier.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_approvers.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_cli.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_console_js.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_entra_identity.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_graph.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_historical.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_issuer_service.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_mcp_self_issue.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_plans.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_remote.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_revert.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_rules.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_state.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_threshold.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_wire_spec.py +0 -0
{gargoyle_receipts-0.13.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.14.1.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.14.1.dev0
|
|
4
4
|
Summary: Signed, single-use, argument-bound receipts and human-approval holds for agent tool calls.
|
|
5
5
|
Author: Justin LaPorte
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -59,7 +59,7 @@ your MCP server and lets through only what both agreed to.
|
|
|
59
59
|
|
|
60
60
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
61
61
|
|
|
62
|
-
Status: pre-release (`0.
|
|
62
|
+
Status: pre-release (`0.14.1.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
|
|
63
63
|
still change before the first release; it is specified in [SPEC.md](SPEC.md).
|
|
64
64
|
|
|
65
65
|
## What a receipt gives you
|
|
@@ -240,6 +240,17 @@ its initialize handshake, so a fleet with Claude on some desks and ChatGPT
|
|
|
240
240
|
on others still has one trail that says who, through what, did what. The
|
|
241
241
|
receipt itself stays model-agnostic.
|
|
242
242
|
|
|
243
|
+
The evidence is the workstation's own statement, and the workstation
|
|
244
|
+
belongs to its user. So the issuer keeps a witness: a signed, hash-chained
|
|
245
|
+
record of every receipt it issued and every grant it accepted, on the
|
|
246
|
+
operator's server. `gargoyle audit --witness` reconciles the two by
|
|
247
|
+
decision id and names what does not agree: a receipt the issuer never
|
|
248
|
+
issued, a held call honored without a grant the issuer saw, a receipt
|
|
249
|
+
issued with no evidence recorded. `--check-tree` hashes the gated folder
|
|
250
|
+
against the evidence's recorded states and names any file changed around
|
|
251
|
+
the gate. On a machine the user administers the gate constrains the
|
|
252
|
+
assistant, not the user; these two checks make the difference visible.
|
|
253
|
+
|
|
243
254
|
The MCP middleware also keeps an evidence log: one hash-chained line per
|
|
244
255
|
decision, refusals and declined holds included, so the trail answers "what
|
|
245
256
|
did the agent try" as well as "what did it do". `gargoyle audit --log`
|
|
@@ -369,6 +380,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
369
380
|
| issuer `GARGOYLE_ENTRA_CLIENT_ID`, `GARGOYLE_ENTRA_SCOPE` | lets the console sign approvers in with Entra (code flow with PKCE, no library); the browser generates a non-exportable key and enrolls it under the UPN |
|
|
370
381
|
| issuer `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys) and which Entra app role (default `Approver`) or groups may enroll |
|
|
371
382
|
| issuer `GARGOYLE_HOLDS_PATH` | SQLite file for holds, grants, declines and plans, so a restart loses nothing a person is acting on (`gargoyle issuer` sets `holds.sqlite` beside the keys); `GET /health` reports it |
|
|
383
|
+
| issuer `GARGOYLE_WITNESS_PATH` | the issuer's signed, hash-chained record of every decision (`gargoyle issuer` sets `witness.jsonl` beside the keys); `gargoyle audit --witness` reconciles a client's evidence against it, `--check-tree DIR` finds files changed outside the gate |
|
|
372
384
|
| issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
|
|
373
385
|
| `GARGOYLE_HOLD_WEBHOOK_URL`, `GARGOYLE_APPROVE_URL` | issuer: POST a notice of every new hold (Slack incoming-webhook shape, `text` plus the hold) with a link to the console; notice only, the grant still needs the approver's key |
|
|
374
386
|
| `GARGOYLE_MCP_STATE_PROBE` | `filesystem` (default) binds a held file write to the file's current state: the approver sees the change as a diff, the call refuses if the file moved before the run (`REF_VERIFY_STATE_MISMATCH`), and the execution receipt records pre and post state; `none` disables |
|
|
@@ -21,7 +21,7 @@ your MCP server and lets through only what both agreed to.
|
|
|
21
21
|
|
|
22
22
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
23
23
|
|
|
24
|
-
Status: pre-release (`0.
|
|
24
|
+
Status: pre-release (`0.14.1.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
|
|
25
25
|
still change before the first release; it is specified in [SPEC.md](SPEC.md).
|
|
26
26
|
|
|
27
27
|
## What a receipt gives you
|
|
@@ -202,6 +202,17 @@ its initialize handshake, so a fleet with Claude on some desks and ChatGPT
|
|
|
202
202
|
on others still has one trail that says who, through what, did what. The
|
|
203
203
|
receipt itself stays model-agnostic.
|
|
204
204
|
|
|
205
|
+
The evidence is the workstation's own statement, and the workstation
|
|
206
|
+
belongs to its user. So the issuer keeps a witness: a signed, hash-chained
|
|
207
|
+
record of every receipt it issued and every grant it accepted, on the
|
|
208
|
+
operator's server. `gargoyle audit --witness` reconciles the two by
|
|
209
|
+
decision id and names what does not agree: a receipt the issuer never
|
|
210
|
+
issued, a held call honored without a grant the issuer saw, a receipt
|
|
211
|
+
issued with no evidence recorded. `--check-tree` hashes the gated folder
|
|
212
|
+
against the evidence's recorded states and names any file changed around
|
|
213
|
+
the gate. On a machine the user administers the gate constrains the
|
|
214
|
+
assistant, not the user; these two checks make the difference visible.
|
|
215
|
+
|
|
205
216
|
The MCP middleware also keeps an evidence log: one hash-chained line per
|
|
206
217
|
decision, refusals and declined holds included, so the trail answers "what
|
|
207
218
|
did the agent try" as well as "what did it do". `gargoyle audit --log`
|
|
@@ -331,6 +342,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
331
342
|
| issuer `GARGOYLE_ENTRA_CLIENT_ID`, `GARGOYLE_ENTRA_SCOPE` | lets the console sign approvers in with Entra (code flow with PKCE, no library); the browser generates a non-exportable key and enrolls it under the UPN |
|
|
332
343
|
| issuer `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys) and which Entra app role (default `Approver`) or groups may enroll |
|
|
333
344
|
| issuer `GARGOYLE_HOLDS_PATH` | SQLite file for holds, grants, declines and plans, so a restart loses nothing a person is acting on (`gargoyle issuer` sets `holds.sqlite` beside the keys); `GET /health` reports it |
|
|
345
|
+
| issuer `GARGOYLE_WITNESS_PATH` | the issuer's signed, hash-chained record of every decision (`gargoyle issuer` sets `witness.jsonl` beside the keys); `gargoyle audit --witness` reconciles a client's evidence against it, `--check-tree DIR` finds files changed outside the gate |
|
|
334
346
|
| issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
|
|
335
347
|
| `GARGOYLE_HOLD_WEBHOOK_URL`, `GARGOYLE_APPROVE_URL` | issuer: POST a notice of every new hold (Slack incoming-webhook shape, `text` plus the hold) with a link to the console; notice only, the grant still needs the approver's key |
|
|
336
348
|
| `GARGOYLE_MCP_STATE_PROBE` | `filesystem` (default) binds a held file write to the file's current state: the approver sees the change as a diff, the call refuses if the file moved before the run (`REF_VERIFY_STATE_MISMATCH`), and the execution receipt records pre and post state; `none` disables |
|
|
@@ -290,6 +290,7 @@ def cmd_issuer(args) -> int:
|
|
|
290
290
|
os.environ.update(_read_env(out / "issuer.env"))
|
|
291
291
|
os.environ.setdefault("GARGOYLE_APPROVERS_PATH", str(out / "approvers.json")) # enrolled approvers live beside the keys
|
|
292
292
|
os.environ.setdefault("GARGOYLE_HOLDS_PATH", str(out / "holds.sqlite")) # holds, grants, declines, plans survive restarts
|
|
293
|
+
os.environ.setdefault("GARGOYLE_WITNESS_PATH", str(out / "witness.jsonl")) # the issuer's signed record, for reconciliation
|
|
293
294
|
os.environ.setdefault("GARGOYLE_LOG_JSON", "1")
|
|
294
295
|
app = app_from_env()
|
|
295
296
|
# The middleware reads the policy pin from a file; write it next to the
|
|
@@ -358,7 +359,35 @@ def cmd_audit(args) -> int:
|
|
|
358
359
|
failed = sum(1 for r in v["entries"] if not r["accepted"])
|
|
359
360
|
print("%d entries (%d honored, %d refused), %d failed; chain %s; tail %s"
|
|
360
361
|
% (v["count"], v["honored"], v["refused"], failed, v["reason"], v["tail_sha256"][:16]))
|
|
361
|
-
|
|
362
|
+
rc = 0 if v["accepted"] else 1
|
|
363
|
+
entries = read_log(args.log)
|
|
364
|
+
if args.witness and not Path(args.witness).exists():
|
|
365
|
+
print("witness file not found: %s (copy the issuer's witness.jsonl here first)" % args.witness, file=sys.stderr)
|
|
366
|
+
return 2
|
|
367
|
+
if args.check_tree and not Path(args.check_tree).is_dir():
|
|
368
|
+
print("not a directory: %s" % args.check_tree, file=sys.stderr)
|
|
369
|
+
return 2
|
|
370
|
+
if args.witness:
|
|
371
|
+
# The issuer's signed record: the client's evidence must agree with it.
|
|
372
|
+
from gargoyle.reconcile import reconcile
|
|
373
|
+
from gargoyle.witness import read_witness, verify_witness
|
|
374
|
+
w = verify_witness(read_witness(args.witness), issuer_public_keys=trust["issuer_public_keys"])
|
|
375
|
+
print("witness: %d entries, %s, tail %s" % (w["count"], w["reason"], w["tail_sha256"][:16]))
|
|
376
|
+
principal = next((e.get("principal") for e in entries if isinstance(e, dict) and e.get("principal")), None)
|
|
377
|
+
r = reconcile(entries, read_witness(args.witness), principal=principal)
|
|
378
|
+
for f in r["findings"]:
|
|
379
|
+
print(" FINDING %-22s %s" % (f["finding"], " ".join("%s=%s" % (k, str(v)[:40]) for k, v in f.items() if k != "finding")))
|
|
380
|
+
print("reconciled: %d issued by the issuer, %d in evidence, %d finding(s)" % (r["issued"], r["evidence_with_receipt"], len(r["findings"])))
|
|
381
|
+
rc = rc or (0 if (w["accepted"] and r["accepted"]) else 1)
|
|
382
|
+
if args.check_tree:
|
|
383
|
+
# Every file the gate wrote must still be as the gate left it.
|
|
384
|
+
from gargoyle.reconcile import check_tree
|
|
385
|
+
t = check_tree(entries, args.check_tree)
|
|
386
|
+
for f in t["findings"]:
|
|
387
|
+
print(" FINDING %-22s %s%s" % (f["finding"], f["path"], " (missing)" if f.get("missing") else ""))
|
|
388
|
+
print("tree: %d gated file(s) checked, %d untouched by the gate, %d changed outside it" % (t["checked"], t["untouched"], len(t["findings"])))
|
|
389
|
+
rc = rc or (0 if t["accepted"] else 1)
|
|
390
|
+
return rc
|
|
362
391
|
receipt, execution = json.load(open(args.receipt)), json.load(open(args.execution))
|
|
363
392
|
grant = json.load(open(args.grant)) if args.grant else None
|
|
364
393
|
result = verify_chain(receipt, execution, grant=grant, **trust)
|
|
@@ -424,6 +453,8 @@ def main(argv=None) -> int:
|
|
|
424
453
|
p = sub.add_parser("audit", help="verify a receipt chain offline, or a whole evidence log")
|
|
425
454
|
p.add_argument("receipt", nargs="?"); p.add_argument("execution", nargs="?"); p.add_argument("grant", nargs="?")
|
|
426
455
|
p.add_argument("--log", help="the middleware's audit.jsonl: verify every entry")
|
|
456
|
+
p.add_argument("--witness", help="the issuer's witness.jsonl: reconcile the evidence against it")
|
|
457
|
+
p.add_argument("--check-tree", metavar="DIR", help="verify every gated file under DIR is as the gate left it")
|
|
427
458
|
p.add_argument("--out", default="gargoyle-deploy")
|
|
428
459
|
p.set_defaults(fn=cmd_audit)
|
|
429
460
|
args = parser.parse_args(argv)
|
|
@@ -48,9 +48,9 @@ from gargoyle.envelope import args_sha256, canonical_json, canonical_json_legacy
|
|
|
48
48
|
from gargoyle.execution import verify_chain
|
|
49
49
|
from gargoyle.verifier import verify_provenance
|
|
50
50
|
|
|
51
|
-
EVIDENCE_VERSION = "gargoyle-evidence/
|
|
51
|
+
EVIDENCE_VERSION = "gargoyle-evidence/4" # 3 added `client`, 4 adds `ref`; hashes over RFC 8785 canonical JSON
|
|
52
52
|
EVIDENCE_VERSIONS = {"gargoyle-evidence/1": canonical_json_legacy, "gargoyle-evidence/2": canonical_json,
|
|
53
|
-
EVIDENCE_VERSION: canonical_json}
|
|
53
|
+
"gargoyle-evidence/3": canonical_json, EVIDENCE_VERSION: canonical_json}
|
|
54
54
|
GENESIS = "0" * 64
|
|
55
55
|
|
|
56
56
|
HONORED = "honored"
|
|
@@ -71,6 +71,11 @@ _FIELDS = ("evidence_version", "seq", "prev_sha256", "at", "kind", "tool", "args
|
|
|
71
71
|
# receipt stays model-agnostic: this is evidence about the session, not a
|
|
72
72
|
# bound field of the decision.
|
|
73
73
|
_FIELDS_V3 = _FIELDS + ("client",)
|
|
74
|
+
# Version 4 adds `ref`: what the adapter examined for a state-bound call (a
|
|
75
|
+
# file path, "mailRule:<id>"), or null. With it a folder can be checked
|
|
76
|
+
# against the evidence without any plaintext (gargoyle.reconcile.check_tree).
|
|
77
|
+
_FIELDS_V4 = _FIELDS_V3 + ("ref",)
|
|
78
|
+
_FIELDS_BY_VERSION = {"gargoyle-evidence/3": _FIELDS_V3, "gargoyle-evidence/4": _FIELDS_V4}
|
|
74
79
|
|
|
75
80
|
|
|
76
81
|
def entry_sha256(entry: Dict[str, Any]) -> str:
|
|
@@ -84,11 +89,13 @@ def entry_sha256(entry: Dict[str, Any]) -> str:
|
|
|
84
89
|
|
|
85
90
|
def build_entry(*, seq: int, prev_sha256: str, kind: str, tool: str, args: Any, principal: str,
|
|
86
91
|
reason: str, receipt: Any = None, grant: Any = None, execution: Any = None,
|
|
87
|
-
at: Optional[datetime] = None, client: Optional[str] = None) -> Dict[str, Any]:
|
|
92
|
+
at: Optional[datetime] = None, client: Optional[str] = None, ref: Optional[str] = None) -> Dict[str, Any]:
|
|
88
93
|
if kind not in (HONORED, REFUSED):
|
|
89
94
|
raise ValueError("kind must be honored or refused")
|
|
90
95
|
if client is not None and not isinstance(client, str):
|
|
91
96
|
raise ValueError("client must be a string or None")
|
|
97
|
+
if ref is not None and not isinstance(ref, str):
|
|
98
|
+
raise ValueError("ref must be a string or None")
|
|
92
99
|
entry = {
|
|
93
100
|
"evidence_version": EVIDENCE_VERSION,
|
|
94
101
|
"seq": seq,
|
|
@@ -103,6 +110,7 @@ def build_entry(*, seq: int, prev_sha256: str, kind: str, tool: str, args: Any,
|
|
|
103
110
|
"grant": grant,
|
|
104
111
|
"execution": execution,
|
|
105
112
|
"client": client,
|
|
113
|
+
"ref": ref,
|
|
106
114
|
}
|
|
107
115
|
entry["entry_sha256"] = entry_sha256(entry)
|
|
108
116
|
return entry
|
|
@@ -131,14 +139,14 @@ class EvidenceLog:
|
|
|
131
139
|
|
|
132
140
|
def append(self, kind: str, tool: str, args: Any, principal: str, reason: str, *,
|
|
133
141
|
receipt: Any = None, grant: Any = None, execution: Any = None,
|
|
134
|
-
client: Optional[str] = None) -> Dict[str, Any]:
|
|
142
|
+
client: Optional[str] = None, ref: Optional[str] = None) -> Dict[str, Any]:
|
|
135
143
|
"""Write one entry and return it. Raises OSError if the file cannot
|
|
136
144
|
be written; the caller decides whether that stops the call."""
|
|
137
145
|
if self._seq is None:
|
|
138
146
|
self._load_tail()
|
|
139
147
|
entry = build_entry(seq=self._seq + 1, prev_sha256=self._tail, kind=kind, tool=tool, args=args,
|
|
140
148
|
principal=principal, reason=reason, receipt=receipt, grant=grant, execution=execution,
|
|
141
|
-
client=client)
|
|
149
|
+
client=client, ref=ref)
|
|
142
150
|
with open(self.path, "a", encoding="utf-8") as f:
|
|
143
151
|
f.write(json.dumps(entry, sort_keys=True) + "\n")
|
|
144
152
|
self._seq, self._tail = entry["seq"], entry["entry_sha256"]
|
|
@@ -146,10 +154,11 @@ class EvidenceLog:
|
|
|
146
154
|
|
|
147
155
|
|
|
148
156
|
def _well_formed(entry: Any) -> bool:
|
|
149
|
-
expected =
|
|
157
|
+
expected = _FIELDS_BY_VERSION.get(entry.get("evidence_version"), _FIELDS) if isinstance(entry, dict) else _FIELDS
|
|
150
158
|
return (isinstance(entry, dict) and set(entry) == set(expected)
|
|
151
159
|
and entry["evidence_version"] in EVIDENCE_VERSIONS
|
|
152
160
|
and (entry.get("client") is None or isinstance(entry.get("client"), str))
|
|
161
|
+
and (entry.get("ref") is None or isinstance(entry.get("ref"), str))
|
|
153
162
|
and isinstance(entry["seq"], int) and entry["seq"] >= 1
|
|
154
163
|
and isinstance(entry["prev_sha256"], str) and len(entry["prev_sha256"]) == 64
|
|
155
164
|
and isinstance(entry["at"], str) and entry["kind"] in (HONORED, REFUSED)
|
|
@@ -192,7 +201,7 @@ def verify_log(
|
|
|
192
201
|
rc = rc if isinstance(rc, dict) else {}
|
|
193
202
|
report.append({"seq": None, "kind": None, "tool": rc.get("tool"), "principal": rc.get("principal"),
|
|
194
203
|
"accepted": False, "reason": REF_EVIDENCE_UNCHAINED if legacy else REF_EVIDENCE_MALFORMED,
|
|
195
|
-
"verdict": None, "released_by": [], "state": None, "client": None, "plan": None})
|
|
204
|
+
"verdict": None, "released_by": [], "state": None, "client": None, "plan": None, "ref": None})
|
|
196
205
|
all_ok = False
|
|
197
206
|
continue
|
|
198
207
|
e = raw
|
|
@@ -230,7 +239,7 @@ def verify_log(
|
|
|
230
239
|
if ex.get("pre_state_sha256") or ex.get("post_state_sha256") else None)
|
|
231
240
|
report.append({"seq": e["seq"], "kind": e["kind"], "tool": e["tool"], "principal": e["principal"],
|
|
232
241
|
"accepted": ok, "reason": reason, "verdict": e["reason"], "released_by": released_by,
|
|
233
|
-
"state": state, "client": e.get("client"), "plan": plan_ref})
|
|
242
|
+
"state": state, "client": e.get("client"), "plan": plan_ref, "ref": e.get("ref")})
|
|
234
243
|
all_ok = all_ok and ok
|
|
235
244
|
# The chain continues from what the file says, so one bad entry is
|
|
236
245
|
# reported once rather than cascading into every later one.
|
|
@@ -55,6 +55,7 @@ from gargoyle.approval import (REF_APPROVAL_APPROVER_NOT_ELIGIBLE, REF_APPROVAL_
|
|
|
55
55
|
from gargoyle.envelope import approval_terms
|
|
56
56
|
from gargoyle.state import state_sha256, valid_description
|
|
57
57
|
from gargoyle.holdstore import ENV_HOLDS_PATH, HoldStore
|
|
58
|
+
from gargoyle.witness import WitnessLog
|
|
58
59
|
from gargoyle.plans import (REF_PLAN_MALFORMED, REF_PLAN_STEP_NOT_PLANNABLE, REF_PLAN_STEP_REFUSED, build_plan,
|
|
59
60
|
sign_plan, valid_step)
|
|
60
61
|
from gargoyle.notify import notifier_from_env
|
|
@@ -104,6 +105,7 @@ def build_issuer_app(
|
|
|
104
105
|
console_entra: Optional[Dict[str, str]] = None,
|
|
105
106
|
hold_store: Optional[HoldStore] = None,
|
|
106
107
|
log_events: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
108
|
+
witness: Optional[WitnessLog] = None,
|
|
107
109
|
) -> FastAPI:
|
|
108
110
|
"""`on_hold(envelope, args)` is called once for every new hold (gargoyle.notify
|
|
109
111
|
posts it to a webhook); it is notice, not admission, and its failure
|
|
@@ -123,12 +125,21 @@ def build_issuer_app(
|
|
|
123
125
|
|
|
124
126
|
def event(kind: str, **fields: Any) -> None:
|
|
125
127
|
"""One structured line per decision the service makes, for the
|
|
126
|
-
operator's log
|
|
128
|
+
operator's log, and one signed entry in the witness log (gargoyle.witness),
|
|
129
|
+
the independent record the client's evidence is reconciled against.
|
|
130
|
+
Neither is the admission path."""
|
|
127
131
|
if log_events is not None:
|
|
128
132
|
try:
|
|
129
133
|
log_events({"event": kind, "at": now().isoformat(), **fields})
|
|
130
134
|
except Exception:
|
|
131
135
|
pass
|
|
136
|
+
if witness is not None:
|
|
137
|
+
try:
|
|
138
|
+
witness.record(kind, **fields)
|
|
139
|
+
except Exception as exc:
|
|
140
|
+
import sys as _sys
|
|
141
|
+
_sys.stderr.write("gargoyle: witness log unwritable: %s\n" % exc)
|
|
142
|
+
app.state.witness = witness
|
|
132
143
|
registry = approver_registry if approver_registry is not None else ApproverRegistry(None)
|
|
133
144
|
static_approvers = dict(approver_public_keys or {})
|
|
134
145
|
|
|
@@ -200,7 +211,8 @@ def build_issuer_app(
|
|
|
200
211
|
covered = True
|
|
201
212
|
break
|
|
202
213
|
event("issued", principal=who["principal"], tool=body["tool"], held=bool(result.envelope["requires_approval"]),
|
|
203
|
-
covered=covered, decision_id=result.envelope.get("decision_id")
|
|
214
|
+
covered=covered, decision_id=result.envelope.get("decision_id"), decision_sha256=result.envelope["decision_sha256"],
|
|
215
|
+
approval_request_id=result.envelope.get("approval_request_id"), args_sha256=result.envelope["request_context"]["args_sha256"])
|
|
204
216
|
if result.envelope["requires_approval"] and not covered:
|
|
205
217
|
arid = result.envelope["approval_request_id"]
|
|
206
218
|
app.state.pending[arid] = result.envelope
|
|
@@ -408,6 +420,12 @@ def build_issuer_app(
|
|
|
408
420
|
return {"principal": upn, "may_approve": may_enroll(who, role=approver_role, groups=approver_groups) or upn in static_approvers,
|
|
409
421
|
"enrolled_public_key_hex": registry.public_hex().get(upn)}
|
|
410
422
|
|
|
423
|
+
@app.get("/witness")
|
|
424
|
+
async def witness_entries(authorization: Optional[str] = Header(default=None)):
|
|
425
|
+
"""The issuer's signed witness log, for reconciliation with a client's evidence."""
|
|
426
|
+
authenticate(authorization)
|
|
427
|
+
return {"entries": list(witness.entries) if witness is not None else []}
|
|
428
|
+
|
|
411
429
|
@app.get("/health")
|
|
412
430
|
async def health():
|
|
413
431
|
"""Public, for a monitor: the service is up, which policy it runs,
|
|
@@ -492,6 +510,8 @@ def app_from_env() -> FastAPI:
|
|
|
492
510
|
ttl_seconds=int(os.environ.get(ENV_RECEIPT_TTL, DEFAULT_TTL_SECONDS)),
|
|
493
511
|
on_hold=notifier_from_env(os.environ),
|
|
494
512
|
hold_store=HoldStore(os.environ.get(ENV_HOLDS_PATH) or None),
|
|
513
|
+
witness=(WitnessLog(os.environ["GARGOYLE_WITNESS_PATH"], Ed25519PrivateKey.from_private_bytes(bytes.fromhex(required[ENV_ISSUER_KEY_HEX])),
|
|
514
|
+
required[ENV_ISSUER_KEY_ID]) if os.environ.get("GARGOYLE_WITNESS_PATH") else None),
|
|
495
515
|
log_events=(lambda e: sys.stderr.write(json.dumps(e, sort_keys=True) + "\n")) if os.environ.get("GARGOYLE_LOG_JSON") else None,
|
|
496
516
|
approver_registry=ApproverRegistry(os.environ.get(ENV_APPROVERS_PATH)),
|
|
497
517
|
approver_role=os.environ.get(ENV_APPROVER_ROLE, DEFAULT_APPROVER_ROLE),
|
|
@@ -364,10 +364,12 @@ def _gate(state: ServerState, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
364
364
|
"""{"executed", "reason", "envelope", "grant", "self_issued"}: the
|
|
365
365
|
receipt and grant that were honored, whether the client brought them or
|
|
366
366
|
this server obtained them."""
|
|
367
|
+
gate_ref: Dict[str, Any] = {"ref": None}
|
|
368
|
+
|
|
367
369
|
def refused(reason, envelope=None):
|
|
368
370
|
# A refusal keeps the receipt when there was one (a hold the human
|
|
369
371
|
# declined or let lapse), so the evidence log can show what was asked.
|
|
370
|
-
return {"executed": False, "reason": reason, "envelope": envelope, "grant": None, "self_issued": False}
|
|
372
|
+
return {"executed": False, "reason": reason, "envelope": envelope, "grant": None, "self_issued": False, "ref": gate_ref["ref"]}
|
|
371
373
|
if state.consumer is None:
|
|
372
374
|
return refused(REF_TARGET_NOT_CONFIGURED)
|
|
373
375
|
|
|
@@ -385,6 +387,8 @@ def _gate(state: ServerState, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
385
387
|
# target's current state goes with the request (gargoyle.state), so
|
|
386
388
|
# a hold is approved against it and bound to it.
|
|
387
389
|
probed = state.adapter.probe(name, args) if state.adapter is not None else None
|
|
390
|
+
if probed is not None:
|
|
391
|
+
gate_ref["ref"] = probed[0].get("ref")
|
|
388
392
|
# An approved plan may already cover this call; say so, and the
|
|
389
393
|
# issuer opens no hold for it.
|
|
390
394
|
release = _plan_release_for(state, name, args)
|
|
@@ -435,9 +439,11 @@ def _gate(state: ServerState, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
435
439
|
except Exception as exc:
|
|
436
440
|
sys.stderr.write("gargoyle: snapshot failed: %s\n" % exc)
|
|
437
441
|
|
|
442
|
+
if now_probe is not None:
|
|
443
|
+
gate_ref["ref"] = now_probe[0].get("ref")
|
|
438
444
|
state.executed.append({"tool": name, "args": args}) # THE side effect
|
|
439
445
|
return {"executed": True, "reason": verdict.reason, "envelope": envelope, "grant": grant,
|
|
440
|
-
"self_issued": self_issued}
|
|
446
|
+
"self_issued": self_issued, "ref": gate_ref["ref"]}
|
|
441
447
|
|
|
442
448
|
|
|
443
449
|
def _plan_release_for(state: ServerState, tool: str, args: Any) -> Optional[Dict[str, Any]]:
|
|
@@ -524,7 +530,7 @@ def _record(state: ServerState, kind: str, gate: Dict[str, Any], params: Dict[st
|
|
|
524
530
|
try:
|
|
525
531
|
state.evidence.append(kind, str(params.get("name")), params.get("arguments", {}), principal,
|
|
526
532
|
gate["reason"], receipt=gate["envelope"], grant=gate["grant"], execution=execution,
|
|
527
|
-
client=state.client)
|
|
533
|
+
client=state.client, ref=gate.get("ref"))
|
|
528
534
|
except (OSError, ValueError) as exc:
|
|
529
535
|
sys.stderr.write("gargoyle: evidence log unwritable: %s\n" % exc)
|
|
530
536
|
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Reconciliation: the client's evidence against the issuer's witness, and
|
|
2
|
+
the gated folder against the evidence.
|
|
3
|
+
|
|
4
|
+
Findings, never silently fixed:
|
|
5
|
+
UNWITNESSED_RECEIPT evidence honors a decision the issuer never issued (forged or foreign)
|
|
6
|
+
UNWITNESSED_RELEASE a held call was honored but the issuer saw no grant for its hold (or plan)
|
|
7
|
+
MISSING_EVIDENCE the issuer issued a receipt to this principal and the client recorded nothing
|
|
8
|
+
EVIDENCE_BEFORE_ISSUE the client's entry predates the issuer's issuance
|
|
9
|
+
CHANGED_OUTSIDE_GATE a file under the gated folder is not in the state the last gated write left it
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
16
|
+
|
|
17
|
+
from gargoyle.state import describe, state_sha256
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _parse(ts: Any) -> Optional[datetime]:
|
|
21
|
+
try:
|
|
22
|
+
return datetime.fromisoformat(ts)
|
|
23
|
+
except (TypeError, ValueError):
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def reconcile(evidence: Iterable[Any], witness: Iterable[Any], *, principal: Optional[str] = None,
|
|
28
|
+
window_seconds: float = 5.0) -> Dict[str, Any]:
|
|
29
|
+
"""Compare verified evidence entries with verified witness entries.
|
|
30
|
+
`principal` limits MISSING_EVIDENCE to receipts issued to that
|
|
31
|
+
principal (the evidence log is one executor's); None means every
|
|
32
|
+
principal in the witness."""
|
|
33
|
+
issued: Dict[str, Dict[str, Any]] = {}
|
|
34
|
+
granted: Dict[str, List[Dict[str, Any]]] = {}
|
|
35
|
+
plan_granted: Dict[str, List[Dict[str, Any]]] = {}
|
|
36
|
+
for w in witness:
|
|
37
|
+
if not isinstance(w, dict):
|
|
38
|
+
continue
|
|
39
|
+
if w.get("event") == "issued" and isinstance(w.get("decision_id"), str):
|
|
40
|
+
issued[w["decision_id"]] = w
|
|
41
|
+
elif w.get("event") == "granted" and isinstance(w.get("approval_request_id"), str):
|
|
42
|
+
(plan_granted if w.get("hold_kind") == "plan" else granted).setdefault(w["approval_request_id"], []).append(w)
|
|
43
|
+
|
|
44
|
+
findings: List[Dict[str, Any]] = []
|
|
45
|
+
seen_decisions = set()
|
|
46
|
+
for e in evidence:
|
|
47
|
+
if not isinstance(e, dict):
|
|
48
|
+
continue
|
|
49
|
+
receipt = e.get("receipt") if isinstance(e.get("receipt"), dict) else None
|
|
50
|
+
if receipt is None:
|
|
51
|
+
continue
|
|
52
|
+
did = receipt.get("decision_id")
|
|
53
|
+
seen_decisions.add(did)
|
|
54
|
+
w = issued.get(did)
|
|
55
|
+
if e.get("kind") == "honored":
|
|
56
|
+
if w is None:
|
|
57
|
+
findings.append({"finding": "UNWITNESSED_RECEIPT", "seq": e.get("seq"), "decision_id": did, "tool": e.get("tool")})
|
|
58
|
+
continue
|
|
59
|
+
if w.get("decision_sha256") != receipt.get("decision_sha256") or w.get("principal") != receipt.get("request_context", {}).get("principal"):
|
|
60
|
+
findings.append({"finding": "UNWITNESSED_RECEIPT", "seq": e.get("seq"), "decision_id": did, "tool": e.get("tool")})
|
|
61
|
+
continue
|
|
62
|
+
if receipt.get("requires_approval"):
|
|
63
|
+
g = e.get("grant")
|
|
64
|
+
arid = receipt.get("approval_request_id")
|
|
65
|
+
if isinstance(g, dict) and "plan" in g:
|
|
66
|
+
pid = (g.get("plan") or {}).get("approval_request_id")
|
|
67
|
+
if pid not in plan_granted:
|
|
68
|
+
findings.append({"finding": "UNWITNESSED_RELEASE", "seq": e.get("seq"), "decision_id": did, "tool": e.get("tool"), "plan": True})
|
|
69
|
+
elif arid not in granted:
|
|
70
|
+
findings.append({"finding": "UNWITNESSED_RELEASE", "seq": e.get("seq"), "decision_id": did, "tool": e.get("tool")})
|
|
71
|
+
at_e, at_w = _parse(e.get("at")), _parse(w.get("at"))
|
|
72
|
+
if at_e and at_w and (at_w - at_e).total_seconds() > window_seconds:
|
|
73
|
+
findings.append({"finding": "EVIDENCE_BEFORE_ISSUE", "seq": e.get("seq"), "decision_id": did})
|
|
74
|
+
for did, w in issued.items():
|
|
75
|
+
if did in seen_decisions:
|
|
76
|
+
continue
|
|
77
|
+
if principal is not None and w.get("principal") != principal:
|
|
78
|
+
continue
|
|
79
|
+
findings.append({"finding": "MISSING_EVIDENCE", "decision_id": did, "tool": w.get("tool"), "principal": w.get("principal"),
|
|
80
|
+
"held": bool(w.get("held")), "at": w.get("at")})
|
|
81
|
+
return {"accepted": not findings, "findings": findings,
|
|
82
|
+
"issued": len(issued), "evidence_with_receipt": len(seen_decisions)}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def check_tree(evidence: Iterable[Any], root: str) -> Dict[str, Any]:
|
|
86
|
+
"""Every file under `root` that the evidence has a post-state for must
|
|
87
|
+
be in that state now; files the gate never touched are counted, not
|
|
88
|
+
judged. A file whose last recorded state says it existed but which is
|
|
89
|
+
gone is reported too."""
|
|
90
|
+
last: Dict[str, Dict[str, Any]] = {}
|
|
91
|
+
for e in evidence:
|
|
92
|
+
if not isinstance(e, dict) or e.get("kind") != "honored":
|
|
93
|
+
continue
|
|
94
|
+
ex = e.get("execution") if isinstance(e.get("execution"), dict) else None
|
|
95
|
+
rc = (e.get("receipt") or {}).get("request_context") if isinstance(e.get("receipt"), dict) else None
|
|
96
|
+
if ex is None or not ex.get("post_state_sha256") or not rc:
|
|
97
|
+
continue
|
|
98
|
+
ref = None
|
|
99
|
+
meta = e.get("grant") if isinstance(e.get("grant"), dict) else None
|
|
100
|
+
# The ref is the path the call changed: from the plan release's args, or
|
|
101
|
+
# the snapshot sidecar is not in evidence, so recover it from the
|
|
102
|
+
# arguments the executor recorded when it could, else skip.
|
|
103
|
+
args = (meta or {}).get("args") if meta and "plan" in meta else None
|
|
104
|
+
if isinstance(args, dict):
|
|
105
|
+
ref = args.get("path") or args.get("source")
|
|
106
|
+
if ref is None:
|
|
107
|
+
ref = e.get("ref")
|
|
108
|
+
if ref is None:
|
|
109
|
+
continue
|
|
110
|
+
last[ref] = {"post": ex["post_state_sha256"], "seq": e.get("seq")}
|
|
111
|
+
findings, checked, untouched = [], 0, 0
|
|
112
|
+
for dirpath, _, files in os.walk(root):
|
|
113
|
+
for name in files:
|
|
114
|
+
p = os.path.join(dirpath, name)
|
|
115
|
+
rec = last.get(p)
|
|
116
|
+
if rec is None:
|
|
117
|
+
untouched += 1
|
|
118
|
+
continue
|
|
119
|
+
checked += 1
|
|
120
|
+
try:
|
|
121
|
+
data = open(p, "rb").read()
|
|
122
|
+
except OSError:
|
|
123
|
+
data = None
|
|
124
|
+
now = state_sha256(describe(p, data, with_text=False))
|
|
125
|
+
if now != rec["post"]:
|
|
126
|
+
findings.append({"finding": "CHANGED_OUTSIDE_GATE", "path": p, "last_gated_seq": rec["seq"]})
|
|
127
|
+
for p, rec in last.items():
|
|
128
|
+
if str(Path(p)).startswith(str(Path(root))) and not os.path.exists(p):
|
|
129
|
+
findings.append({"finding": "CHANGED_OUTSIDE_GATE", "path": p, "last_gated_seq": rec["seq"], "missing": True})
|
|
130
|
+
return {"accepted": not findings, "findings": findings, "checked": checked, "untouched": untouched}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""The issuer's witness log: an independent record to check the client's evidence against.
|
|
2
|
+
|
|
3
|
+
The evidence log (gargoyle.evidence) is the executor's own statement, and
|
|
4
|
+
the executor is the workstation, where the user administers the machine
|
|
5
|
+
and holds the executor key. The issuer is a different party on a server
|
|
6
|
+
the operator controls. It records, signed with the issuer key and
|
|
7
|
+
hash-chained, every receipt it issued, every grant it accepted, every
|
|
8
|
+
decline and every plan. Reconciling the two (gargoyle.reconcile) turns the
|
|
9
|
+
client's evidence from a self-report into a report checked against a
|
|
10
|
+
witness: evidence for a decision the issuer never issued is a forgery, a
|
|
11
|
+
held call honored without a grant the issuer saw is a bypass, a receipt
|
|
12
|
+
issued with no evidence at all is a gap.
|
|
13
|
+
|
|
14
|
+
entry = {
|
|
15
|
+
"witness_version": "gargoyle-witness/1",
|
|
16
|
+
"seq": n, "prev_sha256": ..., "at": ...,
|
|
17
|
+
"event": "issued" | "refused" | "granted" | "declined" | "plan" | "plan_granted",
|
|
18
|
+
<fields per event>,
|
|
19
|
+
"entry_sha256": sha256(canonical_json(entry minus entry_sha256, issuer_key_id, issuer_signature)),
|
|
20
|
+
"issuer_key_id": ..., "issuer_signature": ed25519 over canonical_json(entry minus issuer_signature)
|
|
21
|
+
}
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import json
|
|
26
|
+
import threading
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
30
|
+
|
|
31
|
+
from gargoyle.envelope import canonical_json
|
|
32
|
+
|
|
33
|
+
WITNESS_VERSION = "gargoyle-witness/1"
|
|
34
|
+
GENESIS = "0" * 64
|
|
35
|
+
_HASH_EXCLUDED = ("entry_sha256", "issuer_key_id", "issuer_signature")
|
|
36
|
+
|
|
37
|
+
REF_WITNESS_MALFORMED = "REF_WITNESS_MALFORMED"
|
|
38
|
+
REF_WITNESS_HASH_MISMATCH = "REF_WITNESS_HASH_MISMATCH"
|
|
39
|
+
REF_WITNESS_CHAIN_BROKEN = "REF_WITNESS_CHAIN_BROKEN"
|
|
40
|
+
REF_WITNESS_KEY_UNKNOWN = "REF_WITNESS_KEY_UNKNOWN"
|
|
41
|
+
REF_WITNESS_SIGNATURE_INVALID = "REF_WITNESS_SIGNATURE_INVALID"
|
|
42
|
+
ACCEPT_WITNESS_VERIFIED = "WITNESS_VERIFIED"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def entry_sha256(entry: Dict[str, Any]) -> str:
|
|
46
|
+
body = {k: v for k, v in entry.items() if k not in _HASH_EXCLUDED}
|
|
47
|
+
return hashlib.sha256(canonical_json(body).encode("utf-8")).hexdigest()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class WitnessLog:
|
|
51
|
+
"""Append-only, one JSON line per event, each signed by the issuer."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, path: Optional[str], signing_key: Any, key_id: str) -> None:
|
|
54
|
+
self.path = Path(path) if path else None
|
|
55
|
+
self._key, self._kid = signing_key, key_id
|
|
56
|
+
self._lock = threading.Lock()
|
|
57
|
+
self._seq = 0
|
|
58
|
+
self._tail = GENESIS
|
|
59
|
+
self.entries: List[Dict[str, Any]] = [] # kept in memory too, for tests and GET /witness
|
|
60
|
+
if self.path is not None and self.path.exists():
|
|
61
|
+
for line in self.path.read_text(encoding="utf-8").splitlines():
|
|
62
|
+
if line.strip():
|
|
63
|
+
e = json.loads(line)
|
|
64
|
+
self.entries.append(e)
|
|
65
|
+
self._seq, self._tail = int(e["seq"]), e["entry_sha256"]
|
|
66
|
+
elif self.path is not None:
|
|
67
|
+
# Exists from the first start, so an operator can copy it before
|
|
68
|
+
# the first decision and a monitor can see it is in place.
|
|
69
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
self.path.touch()
|
|
71
|
+
|
|
72
|
+
def record(self, event: str, **fields: Any) -> Dict[str, Any]:
|
|
73
|
+
with self._lock:
|
|
74
|
+
entry: Dict[str, Any] = {"witness_version": WITNESS_VERSION, "seq": self._seq + 1, "prev_sha256": self._tail,
|
|
75
|
+
"at": datetime.now(timezone.utc).isoformat(), "event": event, **fields}
|
|
76
|
+
entry["entry_sha256"] = entry_sha256(entry)
|
|
77
|
+
entry["issuer_key_id"] = self._kid
|
|
78
|
+
region = {k: v for k, v in entry.items() if k != "issuer_signature"}
|
|
79
|
+
entry["issuer_signature"] = self._key.sign(canonical_json(region).encode("utf-8")).hex()
|
|
80
|
+
if self.path is not None:
|
|
81
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
with open(self.path, "a", encoding="utf-8") as f:
|
|
83
|
+
f.write(json.dumps(entry, sort_keys=True) + "\n")
|
|
84
|
+
self.entries.append(entry)
|
|
85
|
+
self._seq, self._tail = entry["seq"], entry["entry_sha256"]
|
|
86
|
+
return entry
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def verify_witness(entries: Iterable[Any], *, issuer_public_keys: Dict[str, Any]) -> Dict[str, Any]:
|
|
90
|
+
"""Every entry hashes, links and carries a valid issuer signature.
|
|
91
|
+
Returns {"accepted", "reason", "count", "tail_sha256", "entries": [...]}."""
|
|
92
|
+
report, prev, expect, tail, ok_all = [], GENESIS, 1, GENESIS, True
|
|
93
|
+
for raw in entries:
|
|
94
|
+
if not isinstance(raw, dict) or raw.get("witness_version") != WITNESS_VERSION or "entry_sha256" not in raw \
|
|
95
|
+
or not isinstance(raw.get("seq"), int) or "event" not in raw:
|
|
96
|
+
report.append({"seq": None, "accepted": False, "reason": REF_WITNESS_MALFORMED}); ok_all = False
|
|
97
|
+
continue
|
|
98
|
+
ok, reason = True, ACCEPT_WITNESS_VERIFIED
|
|
99
|
+
if entry_sha256(raw) != raw["entry_sha256"]:
|
|
100
|
+
ok, reason = False, REF_WITNESS_HASH_MISMATCH
|
|
101
|
+
elif raw["seq"] != expect or raw.get("prev_sha256") != prev:
|
|
102
|
+
ok, reason = False, REF_WITNESS_CHAIN_BROKEN
|
|
103
|
+
else:
|
|
104
|
+
key = issuer_public_keys.get(raw.get("issuer_key_id"))
|
|
105
|
+
if key is None:
|
|
106
|
+
ok, reason = False, REF_WITNESS_KEY_UNKNOWN
|
|
107
|
+
else:
|
|
108
|
+
region = {k: v for k, v in raw.items() if k != "issuer_signature"}
|
|
109
|
+
try:
|
|
110
|
+
key.verify(bytes.fromhex(raw.get("issuer_signature", "")), canonical_json(region).encode("utf-8"))
|
|
111
|
+
except Exception:
|
|
112
|
+
ok, reason = False, REF_WITNESS_SIGNATURE_INVALID
|
|
113
|
+
report.append({"seq": raw["seq"], "event": raw["event"], "accepted": ok, "reason": reason})
|
|
114
|
+
ok_all = ok_all and ok
|
|
115
|
+
expect, prev, tail = raw["seq"] + 1, raw["entry_sha256"], raw["entry_sha256"]
|
|
116
|
+
return {"accepted": ok_all, "reason": ACCEPT_WITNESS_VERIFIED if ok_all else next(r["reason"] for r in report if not r["accepted"]),
|
|
117
|
+
"count": len(report), "tail_sha256": tail, "entries": report}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def read_witness(path: str) -> List[Any]:
|
|
121
|
+
out: List[Any] = []
|
|
122
|
+
for line in Path(path).read_text(encoding="utf-8").splitlines():
|
|
123
|
+
if line.strip():
|
|
124
|
+
try:
|
|
125
|
+
out.append(json.loads(line))
|
|
126
|
+
except ValueError:
|
|
127
|
+
out.append(None)
|
|
128
|
+
return out
|
{gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.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.14.1.dev0
|
|
4
4
|
Summary: Signed, single-use, argument-bound receipts and human-approval holds for agent tool calls.
|
|
5
5
|
Author: Justin LaPorte
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -59,7 +59,7 @@ your MCP server and lets through only what both agreed to.
|
|
|
59
59
|
|
|
60
60
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
61
61
|
|
|
62
|
-
Status: pre-release (`0.
|
|
62
|
+
Status: pre-release (`0.14.1.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
|
|
63
63
|
still change before the first release; it is specified in [SPEC.md](SPEC.md).
|
|
64
64
|
|
|
65
65
|
## What a receipt gives you
|
|
@@ -240,6 +240,17 @@ its initialize handshake, so a fleet with Claude on some desks and ChatGPT
|
|
|
240
240
|
on others still has one trail that says who, through what, did what. The
|
|
241
241
|
receipt itself stays model-agnostic.
|
|
242
242
|
|
|
243
|
+
The evidence is the workstation's own statement, and the workstation
|
|
244
|
+
belongs to its user. So the issuer keeps a witness: a signed, hash-chained
|
|
245
|
+
record of every receipt it issued and every grant it accepted, on the
|
|
246
|
+
operator's server. `gargoyle audit --witness` reconciles the two by
|
|
247
|
+
decision id and names what does not agree: a receipt the issuer never
|
|
248
|
+
issued, a held call honored without a grant the issuer saw, a receipt
|
|
249
|
+
issued with no evidence recorded. `--check-tree` hashes the gated folder
|
|
250
|
+
against the evidence's recorded states and names any file changed around
|
|
251
|
+
the gate. On a machine the user administers the gate constrains the
|
|
252
|
+
assistant, not the user; these two checks make the difference visible.
|
|
253
|
+
|
|
243
254
|
The MCP middleware also keeps an evidence log: one hash-chained line per
|
|
244
255
|
decision, refusals and declined holds included, so the trail answers "what
|
|
245
256
|
did the agent try" as well as "what did it do". `gargoyle audit --log`
|
|
@@ -369,6 +380,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
369
380
|
| issuer `GARGOYLE_ENTRA_CLIENT_ID`, `GARGOYLE_ENTRA_SCOPE` | lets the console sign approvers in with Entra (code flow with PKCE, no library); the browser generates a non-exportable key and enrolls it under the UPN |
|
|
370
381
|
| issuer `GARGOYLE_APPROVERS_PATH`, `GARGOYLE_APPROVER_ROLE`, `GARGOYLE_APPROVER_GROUPS` | where enrolled approvers persist (default `approvers.json` beside the keys) and which Entra app role (default `Approver`) or groups may enroll |
|
|
371
382
|
| issuer `GARGOYLE_HOLDS_PATH` | SQLite file for holds, grants, declines and plans, so a restart loses nothing a person is acting on (`gargoyle issuer` sets `holds.sqlite` beside the keys); `GET /health` reports it |
|
|
383
|
+
| issuer `GARGOYLE_WITNESS_PATH` | the issuer's signed, hash-chained record of every decision (`gargoyle issuer` sets `witness.jsonl` beside the keys); `gargoyle audit --witness` reconciles a client's evidence against it, `--check-tree DIR` finds files changed outside the gate |
|
|
372
384
|
| issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
|
|
373
385
|
| `GARGOYLE_HOLD_WEBHOOK_URL`, `GARGOYLE_APPROVE_URL` | issuer: POST a notice of every new hold (Slack incoming-webhook shape, `text` plus the hold) with a link to the console; notice only, the grant still needs the approver's key |
|
|
374
386
|
| `GARGOYLE_MCP_STATE_PROBE` | `filesystem` (default) binds a held file write to the file's current state: the approver sees the change as a diff, the call refuses if the file moved before the run (`REF_VERIFY_STATE_MISMATCH`), and the execution receipt records pre and post state; `none` disables |
|
|
@@ -28,12 +28,14 @@ gargoyle/pending_store.py
|
|
|
28
28
|
gargoyle/plans.py
|
|
29
29
|
gargoyle/policy.py
|
|
30
30
|
gargoyle/published_record_source.py
|
|
31
|
+
gargoyle/reconcile.py
|
|
31
32
|
gargoyle/remote.py
|
|
32
33
|
gargoyle/replay_cache.py
|
|
33
34
|
gargoyle/revert.py
|
|
34
35
|
gargoyle/rules.py
|
|
35
36
|
gargoyle/state.py
|
|
36
37
|
gargoyle/verifier.py
|
|
38
|
+
gargoyle/witness.py
|
|
37
39
|
gargoyle/specs/graph-reference.json
|
|
38
40
|
gargoyle/static/approve.html
|
|
39
41
|
gargoyle/static/approve.js
|
|
@@ -75,4 +77,5 @@ tests/test_state.py
|
|
|
75
77
|
tests/test_stdio_encoding.py
|
|
76
78
|
tests/test_threshold.py
|
|
77
79
|
tests/test_typescript_conformance.py
|
|
78
|
-
tests/test_wire_spec.py
|
|
80
|
+
tests/test_wire_spec.py
|
|
81
|
+
tests/test_witness.py
|
|
@@ -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.14.1.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"
|
|
@@ -197,11 +197,15 @@ def test_entries_name_the_client_and_older_versions_still_verify(tmp_path):
|
|
|
197
197
|
assert anon["client"] is None
|
|
198
198
|
v = verify_log(read_log(str(log.path)), **TRUST)
|
|
199
199
|
assert v["accepted"] and [r["client"] for r in v["entries"]] == ["Claude Desktop 1.4", None]
|
|
200
|
-
# A version-2 entry (no client
|
|
201
|
-
|
|
200
|
+
# A version-2 entry (no client, no ref) is still well-formed; a version-3 entry has client but no ref;
|
|
201
|
+
# a version-4 entry without its ref member is not well-formed.
|
|
202
|
+
v2 = {k: val for k, val in anon.items() if k not in ("client", "ref")}; v2["evidence_version"] = "gargoyle-evidence/2"
|
|
202
203
|
v2["entry_sha256"] = entry_sha256(v2)
|
|
203
204
|
assert _well_formed(v2)
|
|
204
|
-
|
|
205
|
-
|
|
205
|
+
v3 = {k: val for k, val in anon.items() if k != "ref"}; v3["evidence_version"] = "gargoyle-evidence/3"
|
|
206
|
+
v3["entry_sha256"] = entry_sha256(v3)
|
|
207
|
+
assert _well_formed(v3)
|
|
208
|
+
v4_missing = {k: val for k, val in anon.items() if k != "ref"}
|
|
209
|
+
assert not _well_formed(v4_missing)
|
|
206
210
|
with pytest.raises(ValueError):
|
|
207
211
|
build_entry(seq=1, prev_sha256=GENESIS, kind=REFUSED, tool="t", args={}, principal=PRINCIPAL, reason="x", client=7)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""The issuer's witness log and reconciliation: client evidence checked
|
|
2
|
+
against an independent, issuer-signed record; a gated folder checked
|
|
3
|
+
against the evidence."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
|
|
9
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
10
|
+
from fastapi.testclient import TestClient
|
|
11
|
+
|
|
12
|
+
from gargoyle.approval import build_grant, sign_grant
|
|
13
|
+
from gargoyle.evidence import HONORED, EvidenceLog, read_log, verify_log
|
|
14
|
+
from gargoyle.execution import build_execution_receipt, sign_execution_receipt
|
|
15
|
+
from gargoyle.issuer import issue
|
|
16
|
+
from gargoyle.issuer_service import build_issuer_app
|
|
17
|
+
from gargoyle.jws import encode
|
|
18
|
+
from gargoyle.mcp_server import EXECUTED_META_KEY, IssuerClient, ServerState, StdioUpstream, handle_message
|
|
19
|
+
from gargoyle.policy import ELIGIBLE, Decision
|
|
20
|
+
from gargoyle.reconcile import check_tree, reconcile
|
|
21
|
+
from gargoyle.witness import (ACCEPT_WITNESS_VERIFIED, REF_WITNESS_HASH_MISMATCH, REF_WITNESS_SIGNATURE_INVALID, WitnessLog,
|
|
22
|
+
read_witness, verify_witness)
|
|
23
|
+
|
|
24
|
+
issuer, executor, approver, idp = (Ed25519PrivateKey.generate() for _ in range(4))
|
|
25
|
+
PIN = {"policy_id": "p/1", "policy_sha256": "a" * 64}
|
|
26
|
+
TARGET = "mcp://fs"
|
|
27
|
+
PRINCIPAL = "agent@contoso.com"
|
|
28
|
+
ISSUER_KEYS = {"issuer-1": issuer.public_key()}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FsPolicy:
|
|
32
|
+
def pin(self): return PIN
|
|
33
|
+
def decide(self, target_url, interaction):
|
|
34
|
+
return Decision(ELIGIBLE, None, True) if interaction.get("tool") == "write_file" else Decision(ELIGIBLE)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def token():
|
|
38
|
+
return encode({"sub": PRINCIPAL, "exp": int((datetime.now(timezone.utc) + timedelta(hours=1)).timestamp())}, idp, "idp-1")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_witness_entries_are_signed_and_chained(tmp_path):
|
|
42
|
+
w = WitnessLog(str(tmp_path / "w.jsonl"), issuer, "issuer-1")
|
|
43
|
+
w.record("issued", decision_id="d-1", principal=PRINCIPAL, tool="read_file")
|
|
44
|
+
w.record("granted", approval_request_id="r-1", approver="dana")
|
|
45
|
+
again = WitnessLog(str(tmp_path / "w.jsonl"), issuer, "issuer-1") # continues the chain after a restart
|
|
46
|
+
again.record("declined", approval_request_id="r-2")
|
|
47
|
+
entries = read_witness(str(tmp_path / "w.jsonl"))
|
|
48
|
+
v = verify_witness(entries, issuer_public_keys=ISSUER_KEYS)
|
|
49
|
+
assert v["accepted"] and v["count"] == 3 and [e["seq"] for e in entries] == [1, 2, 3]
|
|
50
|
+
forged = dict(entries[1], approver="mallory")
|
|
51
|
+
assert verify_witness([entries[0], forged, entries[2]], issuer_public_keys=ISSUER_KEYS)["entries"][1]["reason"] == REF_WITNESS_HASH_MISMATCH
|
|
52
|
+
from gargoyle.witness import entry_sha256
|
|
53
|
+
forged["entry_sha256"] = entry_sha256(forged) # rehashed, but the issuer did not sign it
|
|
54
|
+
assert verify_witness([entries[0], forged], issuer_public_keys=ISSUER_KEYS)["entries"][1]["reason"] == REF_WITNESS_SIGNATURE_INVALID
|
|
55
|
+
assert not verify_witness(entries, issuer_public_keys={"other": executor.public_key()})["accepted"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _rig(tmp_path, root):
|
|
59
|
+
w = WitnessLog(str(tmp_path / "witness.jsonl"), issuer, "issuer-1")
|
|
60
|
+
app = build_issuer_app(policy=FsPolicy(), signing_key=issuer, key_id="issuer-1", idp_public_keys={"idp-1": idp.public_key()},
|
|
61
|
+
approver_public_keys={"approver-1": approver.public_key()}, witness=w)
|
|
62
|
+
c = TestClient(app); h = {"Authorization": "Bearer " + token()}
|
|
63
|
+
|
|
64
|
+
class Shim:
|
|
65
|
+
def post(self, url, json=None, headers=None, timeout=None): return c.post(url.replace("http://i", ""), json=json, headers=headers)
|
|
66
|
+
def get(self, url, headers=None, timeout=None): return c.get(url.replace("http://i", ""), headers=headers)
|
|
67
|
+
|
|
68
|
+
def human(seconds):
|
|
69
|
+
for hold in c.get("/pending", headers=h).json()["pending"]:
|
|
70
|
+
env = hold["envelope"]
|
|
71
|
+
g = sign_grant(build_grant(decision_sha256=env["decision_sha256"], approval_request_id=env["approval_request_id"],
|
|
72
|
+
grant_id="g-" + env["approval_request_id"], not_after=datetime.now(timezone.utc) + timedelta(minutes=5)), approver, "approver-1")
|
|
73
|
+
c.post("/grants", json=g, headers=h)
|
|
74
|
+
|
|
75
|
+
up = StdioUpstream([sys.executable, "-I", "-m", "gargoyle.fs_server", str(root)])
|
|
76
|
+
state = ServerState({"pinned_public_keys": ISSUER_KEYS, "record": PIN, "target_id": TARGET, "principal": PRINCIPAL,
|
|
77
|
+
"approver_public_keys": {"approver-1": approver.public_key()}, "executor_signing_key": executor, "executor_key_id": "executor-1"},
|
|
78
|
+
upstream=up, issuer_client=IssuerClient("http://i", token(), http=Shim(), approval_timeout=30, poll_seconds=0.01, sleep=human),
|
|
79
|
+
audit_log=str(tmp_path / "audit.jsonl"))
|
|
80
|
+
handle_message(state, {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})
|
|
81
|
+
handle_message(state, {"jsonrpc": "2.0", "method": "notifications/initialized"})
|
|
82
|
+
return state, up, c, h, w
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def call(state, n, name, args):
|
|
86
|
+
return handle_message(state, {"jsonrpc": "2.0", "id": n, "method": "tools/call", "params": {"name": name, "arguments": args}})["result"]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_honest_evidence_reconciles_and_tampering_is_found(tmp_path):
|
|
90
|
+
root = tmp_path / "root"; root.mkdir(); (root / "n.md").write_text("one\n", encoding="utf-8"); (root / "other.md").write_text("x", encoding="utf-8")
|
|
91
|
+
state, up, c, h, w = _rig(tmp_path, root)
|
|
92
|
+
try:
|
|
93
|
+
assert call(state, 2, "read_file", {"path": str(root / "n.md")})["_meta"][EXECUTED_META_KEY]
|
|
94
|
+
assert call(state, 3, "write_file", {"path": str(root / "n.md"), "content": "two\n"})["_meta"][EXECUTED_META_KEY]
|
|
95
|
+
finally:
|
|
96
|
+
up.close()
|
|
97
|
+
evidence = read_log(str(tmp_path / "audit.jsonl")); witness = read_witness(str(tmp_path / "witness.jsonl"))
|
|
98
|
+
assert [e["event"] for e in witness] == ["issued", "issued", "granted"]
|
|
99
|
+
assert evidence[1]["ref"] == str(root / "n.md") and evidence[1]["evidence_version"] == "gargoyle-evidence/4"
|
|
100
|
+
assert verify_log(evidence, issuer_public_keys=ISSUER_KEYS, executor_public_keys={"executor-1": executor.public_key()},
|
|
101
|
+
approver_public_keys={"approver-1": approver.public_key()})["accepted"]
|
|
102
|
+
r = reconcile(evidence, witness, principal=PRINCIPAL)
|
|
103
|
+
assert r["accepted"] and r["issued"] == 2 and r["evidence_with_receipt"] == 2
|
|
104
|
+
# The witness is served to an authenticated caller too.
|
|
105
|
+
assert len(c.get("/witness", headers=h).json()["entries"]) == 3
|
|
106
|
+
|
|
107
|
+
# 1. A forged honored entry for a decision the issuer never issued: the user holds the executor key, so it verifies alone.
|
|
108
|
+
now = datetime.now(timezone.utc)
|
|
109
|
+
fake_call = {"tool": "write_file", "args": {"path": str(root / "other.md"), "content": "pwned"}, "principal": PRINCIPAL}
|
|
110
|
+
rogue_issuer = Ed25519PrivateKey.generate()
|
|
111
|
+
fake_env = issue(FsPolicy(), TARGET, fake_call, signing_key=rogue_issuer, key_id="issuer-1", now=now).envelope
|
|
112
|
+
forged = {"kind": HONORED, "receipt": fake_env, "grant": None, "execution": None, "seq": 99, "at": now.isoformat(), "tool": "write_file"}
|
|
113
|
+
r = reconcile(evidence + [forged], witness, principal=PRINCIPAL)
|
|
114
|
+
assert [f["finding"] for f in r["findings"]] == ["UNWITNESSED_RECEIPT"]
|
|
115
|
+
# 2. A held call honored with no grant the issuer saw: drop the witness's grant line.
|
|
116
|
+
r = reconcile(evidence, [x for x in witness if x["event"] != "granted"], principal=PRINCIPAL)
|
|
117
|
+
assert [f["finding"] for f in r["findings"]] == ["UNWITNESSED_RELEASE"]
|
|
118
|
+
# 3. A receipt the issuer issued but the client never recorded: drop the client's read entry.
|
|
119
|
+
r = reconcile([e for e in evidence if e["tool"] != "read_file"], witness, principal=PRINCIPAL)
|
|
120
|
+
assert [f["finding"] for f in r["findings"]] == ["MISSING_EVIDENCE"] and r["findings"][0]["tool"] == "read_file"
|
|
121
|
+
|
|
122
|
+
# The tree: as the gate left it, then edited behind its back, then deleted.
|
|
123
|
+
t = check_tree(evidence, str(root))
|
|
124
|
+
assert t["accepted"] and t["checked"] == 1 and t["untouched"] == 1
|
|
125
|
+
(root / "n.md").write_text("edited in notepad\n", encoding="utf-8")
|
|
126
|
+
t = check_tree(evidence, str(root))
|
|
127
|
+
assert [f["finding"] for f in t["findings"]] == ["CHANGED_OUTSIDE_GATE"] and t["findings"][0]["path"] == str(root / "n.md")
|
|
128
|
+
(root / "n.md").unlink()
|
|
129
|
+
assert check_tree(evidence, str(root))["findings"][0].get("missing") is True
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_audit_command_reports_reconciliation_and_tree(tmp_path, capsys):
|
|
133
|
+
root = tmp_path / "root"; root.mkdir(); (root / "n.md").write_text("one\n", encoding="utf-8")
|
|
134
|
+
state, up, c, h, w = _rig(tmp_path, root)
|
|
135
|
+
try:
|
|
136
|
+
assert call(state, 2, "write_file", {"path": str(root / "n.md"), "content": "two\n"})["_meta"][EXECUTED_META_KEY]
|
|
137
|
+
finally:
|
|
138
|
+
up.close()
|
|
139
|
+
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
|
140
|
+
hexpub = lambda k: k.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw).hex()
|
|
141
|
+
out = tmp_path / "deploy"; out.mkdir()
|
|
142
|
+
(out / "public-keys.json").write_text(json.dumps({"issuer": {"issuer-1": hexpub(issuer)}, "executor": {"executor-1": hexpub(executor)},
|
|
143
|
+
"approver": {"approver-1": hexpub(approver)}}))
|
|
144
|
+
from gargoyle.cli import main
|
|
145
|
+
assert main(["audit", "--log", str(tmp_path / "audit.jsonl"), "--out", str(out), "--witness", str(tmp_path / "witness.jsonl"),
|
|
146
|
+
"--check-tree", str(root)]) == 0
|
|
147
|
+
text = capsys.readouterr().out
|
|
148
|
+
assert "witness: 2 entries, WITNESS_VERIFIED" in text and "0 finding(s)" in text and "1 gated file(s) checked" in text
|
|
149
|
+
(root / "n.md").write_text("tampered", encoding="utf-8")
|
|
150
|
+
assert main(["audit", "--log", str(tmp_path / "audit.jsonl"), "--out", str(out), "--check-tree", str(root)]) == 1
|
|
151
|
+
assert "CHANGED_OUTSIDE_GATE" in capsys.readouterr().out
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_audit_names_a_missing_witness_or_folder_plainly(tmp_path, capsys):
|
|
155
|
+
from gargoyle.cli import main
|
|
156
|
+
log = tmp_path / "audit.jsonl"; log.write_text("")
|
|
157
|
+
out = tmp_path / "deploy"; out.mkdir()
|
|
158
|
+
(out / "public-keys.json").write_text(json.dumps({"issuer": {}, "executor": {}, "approver": {}}))
|
|
159
|
+
assert main(["audit", "--log", str(log), "--out", str(out), "--witness", str(tmp_path / "nope.jsonl")]) == 2
|
|
160
|
+
assert "witness file not found" in capsys.readouterr().err
|
|
161
|
+
assert main(["audit", "--log", str(log), "--out", str(out), "--check-tree", str(tmp_path / "nodir")]) == 2
|
|
162
|
+
assert "not a directory" in capsys.readouterr().err
|
|
163
|
+
# The issuer creates the witness file at start, before any decision.
|
|
164
|
+
w = WitnessLog(str(tmp_path / "fresh" / "witness.jsonl"), issuer, "issuer-1")
|
|
165
|
+
assert (tmp_path / "fresh" / "witness.jsonl").exists() and w.entries == []
|
|
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.13.0.dev0 → gargoyle_receipts-0.14.1.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
|
{gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/published_record_source.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/specs/graph-reference.json
RENAMED
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/static/approve.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.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
|
{gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_issuer_service.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_mcp_self_issue.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_published_record.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_stdio_encoding.py
RENAMED
|
File without changes
|
|
File without changes
|
{gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_typescript_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|