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.
Files changed (83) hide show
  1. {gargoyle_receipts-0.13.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.14.1.dev0}/PKG-INFO +14 -2
  2. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/README.md +13 -1
  3. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/cli.py +32 -1
  4. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/evidence.py +17 -8
  5. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/issuer_service.py +22 -2
  6. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/mcp_server.py +9 -3
  7. gargoyle_receipts-0.14.1.dev0/gargoyle/reconcile.py +130 -0
  8. gargoyle_receipts-0.14.1.dev0/gargoyle/witness.py +128 -0
  9. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0/gargoyle_receipts.egg-info}/PKG-INFO +14 -2
  10. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +4 -1
  11. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/pyproject.toml +1 -1
  12. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_evidence.py +8 -4
  13. gargoyle_receipts-0.14.1.dev0/tests/test_witness.py +165 -0
  14. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/LICENSE +0 -0
  15. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/NOTICE +0 -0
  16. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/__init__.py +0 -0
  17. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/adapters.py +0 -0
  18. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/approval.py +0 -0
  19. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/approver_cli.py +0 -0
  20. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/approver_trust.py +0 -0
  21. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/approvers.py +0 -0
  22. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/consumer.py +0 -0
  23. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/entra_login.py +0 -0
  24. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/envelope.py +0 -0
  25. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/execution.py +0 -0
  26. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/fs_server.py +0 -0
  27. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/holdstore.py +0 -0
  28. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/identity.py +0 -0
  29. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/issuer.py +0 -0
  30. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/jws.py +0 -0
  31. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/key_record_source.py +0 -0
  32. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/notify.py +0 -0
  33. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/opa.py +0 -0
  34. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/pending_store.py +0 -0
  35. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/plans.py +0 -0
  36. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/policy.py +0 -0
  37. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/published_record_source.py +0 -0
  38. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/remote.py +0 -0
  39. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/replay_cache.py +0 -0
  40. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/revert.py +0 -0
  41. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/rules.py +0 -0
  42. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/specs/graph-reference.json +0 -0
  43. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/state.py +0 -0
  44. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/static/approve.html +0 -0
  45. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/static/approve.js +0 -0
  46. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle/verifier.py +0 -0
  47. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  48. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  49. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  50. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  51. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/setup.cfg +0 -0
  52. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_annotations.py +0 -0
  53. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_approver_cli.py +0 -0
  54. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_approvers.py +0 -0
  55. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_canonical.py +0 -0
  56. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_cli.py +0 -0
  57. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_console_js.py +0 -0
  58. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_consumer.py +0 -0
  59. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_entra_identity.py +0 -0
  60. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_entra_login.py +0 -0
  61. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_execution.py +0 -0
  62. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_fs_server.py +0 -0
  63. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_graph.py +0 -0
  64. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_historical.py +0 -0
  65. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_identity.py +0 -0
  66. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_issuer_service.py +0 -0
  67. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_jws.py +0 -0
  68. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_mcp_proxy.py +0 -0
  69. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_mcp_self_issue.py +0 -0
  70. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_mcp_server.py +0 -0
  71. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_notify.py +0 -0
  72. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_opa.py +0 -0
  73. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_plans.py +0 -0
  74. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_policy.py +0 -0
  75. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_published_record.py +0 -0
  76. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_remote.py +0 -0
  77. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_revert.py +0 -0
  78. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_rules.py +0 -0
  79. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_state.py +0 -0
  80. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_stdio_encoding.py +0 -0
  81. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_threshold.py +0 -0
  82. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_typescript_conformance.py +0 -0
  83. {gargoyle_receipts-0.13.0.dev0 → gargoyle_receipts-0.14.1.dev0}/tests/test_wire_spec.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.13.0.dev0
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
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
61
61
 
62
- Status: pre-release (`0.13.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
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
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
23
23
 
24
- Status: pre-release (`0.13.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
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
- return 0 if v["accepted"] else 1
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/3" # version 3 adds `client`; hashes over RFC 8785 canonical JSON
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 = _FIELDS_V3 if isinstance(entry, dict) and entry.get("evidence_version") == EVIDENCE_VERSION else _FIELDS
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; never the admission path."""
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.13.0.dev0
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
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
61
61
 
62
- Status: pre-release (`0.13.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
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.13.0.dev0"
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 member) is still well-formed; a version-3 entry without it is not.
201
- v2 = {k: val for k, val in anon.items() if k != "client"}; v2["evidence_version"] = "gargoyle-evidence/2"
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
- v3_missing = {k: val for k, val in anon.items() if k != "client"}
205
- assert not _well_formed(v3_missing)
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 == []