gargoyle-receipts 0.16.3.dev0__tar.gz → 0.17.0.dev0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. {gargoyle_receipts-0.16.3.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.17.0.dev0}/PKG-INFO +19 -7
  2. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/README.md +18 -6
  3. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/adapters.py +4 -3
  4. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/cli.py +59 -1
  5. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/envelope.py +7 -3
  6. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/evidence.py +25 -0
  7. gargoyle_receipts-0.17.0.dev0/gargoyle/globs.py +58 -0
  8. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/issuer_service.py +151 -25
  9. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/mcp_server.py +30 -11
  10. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/plans.py +22 -2
  11. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/policy.py +3 -1
  12. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/reconcile.py +8 -0
  13. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/replay_cache.py +42 -0
  14. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/rules.py +156 -1
  15. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/state.py +17 -0
  16. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/static/approve.html +41 -7
  17. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +19 -7
  18. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +4 -0
  19. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/pyproject.toml +1 -1
  20. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_approvers.py +52 -3
  21. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_cli.py +29 -1
  22. gargoyle_receipts-0.17.0.dev0/tests/test_globs.py +46 -0
  23. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_plans.py +88 -1
  24. gargoyle_receipts-0.17.0.dev0/tests/test_review_fixes.py +236 -0
  25. gargoyle_receipts-0.17.0.dev0/tests/test_state_text.py +48 -0
  26. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_threshold.py +2 -2
  27. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/LICENSE +0 -0
  28. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/NOTICE +0 -0
  29. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/__init__.py +0 -0
  30. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/__main__.py +0 -0
  31. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/approval.py +0 -0
  32. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/approver_cli.py +0 -0
  33. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/approver_trust.py +0 -0
  34. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/approvers.py +0 -0
  35. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/consumer.py +0 -0
  36. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/entra_login.py +0 -0
  37. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/execution.py +0 -0
  38. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/fs_server.py +0 -0
  39. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/holdstore.py +0 -0
  40. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/identity.py +0 -0
  41. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/issuer.py +0 -0
  42. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/jws.py +0 -0
  43. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/key_record_source.py +0 -0
  44. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/notify.py +0 -0
  45. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/opa.py +0 -0
  46. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/pending_store.py +0 -0
  47. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/published_record_source.py +0 -0
  48. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/remote.py +0 -0
  49. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/revert.py +0 -0
  50. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
  51. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/static/approve.js +0 -0
  52. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/verifier.py +0 -0
  53. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle/witness.py +0 -0
  54. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  55. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  56. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  57. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  58. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/setup.cfg +0 -0
  59. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_annotations.py +0 -0
  60. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_approver_cli.py +0 -0
  61. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_canonical.py +0 -0
  62. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_console_js.py +0 -0
  63. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_consumer.py +0 -0
  64. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_entra_identity.py +0 -0
  65. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_entra_login.py +0 -0
  66. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_evidence.py +0 -0
  67. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_execution.py +0 -0
  68. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_fs_server.py +0 -0
  69. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_graph.py +0 -0
  70. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_historical.py +0 -0
  71. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_identity.py +0 -0
  72. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_issuer_service.py +0 -0
  73. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_jws.py +0 -0
  74. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_mcp_proxy.py +0 -0
  75. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_mcp_self_issue.py +0 -0
  76. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_mcp_server.py +0 -0
  77. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_notify.py +0 -0
  78. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_opa.py +0 -0
  79. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_policy.py +0 -0
  80. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_published_record.py +0 -0
  81. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_remote.py +0 -0
  82. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_revert.py +0 -0
  83. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_rules.py +0 -0
  84. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_state.py +0 -0
  85. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_stdio_encoding.py +0 -0
  86. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_typescript_conformance.py +0 -0
  87. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_wire_spec.py +0 -0
  88. {gargoyle_receipts-0.16.3.dev0 → gargoyle_receipts-0.17.0.dev0}/tests/test_witness.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.16.3.dev0
3
+ Version: 0.17.0.dev0
4
4
  Summary: Signed, single-use, argument-bound receipts and human-approval holds for agent tool calls.
5
5
  Author: Justin LaPorte
6
6
  License-Expression: Apache-2.0
@@ -59,7 +59,7 @@ your MCP server and lets through only what both agreed to.
59
59
 
60
60
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
61
61
 
62
- Status: pre-release (`0.16.3.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
62
+ Status: pre-release (`0.17.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
63
63
  still change before the first release; it is specified in [SPEC.md](SPEC.md).
64
64
 
65
65
  ## What a receipt gives you
@@ -161,7 +161,9 @@ two-method interface.
161
161
  **No engine.** `gargoyle.rules.RulesPolicy` decides from a JSON file:
162
162
  first matching rule wins, globs on tool name, principal, delegation,
163
163
  the caller's groups and roles, and string arguments, actions allow, hold
164
- or deny. The pin is the file's hash,
164
+ or deny. Argument globs that look like paths are matched on a normalized
165
+ form (separators, `..`, letter case), so a rule for one folder is not
166
+ slipped by traversal or spelling; `*` spans folders. The pin is the file's hash,
165
167
  so an edit invalidates outstanding receipts exactly as an engine change
166
168
  does. This is what `gargoyle init` sets up.
167
169
 
@@ -278,9 +280,14 @@ chat channel can approve anything.
278
280
 
279
281
  pip install gargoyle-receipts
280
282
 
281
- Three commands, no policy engine, no containers, no client changes:
283
+ Three commands, no policy engine, no containers, no client changes
284
+ (`--observe` starts a deployment in record-only mode, every call allowed
285
+ including destructive ones and every call recorded; `gargoyle audit
286
+ --summary` then shows calls by principal and tool, the input for the
287
+ hold and deny rules):
282
288
 
283
289
  gargoyle init /path/the/agent/may/touch # keys, config, rules.json
290
+ gargoyle init --observe /path/... # or: allow and record everything, hold nothing, tighten later
284
291
  gargoyle issuer # the issuing service, on rules.json
285
292
  gargoyle approve --watch 2 # where the approver sits (terminal)
286
293
 
@@ -298,7 +305,11 @@ stay on file so earlier grants still audit; new ones are refused at the
298
305
  issuer and at every middleware on its next held call. Revocation retires
299
306
  a key, not a person: while the Approver role stands, the person can sign
300
307
  in and enroll a fresh key, so remove the role too; the revoked key itself
301
- is never accepted again. Every key an approver held stays in the file
308
+ is never accepted again. The role is what counts at release time: a
309
+ grant under an enrolled key is accepted only from that account, under a
310
+ token that carries the role now, so a role removed in the directory ends
311
+ the power at once. One key per account: a second browser does not enroll
312
+ until the first key consents or the operator revokes it. Every key an approver held stays in the file
302
313
  (`history`), so an audit of grants released under an earlier key still
303
314
  verifies: copy the issuer's current `approvers.json` beside the evidence
304
315
  before auditing. Approvers, and
@@ -397,6 +408,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
397
408
  | issuer `GARGOYLE_ENTRA_TENANT` + `GARGOYLE_ISSUER_AUDIENCE` | trust the tenant's tokens; the audience is required with a tenant (the service will not start without it), and a directory token for any other tenant is `REF_IDENTITY_WRONG_TENANT` |
398
409
  | 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 |
399
410
  | issuer `GARGOYLE_MAX_OPEN_HOLDS` | how many held receipts and plans one principal may have waiting at once (default 20); the call over the cap is `REF_ISSUER_TOO_MANY_HOLDS` and nothing is stored |
411
+ | issuer `GARGOYLE_ALLOW_SELF_APPROVAL` | `1` lets a person release their own call; by default the approver may be neither the hold's principal nor the party acted for (`REF_ISSUER_SELF_APPROVAL`). A grant for an expired hold is `REF_ISSUER_HOLD_EXPIRED` and the hold is dropped |
400
412
  | 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 |
401
413
  | issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
402
414
  | `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 |
@@ -405,7 +417,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
405
417
  | `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
406
418
  | `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
407
419
  | `GARGOYLE_MCP_SNAPSHOT_DIR` | where the bytes a held, state-bound write replaces are kept (default `snapshots/` beside the audit log; `none` disables undo); the middleware then offers a `gargoyle_revert` tool that restores an approved change through the gate, held and state-bound like any write |
408
- | `GARGOYLE_MCP_AUDIT_LOG` | the evidence log: every decision, honored or refused, as one hash-chained JSON line with its receipt, grant and execution receipt; `gargoyle audit --log` verifies the file |
420
+ | `GARGOYLE_MCP_AUDIT_LOG` | the evidence log: every decision, honored or refused, as one hash-chained JSON line with its receipt, grant and execution receipt; `gargoyle audit --log` verifies the file; the replay claims that make a receipt single-use live beside it in `<log stem>.replay.sqlite`, so a restarted gate still refuses a receipt it honored |
409
421
 
410
422
  **Issuing service** (`gargoyle.issuer_service`, run with uvicorn from
411
423
  `app_from_env()`): identity token in, receipt out, with the hold and grant
@@ -453,7 +465,7 @@ on it.
453
465
  | `mcp_server` | the stdio MCP middleware (and demo executor), with self-issue mode |
454
466
  | `fs_server` | a small Python filesystem MCP server, for clients without Node |
455
467
  | `issuer_service` | the HTTP issuing service: identity in, receipts out, holds and grants |
456
- | `replay_cache`, `pending_store` | single use for receipts and holds; in-memory or Redis |
468
+ | `replay_cache`, `pending_store` | single use for receipts and holds; in memory, SQLite beside the audit log (the middleware's default) or Redis |
457
469
  | `approval`, `approver_cli`, `approver_trust` | grants, the human's tool, approver key trust |
458
470
  | `published_record_source` | the live policy pin as a publisher-signed record with serial and expiry |
459
471
  | `key_record_source` | root-signed issuer and approver keys with windows and revocation |
@@ -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.16.3.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
24
+ Status: pre-release (`0.17.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
25
25
  still change before the first release; it is specified in [SPEC.md](SPEC.md).
26
26
 
27
27
  ## What a receipt gives you
@@ -123,7 +123,9 @@ two-method interface.
123
123
  **No engine.** `gargoyle.rules.RulesPolicy` decides from a JSON file:
124
124
  first matching rule wins, globs on tool name, principal, delegation,
125
125
  the caller's groups and roles, and string arguments, actions allow, hold
126
- or deny. The pin is the file's hash,
126
+ or deny. Argument globs that look like paths are matched on a normalized
127
+ form (separators, `..`, letter case), so a rule for one folder is not
128
+ slipped by traversal or spelling; `*` spans folders. The pin is the file's hash,
127
129
  so an edit invalidates outstanding receipts exactly as an engine change
128
130
  does. This is what `gargoyle init` sets up.
129
131
 
@@ -240,9 +242,14 @@ chat channel can approve anything.
240
242
 
241
243
  pip install gargoyle-receipts
242
244
 
243
- Three commands, no policy engine, no containers, no client changes:
245
+ Three commands, no policy engine, no containers, no client changes
246
+ (`--observe` starts a deployment in record-only mode, every call allowed
247
+ including destructive ones and every call recorded; `gargoyle audit
248
+ --summary` then shows calls by principal and tool, the input for the
249
+ hold and deny rules):
244
250
 
245
251
  gargoyle init /path/the/agent/may/touch # keys, config, rules.json
252
+ gargoyle init --observe /path/... # or: allow and record everything, hold nothing, tighten later
246
253
  gargoyle issuer # the issuing service, on rules.json
247
254
  gargoyle approve --watch 2 # where the approver sits (terminal)
248
255
 
@@ -260,7 +267,11 @@ stay on file so earlier grants still audit; new ones are refused at the
260
267
  issuer and at every middleware on its next held call. Revocation retires
261
268
  a key, not a person: while the Approver role stands, the person can sign
262
269
  in and enroll a fresh key, so remove the role too; the revoked key itself
263
- is never accepted again. Every key an approver held stays in the file
270
+ is never accepted again. The role is what counts at release time: a
271
+ grant under an enrolled key is accepted only from that account, under a
272
+ token that carries the role now, so a role removed in the directory ends
273
+ the power at once. One key per account: a second browser does not enroll
274
+ until the first key consents or the operator revokes it. Every key an approver held stays in the file
264
275
  (`history`), so an audit of grants released under an earlier key still
265
276
  verifies: copy the issuer's current `approvers.json` beside the evidence
266
277
  before auditing. Approvers, and
@@ -359,6 +370,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
359
370
  | issuer `GARGOYLE_ENTRA_TENANT` + `GARGOYLE_ISSUER_AUDIENCE` | trust the tenant's tokens; the audience is required with a tenant (the service will not start without it), and a directory token for any other tenant is `REF_IDENTITY_WRONG_TENANT` |
360
371
  | issuer `GARGOYLE_HOLDS_PATH` | SQLite file for holds, grants, declines and plans, so a restart loses nothing a person is acting on (`gargoyle issuer` sets `holds.sqlite` beside the keys); `GET /health` reports it |
361
372
  | issuer `GARGOYLE_MAX_OPEN_HOLDS` | how many held receipts and plans one principal may have waiting at once (default 20); the call over the cap is `REF_ISSUER_TOO_MANY_HOLDS` and nothing is stored |
373
+ | issuer `GARGOYLE_ALLOW_SELF_APPROVAL` | `1` lets a person release their own call; by default the approver may be neither the hold's principal nor the party acted for (`REF_ISSUER_SELF_APPROVAL`). A grant for an expired hold is `REF_ISSUER_HOLD_EXPIRED` and the hold is dropped |
362
374
  | 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 |
363
375
  | issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
364
376
  | `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 |
@@ -367,7 +379,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
367
379
  | `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
368
380
  | `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
369
381
  | `GARGOYLE_MCP_SNAPSHOT_DIR` | where the bytes a held, state-bound write replaces are kept (default `snapshots/` beside the audit log; `none` disables undo); the middleware then offers a `gargoyle_revert` tool that restores an approved change through the gate, held and state-bound like any write |
370
- | `GARGOYLE_MCP_AUDIT_LOG` | the evidence log: every decision, honored or refused, as one hash-chained JSON line with its receipt, grant and execution receipt; `gargoyle audit --log` verifies the file |
382
+ | `GARGOYLE_MCP_AUDIT_LOG` | the evidence log: every decision, honored or refused, as one hash-chained JSON line with its receipt, grant and execution receipt; `gargoyle audit --log` verifies the file; the replay claims that make a receipt single-use live beside it in `<log stem>.replay.sqlite`, so a restarted gate still refuses a receipt it honored |
371
383
 
372
384
  **Issuing service** (`gargoyle.issuer_service`, run with uvicorn from
373
385
  `app_from_env()`): identity token in, receipt out, with the hold and grant
@@ -415,7 +427,7 @@ on it.
415
427
  | `mcp_server` | the stdio MCP middleware (and demo executor), with self-issue mode |
416
428
  | `fs_server` | a small Python filesystem MCP server, for clients without Node |
417
429
  | `issuer_service` | the HTTP issuing service: identity in, receipts out, holds and grants |
418
- | `replay_cache`, `pending_store` | single use for receipts and holds; in-memory or Redis |
430
+ | `replay_cache`, `pending_store` | single use for receipts and holds; in memory, SQLite beside the audit log (the middleware's default) or Redis |
419
431
  | `approval`, `approver_cli`, `approver_trust` | grants, the human's tool, approver key trust |
420
432
  | `published_record_source` | the live policy pin as a publisher-signed record with serial and expiry |
421
433
  | `key_record_source` | root-signed issuer and approver keys with windows and revocation |
@@ -135,9 +135,10 @@ class GraphAdapter:
135
135
  @staticmethod
136
136
  def _desc(ref: str, obj: Any, summary: str, irreversible: bool = False) -> Tuple[Dict[str, Any], Optional[bytes]]:
137
137
  data = canonical_json(obj).encode("utf-8") if obj is not None else None
138
- desc = describe(ref, data, with_text=False)
139
- if data is not None:
140
- desc["text"] = json.dumps(obj, indent=2, sort_keys=True)
138
+ # The text is the hashed bytes themselves (canonical JSON), so the
139
+ # console can verify it against the bound state before showing it;
140
+ # it pretty-prints JSON for display.
141
+ desc = describe(ref, data, with_text=True)
141
142
  desc["summary"] = summary
142
143
  if irreversible:
143
144
  desc["irreversible"] = True
@@ -51,6 +51,17 @@ from pathlib import Path
51
51
 
52
52
  PRINCIPAL = "spiffe://gargoyle.local/agents/assistant"
53
53
 
54
+ OBSERVE_RULES = {
55
+ # Observe mode: every call allowed (deletes too), every call recorded, none held. The
56
+ # first week of a deployment, so the hold and deny rules are written
57
+ # from the audit rather than guessed. The policy pin changes when the
58
+ # rules do, so switching modes means restarting the issuer and copying
59
+ # the new record.json to each client.
60
+ "version": "1",
61
+ "default": "allow",
62
+ "rules": [],
63
+ }
64
+
54
65
  DEFAULT_RULES = {
55
66
  "version": "1",
56
67
  "default": "deny",
@@ -118,7 +129,22 @@ def cmd_init(args) -> int:
118
129
  issuer_url = args.issuer_url or "http://%s:%d" % (args.host, args.port)
119
130
 
120
131
  rules = out / "rules.json"
121
- if not rules.exists():
132
+ if args.observe:
133
+ current = None
134
+ if rules.exists():
135
+ try:
136
+ current = json.loads(rules.read_text())
137
+ except ValueError as exc:
138
+ print("%s is not valid JSON (%s); fix or remove it before --observe" % (rules, exc), file=sys.stderr)
139
+ return 2
140
+ if current is None or current == DEFAULT_RULES:
141
+ _write(rules, json.dumps(OBSERVE_RULES, indent=2) + "\n")
142
+ elif current != OBSERVE_RULES:
143
+ print("rules.json was edited by hand and is left as it is; --observe changed nothing", file=sys.stderr)
144
+ print("observe mode: rules.json allows every call, destructive ones included; the gate records all of "
145
+ "them and holds none. Tighten rules.json from `gargoyle audit --summary`, then restart the issuer "
146
+ "and copy the new record.json to the clients.")
147
+ elif not rules.exists():
122
148
  _write(rules, json.dumps(DEFAULT_RULES, indent=2) + "\n")
123
149
  _write(out / "issuer.env", _env_lines({
124
150
  "GARGOYLE_ISSUER_KEY_ID": "issuer-1", "GARGOYLE_ISSUER_KEY_HEX": issuer_hex,
@@ -360,6 +386,33 @@ def cmd_approvers(args) -> int:
360
386
  return 1
361
387
 
362
388
 
389
+ def _audit_summary(report, raw) -> None:
390
+ """Calls by principal and tool with their outcomes, then who released
391
+ and which clients: a week of observe mode as a table, the input for
392
+ writing the hold and deny rules."""
393
+ from collections import Counter
394
+ counts: Dict[tuple, Counter] = {}
395
+ releases: Counter = Counter()
396
+ clients: Counter = Counter()
397
+ ats = [e.get("at") for e in raw if isinstance(e, dict) and isinstance(e.get("at"), str)]
398
+ from gargoyle.evidence import FAILED_VERDICTS
399
+ for r in report:
400
+ kind = r["kind"] or "?"
401
+ if kind == "honored" and r.get("verdict") in FAILED_VERDICTS:
402
+ kind = "failed" # admitted, but the upstream did not do it
403
+ counts.setdefault((r["principal"] or "?", r["tool"] or "?"), Counter())[kind] += 1
404
+ for who in r.get("released_by") or []:
405
+ releases[who] += 1
406
+ clients[r.get("client") or "(client unnamed)"] += 1
407
+ print("summary: %d call(s)%s" % (len(report), (", %s to %s" % (min(ats)[:19], max(ats)[:19])) if ats else ""))
408
+ print(" %-28s %-24s %7s %7s %7s" % ("principal", "tool", "honored", "failed", "refused"))
409
+ for (principal, tool), c in sorted(counts.items(), key=lambda kv: (kv[0][0], -sum(kv[1].values()), kv[0][1])):
410
+ print(" %-28s %-24s %7d %7d %7d" % (principal[:28], tool[:24], c.get("honored", 0), c.get("failed", 0), c.get("refused", 0)))
411
+ if releases:
412
+ print(" releases: " + ", ".join("%s %d" % (w, n) for w, n in releases.most_common()))
413
+ print(" clients: " + ", ".join("%s %d" % (w, n) for w, n in clients.most_common()))
414
+
415
+
363
416
  def cmd_audit(args) -> int:
364
417
  from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
365
418
  from gargoyle.execution import verify_chain
@@ -406,6 +459,8 @@ def cmd_audit(args) -> int:
406
459
  % (v["count"], v["honored"], v["refused"], failed, v["reason"], v["tail_sha256"][:16]))
407
460
  rc = 0 if v["accepted"] else 1
408
461
  entries = read_log(args.log)
462
+ if args.summary:
463
+ _audit_summary(v["entries"], entries)
409
464
  if args.witness and not Path(args.witness).exists():
410
465
  print("witness file not found: %s (copy the issuer's witness.jsonl here first)" % args.witness, file=sys.stderr)
411
466
  return 2
@@ -460,6 +515,8 @@ def main(argv=None) -> int:
460
515
  p.add_argument("--issuer-url", help="URL the client reaches the issuer at (default http://HOST:PORT)")
461
516
  p.add_argument("--client-python", help="the client's Python executable for the MCP entry (default: this one)")
462
517
  p.add_argument("--client-config-dir", help="where the client keeps middleware.env, record.json, audit.jsonl (default: --out)")
518
+ p.add_argument("--observe", action="store_true",
519
+ help="observe mode: allow and record every call, hold none; tighten rules.json later from the audit")
463
520
  p.add_argument("--approvers", type=int, default=1, metavar="N",
464
521
  help="how many approver keys to create (approver-1..N); a rule with \"approvals\": 2 then needs two of them")
465
522
  p.add_argument("--upstream", choices=["npx-filesystem", "python-fs"], default="npx-filesystem",
@@ -512,6 +569,7 @@ def main(argv=None) -> int:
512
569
  p.add_argument("--log", help="the middleware's audit.jsonl: verify every entry")
513
570
  p.add_argument("--witness", help="the issuer's witness.jsonl: reconcile the evidence against it")
514
571
  p.add_argument("--check-tree", metavar="DIR", help="verify every gated file under DIR is as the gate left it")
572
+ p.add_argument("--summary", action="store_true", help="after the rows: calls by principal and tool with outcomes, releases, clients")
515
573
  p.add_argument("--out", default="gargoyle-deploy")
516
574
  p.set_defaults(fn=cmd_audit)
517
575
  args = parser.parse_args(argv)
@@ -208,7 +208,9 @@ def valid_principal(principal: Any) -> bool:
208
208
  def valid_approval(approval: Any) -> bool:
209
209
  """True iff `approval` is a well-formed receipt `approval` member:
210
210
  {"required": int >= 1, "approvers": [non-empty unique strings] optional}."""
211
- if not isinstance(approval, dict) or not set(approval) <= {"required", "approvers"} or "required" not in approval:
211
+ if not isinstance(approval, dict) or not set(approval) <= {"required", "approvers", "plannable"} or "required" not in approval:
212
+ return False
213
+ if "plannable" in approval and not isinstance(approval["plannable"], bool):
212
214
  return False
213
215
  required = approval["required"]
214
216
  if isinstance(required, bool) or not isinstance(required, int) or required < 1:
@@ -320,6 +322,8 @@ def build_envelope(
320
322
  envelope["approval"] = {"required": approval["required"]}
321
323
  if "approvers" in approval:
322
324
  envelope["approval"]["approvers"] = sorted(approval["approvers"])
325
+ if approval.get("plannable") is False:
326
+ envelope["approval"]["plannable"] = False
323
327
  envelope["decision_sha256"] = decision_sha256_of(envelope)
324
328
  return envelope
325
329
 
@@ -329,8 +333,8 @@ def approval_terms(envelope: Dict[str, Any]) -> Dict[str, Any]:
329
333
  the receipt's `approval` member, or the default of one from anyone."""
330
334
  a = envelope.get("approval")
331
335
  if isinstance(a, dict):
332
- return {"required": a["required"], "approvers": a.get("approvers")}
333
- return {"required": 1, "approvers": None}
336
+ return {"required": a["required"], "approvers": a.get("approvers"), "plannable": a.get("plannable", True)}
337
+ return {"required": 1, "approvers": None, "plannable": True}
334
338
 
335
339
 
336
340
  def reassert(
@@ -63,6 +63,17 @@ REF_EVIDENCE_HASH_MISMATCH = "REF_EVIDENCE_HASH_MISMATCH" # entry_sha256 does n
63
63
  REF_EVIDENCE_CHAIN_BROKEN = "REF_EVIDENCE_CHAIN_BROKEN" # seq or prev_sha256 does not follow
64
64
  REF_EVIDENCE_RECEIPT_MISSING = "REF_EVIDENCE_RECEIPT_MISSING" # honored without receipt or execution
65
65
  REF_EVIDENCE_UNCHAINED = "REF_EVIDENCE_UNCHAINED" # a pre-chain {receipt, grant, execution} line
66
+ REF_EVIDENCE_REPLAY = "REF_EVIDENCE_REPLAY" # a decision id (or plan step) honored twice in one log
67
+
68
+ # Verdicts an honored entry carries when the admitted call did not succeed
69
+ # downstream: the receipt was claimed, so the entry must exist, and the
70
+ # verdict says the change most likely did not happen (the execution receipt's
71
+ # outputs carry the error). Any other verdict on an honored entry is the
72
+ # verifier's accept reason.
73
+ TOOL_ERROR = "TOOL_ERROR" # the upstream answered with isError: true
74
+ UPSTREAM_ERROR = "UPSTREAM_ERROR" # the upstream answered with a JSON-RPC error
75
+ UPSTREAM_UNAVAILABLE = "UPSTREAM_UNAVAILABLE" # the upstream could not be reached after the claim
76
+ FAILED_VERDICTS = frozenset((TOOL_ERROR, UPSTREAM_ERROR, UPSTREAM_UNAVAILABLE))
66
77
 
67
78
  _FIELDS = ("evidence_version", "seq", "prev_sha256", "at", "kind", "tool", "args_sha256",
68
79
  "principal", "reason", "receipt", "grant", "execution", "entry_sha256")
@@ -191,6 +202,7 @@ def verify_log(
191
202
  expect_seq, prev, tail = 1, GENESIS, GENESIS
192
203
  counts = {HONORED: 0, REFUSED: 0}
193
204
  all_ok = True
205
+ claimed: Dict[str, int] = {} # decision_id / plan step -> seq of the honored entry that spent it
194
206
 
195
207
  for raw in entries:
196
208
  if not _well_formed(raw):
@@ -220,6 +232,19 @@ def verify_log(
220
232
  if ok and (rc.get("tool") != e["tool"] or rc.get("args_sha256") != e["args_sha256"]
221
233
  or rc.get("principal") != e["principal"]):
222
234
  ok, reason = False, REF_EVIDENCE_RECEIPT_MISSING
235
+ if ok:
236
+ # Single use, checked after the fact: a decision id, or a
237
+ # plan step, honored twice in one log is a replay the
238
+ # executor's cache missed (a second process, a lost cache).
239
+ keys = [str(e["receipt"].get("decision_id"))]
240
+ gs = e["grant"]
241
+ if isinstance(gs, dict) and "plan" in gs and "step" in gs:
242
+ keys.append("plan:%s:%s" % ((gs["plan"] or {}).get("plan_id"), gs["step"]))
243
+ if any(k in claimed for k in keys):
244
+ ok, reason = False, REF_EVIDENCE_REPLAY
245
+ else:
246
+ for k in keys:
247
+ claimed[k] = e["seq"]
223
248
  else:
224
249
  reason = ACCEPT_ENTRY_VERIFIED
225
250
  if e["receipt"] is not None:
@@ -0,0 +1,58 @@
1
+ """Argument globs, as rules (gargoyle.rules) and plan steps (gargoyle.plans)
2
+ use them: `fnmatch` patterns over one argument value, with paths made safe
3
+ to match.
4
+
5
+ A raw `fnmatch` on a path is a hole: `C:\\proj\\*` is slipped by
6
+ `C:\\proj\\..\\secrets\\x`, by `c:\\PROJ\\x` on a case-insensitive disk and
7
+ by `C:/proj/x`. So when either side looks like a path (a separator, or a
8
+ drive letter), both are normalized first: separators become `/`, `.` and
9
+ `..` segments are resolved lexically, repeated separators collapse, and
10
+ the whole string is lower-cased, since the gate cannot know the target
11
+ filesystem's case rules and a rule meant for one folder must not be
12
+ slipped by a letter. Values that look like nothing of the kind (a mail
13
+ address, an account id) are matched exactly as written, case included.
14
+
15
+ `*` spans folders: a rule for `C:\\proj\\*` covers the whole tree under it.
16
+ There is no segment-bounded wildcard; a rule that wants one folder level
17
+ names it.
18
+ """
19
+
20
+ import fnmatch
21
+ import posixpath
22
+ import re
23
+
24
+ _PATHLIKE = re.compile(r"[\\/]|^[A-Za-z]:")
25
+ _DRIVE = re.compile(r"^([A-Za-z]:)(.*)$", re.S)
26
+
27
+
28
+ def is_pathlike(value: str) -> bool:
29
+ return bool(_PATHLIKE.search(value))
30
+
31
+
32
+ def normalize_path(value: str) -> str:
33
+ """The comparable form of a path or path glob (see the module note)."""
34
+ text = value.replace("\\", "/")
35
+ drive = ""
36
+ m = _DRIVE.match(text)
37
+ if m:
38
+ drive, text = m.group(1), m.group(2) or "/"
39
+ if text.startswith("/"):
40
+ text = "/" + text.lstrip("/") # after a drive, repeated separators are just separators
41
+ unc = not drive and text.startswith("//") and not text.startswith("///")
42
+ text = posixpath.normpath(text) if text else ""
43
+ if unc and not text.startswith("//"):
44
+ text = "/" + text
45
+ return (drive + text).lower()
46
+
47
+
48
+ def comparable(glob: str, value: str):
49
+ """Both sides as they are compared: normalized when either is a path."""
50
+ if is_pathlike(value) or is_pathlike(glob):
51
+ return normalize_path(glob), normalize_path(value)
52
+ return glob, value
53
+
54
+
55
+ def glob_match(glob: str, value: str) -> bool:
56
+ """Does the argument value match the glob? Paths normalized first."""
57
+ g, v = comparable(glob, value)
58
+ return fnmatch.fnmatchcase(v, g)