gargoyle-receipts 0.12.0.dev0__tar.gz → 0.14.0.dev0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {gargoyle_receipts-0.12.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.14.0.dev0}/PKG-INFO +16 -2
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/README.md +15 -1
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/cli.py +57 -1
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/evidence.py +17 -8
- gargoyle_receipts-0.14.0.dev0/gargoyle/holdstore.py +116 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/issuer_service.py +66 -8
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/mcp_server.py +9 -3
- gargoyle_receipts-0.14.0.dev0/gargoyle/reconcile.py +130 -0
- gargoyle_receipts-0.14.0.dev0/gargoyle/witness.py +123 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +16 -2
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +5 -1
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/pyproject.toml +1 -1
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_evidence.py +8 -4
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_issuer_service.py +30 -0
- gargoyle_receipts-0.14.0.dev0/tests/test_witness.py +151 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/adapters.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/approval.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/approvers.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/consumer.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/envelope.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/execution.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/identity.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/plans.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/policy.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/published_record_source.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/remote.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/replay_cache.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/revert.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/rules.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/state.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/static/approve.html +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/static/approve.js +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle/verifier.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_approvers.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_cli.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_console_js.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_entra_identity.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_graph.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_historical.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_mcp_self_issue.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_plans.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_remote.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_revert.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_rules.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_state.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_threshold.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.12.0.dev0 → gargoyle_receipts-0.14.0.dev0}/tests/test_wire_spec.py +0 -0
{gargoyle_receipts-0.12.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.14.0.dev0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gargoyle-receipts
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.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
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
61
61
|
|
|
62
|
-
Status: pre-release (`0.
|
|
62
|
+
Status: pre-release (`0.14.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
|
|
@@ -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`
|
|
@@ -368,6 +379,9 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
368
379
|
| `GARGOYLE_MCP_ENTRA_TENANT`, `_CLIENT_ID`, `_SCOPE`, `_TOKEN_CACHE` | instead of a fixed client token: the user's own Entra ID token, written by `gargoyle login` (device code, MSAL cache, silent refresh); the principal is their UPN. No usable token refuses `REF_TARGET_LOGIN_REQUIRED` |
|
|
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 |
|
|
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 |
|
|
384
|
+
| issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
|
|
371
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 |
|
|
372
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 |
|
|
373
387
|
| `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
|
|
@@ -21,7 +21,7 @@ your MCP server and lets through only what both agreed to.
|
|
|
21
21
|
|
|
22
22
|
[](https://pypi.org/project/gargoyle-receipts/)
|
|
23
23
|
|
|
24
|
-
Status: pre-release (`0.
|
|
24
|
+
Status: pre-release (`0.14.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
|
|
@@ -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`
|
|
@@ -330,6 +341,9 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
330
341
|
| `GARGOYLE_MCP_ENTRA_TENANT`, `_CLIENT_ID`, `_SCOPE`, `_TOKEN_CACHE` | instead of a fixed client token: the user's own Entra ID token, written by `gargoyle login` (device code, MSAL cache, silent refresh); the principal is their UPN. No usable token refuses `REF_TARGET_LOGIN_REQUIRED` |
|
|
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 |
|
|
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 |
|
|
346
|
+
| issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
|
|
333
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 |
|
|
334
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 |
|
|
335
349
|
| `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
|
|
@@ -26,6 +26,8 @@ policy engine, a container runtime, or any client changes.
|
|
|
26
26
|
approver's machine); with --approvers N also approver-2.env ...
|
|
27
27
|
approver-web.json the same key and token in the form the browser approval
|
|
28
28
|
console loads (private; same machine as approver.env)
|
|
29
|
+
gargoyle-issuer.service, apache-gargoyle.conf
|
|
30
|
+
a systemd unit and an Apache virtual host for the issuer
|
|
29
31
|
mcp-config.json the server entry to paste into the MCP client
|
|
30
32
|
public-keys.json issuer, approver and executor public keys, for audits
|
|
31
33
|
|
|
@@ -147,6 +149,33 @@ def cmd_init(args) -> int:
|
|
|
147
149
|
"issuer": {"issuer-1": issuer_pub}, "approver": {a[0]: a[3] for a in approvers},
|
|
148
150
|
"executor": {"executor-1": executor_pub}, "identity_provider": {"idp-1": idp_pub},
|
|
149
151
|
}, indent=2) + "\n")
|
|
152
|
+
_write(out / "gargoyle-issuer.service", """[Unit]
|
|
153
|
+
Description=Gargoyle issuing service
|
|
154
|
+
After=network-online.target
|
|
155
|
+
|
|
156
|
+
[Service]
|
|
157
|
+
User=gargoyle
|
|
158
|
+
WorkingDirectory=%(out)s
|
|
159
|
+
ExecStart=%(python)s -m gargoyle.cli issuer --out %(out)s --host 127.0.0.1 --port %(port)d
|
|
160
|
+
Restart=on-failure
|
|
161
|
+
RestartSec=3
|
|
162
|
+
|
|
163
|
+
[Install]
|
|
164
|
+
WantedBy=multi-user.target
|
|
165
|
+
""" % {"out": str(out), "python": sys.executable, "port": args.port})
|
|
166
|
+
_write(out / "apache-gargoyle.conf", """# Apache in front of the issuer: TLS from a trusted CA, one virtual host.
|
|
167
|
+
<VirtualHost *:443>
|
|
168
|
+
ServerName %(host)s
|
|
169
|
+
SSLEngine on
|
|
170
|
+
SSLCertificateFile /etc/ssl/certs/gargoyle.crt
|
|
171
|
+
SSLCertificateKeyFile /etc/ssl/private/gargoyle.key
|
|
172
|
+
ProxyPreserveHost On
|
|
173
|
+
ProxyPass / http://127.0.0.1:%(port)d/
|
|
174
|
+
ProxyPassReverse / http://127.0.0.1:%(port)d/
|
|
175
|
+
ErrorLog ${APACHE_LOG_DIR}/gargoyle-error.log
|
|
176
|
+
CustomLog ${APACHE_LOG_DIR}/gargoyle-access.log combined
|
|
177
|
+
</VirtualHost>
|
|
178
|
+
""" % {"host": (args.issuer_url or "gargoyle.example").replace("https://", "").replace("http://", "").split("/")[0].split(":")[0], "port": args.port})
|
|
150
179
|
upstream = (["npx", "-y", "@modelcontextprotocol/server-filesystem", allowed] if args.upstream == "npx-filesystem"
|
|
151
180
|
else [client_python, "-m", "gargoyle.fs_server", allowed])
|
|
152
181
|
_write(out / "mcp-config.json", json.dumps({"mcpServers": {"filesystem-gargoyle": {
|
|
@@ -260,6 +289,9 @@ def cmd_issuer(args) -> int:
|
|
|
260
289
|
out = Path(args.out).resolve()
|
|
261
290
|
os.environ.update(_read_env(out / "issuer.env"))
|
|
262
291
|
os.environ.setdefault("GARGOYLE_APPROVERS_PATH", str(out / "approvers.json")) # enrolled approvers live beside the keys
|
|
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
|
|
294
|
+
os.environ.setdefault("GARGOYLE_LOG_JSON", "1")
|
|
263
295
|
app = app_from_env()
|
|
264
296
|
# The middleware reads the policy pin from a file; write it next to the
|
|
265
297
|
# config so the client-side entry finds it at the configured path.
|
|
@@ -327,7 +359,29 @@ def cmd_audit(args) -> int:
|
|
|
327
359
|
failed = sum(1 for r in v["entries"] if not r["accepted"])
|
|
328
360
|
print("%d entries (%d honored, %d refused), %d failed; chain %s; tail %s"
|
|
329
361
|
% (v["count"], v["honored"], v["refused"], failed, v["reason"], v["tail_sha256"][:16]))
|
|
330
|
-
|
|
362
|
+
rc = 0 if v["accepted"] else 1
|
|
363
|
+
entries = read_log(args.log)
|
|
364
|
+
if args.witness:
|
|
365
|
+
# The issuer's signed record: the client's evidence must agree with it.
|
|
366
|
+
from gargoyle.reconcile import reconcile
|
|
367
|
+
from gargoyle.witness import read_witness, verify_witness
|
|
368
|
+
w = verify_witness(read_witness(args.witness), issuer_public_keys=trust["issuer_public_keys"])
|
|
369
|
+
print("witness: %d entries, %s, tail %s" % (w["count"], w["reason"], w["tail_sha256"][:16]))
|
|
370
|
+
principal = next((e.get("principal") for e in entries if isinstance(e, dict) and e.get("principal")), None)
|
|
371
|
+
r = reconcile(entries, read_witness(args.witness), principal=principal)
|
|
372
|
+
for f in r["findings"]:
|
|
373
|
+
print(" FINDING %-22s %s" % (f["finding"], " ".join("%s=%s" % (k, str(v)[:40]) for k, v in f.items() if k != "finding")))
|
|
374
|
+
print("reconciled: %d issued by the issuer, %d in evidence, %d finding(s)" % (r["issued"], r["evidence_with_receipt"], len(r["findings"])))
|
|
375
|
+
rc = rc or (0 if (w["accepted"] and r["accepted"]) else 1)
|
|
376
|
+
if args.check_tree:
|
|
377
|
+
# Every file the gate wrote must still be as the gate left it.
|
|
378
|
+
from gargoyle.reconcile import check_tree
|
|
379
|
+
t = check_tree(entries, args.check_tree)
|
|
380
|
+
for f in t["findings"]:
|
|
381
|
+
print(" FINDING %-22s %s%s" % (f["finding"], f["path"], " (missing)" if f.get("missing") else ""))
|
|
382
|
+
print("tree: %d gated file(s) checked, %d untouched by the gate, %d changed outside it" % (t["checked"], t["untouched"], len(t["findings"])))
|
|
383
|
+
rc = rc or (0 if t["accepted"] else 1)
|
|
384
|
+
return rc
|
|
331
385
|
receipt, execution = json.load(open(args.receipt)), json.load(open(args.execution))
|
|
332
386
|
grant = json.load(open(args.grant)) if args.grant else None
|
|
333
387
|
result = verify_chain(receipt, execution, grant=grant, **trust)
|
|
@@ -393,6 +447,8 @@ def main(argv=None) -> int:
|
|
|
393
447
|
p = sub.add_parser("audit", help="verify a receipt chain offline, or a whole evidence log")
|
|
394
448
|
p.add_argument("receipt", nargs="?"); p.add_argument("execution", nargs="?"); p.add_argument("grant", nargs="?")
|
|
395
449
|
p.add_argument("--log", help="the middleware's audit.jsonl: verify every entry")
|
|
450
|
+
p.add_argument("--witness", help="the issuer's witness.jsonl: reconcile the evidence against it")
|
|
451
|
+
p.add_argument("--check-tree", metavar="DIR", help="verify every gated file under DIR is as the gate left it")
|
|
396
452
|
p.add_argument("--out", default="gargoyle-deploy")
|
|
397
453
|
p.set_defaults(fn=cmd_audit)
|
|
398
454
|
args = parser.parse_args(argv)
|
|
@@ -48,9 +48,9 @@ from gargoyle.envelope import args_sha256, canonical_json, canonical_json_legacy
|
|
|
48
48
|
from gargoyle.execution import verify_chain
|
|
49
49
|
from gargoyle.verifier import verify_provenance
|
|
50
50
|
|
|
51
|
-
EVIDENCE_VERSION = "gargoyle-evidence/
|
|
51
|
+
EVIDENCE_VERSION = "gargoyle-evidence/4" # 3 added `client`, 4 adds `ref`; hashes over RFC 8785 canonical JSON
|
|
52
52
|
EVIDENCE_VERSIONS = {"gargoyle-evidence/1": canonical_json_legacy, "gargoyle-evidence/2": canonical_json,
|
|
53
|
-
EVIDENCE_VERSION: canonical_json}
|
|
53
|
+
"gargoyle-evidence/3": canonical_json, EVIDENCE_VERSION: canonical_json}
|
|
54
54
|
GENESIS = "0" * 64
|
|
55
55
|
|
|
56
56
|
HONORED = "honored"
|
|
@@ -71,6 +71,11 @@ _FIELDS = ("evidence_version", "seq", "prev_sha256", "at", "kind", "tool", "args
|
|
|
71
71
|
# receipt stays model-agnostic: this is evidence about the session, not a
|
|
72
72
|
# bound field of the decision.
|
|
73
73
|
_FIELDS_V3 = _FIELDS + ("client",)
|
|
74
|
+
# Version 4 adds `ref`: what the adapter examined for a state-bound call (a
|
|
75
|
+
# file path, "mailRule:<id>"), or null. With it a folder can be checked
|
|
76
|
+
# against the evidence without any plaintext (gargoyle.reconcile.check_tree).
|
|
77
|
+
_FIELDS_V4 = _FIELDS_V3 + ("ref",)
|
|
78
|
+
_FIELDS_BY_VERSION = {"gargoyle-evidence/3": _FIELDS_V3, "gargoyle-evidence/4": _FIELDS_V4}
|
|
74
79
|
|
|
75
80
|
|
|
76
81
|
def entry_sha256(entry: Dict[str, Any]) -> str:
|
|
@@ -84,11 +89,13 @@ def entry_sha256(entry: Dict[str, Any]) -> str:
|
|
|
84
89
|
|
|
85
90
|
def build_entry(*, seq: int, prev_sha256: str, kind: str, tool: str, args: Any, principal: str,
|
|
86
91
|
reason: str, receipt: Any = None, grant: Any = None, execution: Any = None,
|
|
87
|
-
at: Optional[datetime] = None, client: Optional[str] = None) -> Dict[str, Any]:
|
|
92
|
+
at: Optional[datetime] = None, client: Optional[str] = None, ref: Optional[str] = None) -> Dict[str, Any]:
|
|
88
93
|
if kind not in (HONORED, REFUSED):
|
|
89
94
|
raise ValueError("kind must be honored or refused")
|
|
90
95
|
if client is not None and not isinstance(client, str):
|
|
91
96
|
raise ValueError("client must be a string or None")
|
|
97
|
+
if ref is not None and not isinstance(ref, str):
|
|
98
|
+
raise ValueError("ref must be a string or None")
|
|
92
99
|
entry = {
|
|
93
100
|
"evidence_version": EVIDENCE_VERSION,
|
|
94
101
|
"seq": seq,
|
|
@@ -103,6 +110,7 @@ def build_entry(*, seq: int, prev_sha256: str, kind: str, tool: str, args: Any,
|
|
|
103
110
|
"grant": grant,
|
|
104
111
|
"execution": execution,
|
|
105
112
|
"client": client,
|
|
113
|
+
"ref": ref,
|
|
106
114
|
}
|
|
107
115
|
entry["entry_sha256"] = entry_sha256(entry)
|
|
108
116
|
return entry
|
|
@@ -131,14 +139,14 @@ class EvidenceLog:
|
|
|
131
139
|
|
|
132
140
|
def append(self, kind: str, tool: str, args: Any, principal: str, reason: str, *,
|
|
133
141
|
receipt: Any = None, grant: Any = None, execution: Any = None,
|
|
134
|
-
client: Optional[str] = None) -> Dict[str, Any]:
|
|
142
|
+
client: Optional[str] = None, ref: Optional[str] = None) -> Dict[str, Any]:
|
|
135
143
|
"""Write one entry and return it. Raises OSError if the file cannot
|
|
136
144
|
be written; the caller decides whether that stops the call."""
|
|
137
145
|
if self._seq is None:
|
|
138
146
|
self._load_tail()
|
|
139
147
|
entry = build_entry(seq=self._seq + 1, prev_sha256=self._tail, kind=kind, tool=tool, args=args,
|
|
140
148
|
principal=principal, reason=reason, receipt=receipt, grant=grant, execution=execution,
|
|
141
|
-
client=client)
|
|
149
|
+
client=client, ref=ref)
|
|
142
150
|
with open(self.path, "a", encoding="utf-8") as f:
|
|
143
151
|
f.write(json.dumps(entry, sort_keys=True) + "\n")
|
|
144
152
|
self._seq, self._tail = entry["seq"], entry["entry_sha256"]
|
|
@@ -146,10 +154,11 @@ class EvidenceLog:
|
|
|
146
154
|
|
|
147
155
|
|
|
148
156
|
def _well_formed(entry: Any) -> bool:
|
|
149
|
-
expected =
|
|
157
|
+
expected = _FIELDS_BY_VERSION.get(entry.get("evidence_version"), _FIELDS) if isinstance(entry, dict) else _FIELDS
|
|
150
158
|
return (isinstance(entry, dict) and set(entry) == set(expected)
|
|
151
159
|
and entry["evidence_version"] in EVIDENCE_VERSIONS
|
|
152
160
|
and (entry.get("client") is None or isinstance(entry.get("client"), str))
|
|
161
|
+
and (entry.get("ref") is None or isinstance(entry.get("ref"), str))
|
|
153
162
|
and isinstance(entry["seq"], int) and entry["seq"] >= 1
|
|
154
163
|
and isinstance(entry["prev_sha256"], str) and len(entry["prev_sha256"]) == 64
|
|
155
164
|
and isinstance(entry["at"], str) and entry["kind"] in (HONORED, REFUSED)
|
|
@@ -192,7 +201,7 @@ def verify_log(
|
|
|
192
201
|
rc = rc if isinstance(rc, dict) else {}
|
|
193
202
|
report.append({"seq": None, "kind": None, "tool": rc.get("tool"), "principal": rc.get("principal"),
|
|
194
203
|
"accepted": False, "reason": REF_EVIDENCE_UNCHAINED if legacy else REF_EVIDENCE_MALFORMED,
|
|
195
|
-
"verdict": None, "released_by": [], "state": None, "client": None, "plan": None})
|
|
204
|
+
"verdict": None, "released_by": [], "state": None, "client": None, "plan": None, "ref": None})
|
|
196
205
|
all_ok = False
|
|
197
206
|
continue
|
|
198
207
|
e = raw
|
|
@@ -230,7 +239,7 @@ def verify_log(
|
|
|
230
239
|
if ex.get("pre_state_sha256") or ex.get("post_state_sha256") else None)
|
|
231
240
|
report.append({"seq": e["seq"], "kind": e["kind"], "tool": e["tool"], "principal": e["principal"],
|
|
232
241
|
"accepted": ok, "reason": reason, "verdict": e["reason"], "released_by": released_by,
|
|
233
|
-
"state": state, "client": e.get("client"), "plan": plan_ref})
|
|
242
|
+
"state": state, "client": e.get("client"), "plan": plan_ref, "ref": e.get("ref")})
|
|
234
243
|
all_ok = all_ok and ok
|
|
235
244
|
# The chain continues from what the file says, so one bad entry is
|
|
236
245
|
# reported once rather than cascading into every later one.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Persistent issuer state: holds, grants, declines and plans survive a restart.
|
|
2
|
+
|
|
3
|
+
The issuing service keeps little state, but what it keeps is what a human
|
|
4
|
+
is in the middle of acting on. In a lab the service was restarted a dozen
|
|
5
|
+
times in two days and each restart dropped the holds in flight; the
|
|
6
|
+
middleware then reported a timeout and the assistant retried. A client's
|
|
7
|
+
approver would see a card that did nothing. This module backs each of the
|
|
8
|
+
service's maps with a SQLite table (standard library, one file, one
|
|
9
|
+
process), so a restart changes nothing a person can see. With no path the
|
|
10
|
+
store is in memory, which is what the tests use.
|
|
11
|
+
|
|
12
|
+
Dict semantics are kept on purpose: the service code reads as before,
|
|
13
|
+
and a value is written back on assignment, never by mutating what a read
|
|
14
|
+
returned.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import sqlite3
|
|
19
|
+
import threading
|
|
20
|
+
from collections.abc import MutableMapping, MutableSet
|
|
21
|
+
from typing import Any, Iterator, Optional
|
|
22
|
+
|
|
23
|
+
ENV_HOLDS_PATH = "GARGOYLE_HOLDS_PATH"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class HoldStore:
|
|
27
|
+
def __init__(self, path: Optional[str] = None) -> None:
|
|
28
|
+
self.path = path or ":memory:"
|
|
29
|
+
self._db = sqlite3.connect(self.path, check_same_thread=False, isolation_level=None)
|
|
30
|
+
self._lock = threading.RLock()
|
|
31
|
+
with self._lock:
|
|
32
|
+
self._db.execute("PRAGMA journal_mode=WAL") if self.path != ":memory:" else None
|
|
33
|
+
self._db.execute("CREATE TABLE IF NOT EXISTS kv (ns TEXT NOT NULL, k TEXT NOT NULL, v TEXT NOT NULL, PRIMARY KEY (ns, k))")
|
|
34
|
+
|
|
35
|
+
def dict(self, ns: str) -> "PersistentDict":
|
|
36
|
+
return PersistentDict(self, ns)
|
|
37
|
+
|
|
38
|
+
def set(self, ns: str) -> "PersistentSet":
|
|
39
|
+
return PersistentSet(self, ns)
|
|
40
|
+
|
|
41
|
+
# ---- primitives ----
|
|
42
|
+
def _get(self, ns: str, k: str) -> Any:
|
|
43
|
+
with self._lock:
|
|
44
|
+
row = self._db.execute("SELECT v FROM kv WHERE ns=? AND k=?", (ns, k)).fetchone()
|
|
45
|
+
return json.loads(row[0]) if row else None
|
|
46
|
+
|
|
47
|
+
def _has(self, ns: str, k: str) -> bool:
|
|
48
|
+
with self._lock:
|
|
49
|
+
return self._db.execute("SELECT 1 FROM kv WHERE ns=? AND k=?", (ns, k)).fetchone() is not None
|
|
50
|
+
|
|
51
|
+
def _put(self, ns: str, k: str, v: Any) -> None:
|
|
52
|
+
with self._lock:
|
|
53
|
+
self._db.execute("INSERT OR REPLACE INTO kv (ns, k, v) VALUES (?, ?, ?)", (ns, k, json.dumps(v, sort_keys=True)))
|
|
54
|
+
|
|
55
|
+
def _delete(self, ns: str, k: str) -> bool:
|
|
56
|
+
with self._lock:
|
|
57
|
+
cur = self._db.execute("DELETE FROM kv WHERE ns=? AND k=?", (ns, k))
|
|
58
|
+
return cur.rowcount > 0
|
|
59
|
+
|
|
60
|
+
def _keys(self, ns: str) -> list:
|
|
61
|
+
with self._lock:
|
|
62
|
+
return [r[0] for r in self._db.execute("SELECT k FROM kv WHERE ns=? ORDER BY rowid", (ns,)).fetchall()]
|
|
63
|
+
|
|
64
|
+
def _count(self, ns: str) -> int:
|
|
65
|
+
with self._lock:
|
|
66
|
+
return self._db.execute("SELECT COUNT(*) FROM kv WHERE ns=?", (ns,)).fetchone()[0]
|
|
67
|
+
|
|
68
|
+
def close(self) -> None:
|
|
69
|
+
with self._lock:
|
|
70
|
+
self._db.close()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class PersistentDict(MutableMapping):
|
|
74
|
+
def __init__(self, store: HoldStore, ns: str) -> None:
|
|
75
|
+
self._s, self._ns = store, ns
|
|
76
|
+
|
|
77
|
+
def __getitem__(self, k: str) -> Any:
|
|
78
|
+
if not self._s._has(self._ns, k):
|
|
79
|
+
raise KeyError(k)
|
|
80
|
+
return self._s._get(self._ns, k)
|
|
81
|
+
|
|
82
|
+
def __setitem__(self, k: str, v: Any) -> None:
|
|
83
|
+
self._s._put(self._ns, k, v)
|
|
84
|
+
|
|
85
|
+
def __delitem__(self, k: str) -> None:
|
|
86
|
+
if not self._s._delete(self._ns, k):
|
|
87
|
+
raise KeyError(k)
|
|
88
|
+
|
|
89
|
+
def __contains__(self, k: object) -> bool:
|
|
90
|
+
return isinstance(k, str) and self._s._has(self._ns, k)
|
|
91
|
+
|
|
92
|
+
def __iter__(self) -> Iterator[str]:
|
|
93
|
+
return iter(self._s._keys(self._ns))
|
|
94
|
+
|
|
95
|
+
def __len__(self) -> int:
|
|
96
|
+
return self._s._count(self._ns)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class PersistentSet(MutableSet):
|
|
100
|
+
def __init__(self, store: HoldStore, ns: str) -> None:
|
|
101
|
+
self._s, self._ns = store, ns
|
|
102
|
+
|
|
103
|
+
def __contains__(self, k: object) -> bool:
|
|
104
|
+
return isinstance(k, str) and self._s._has(self._ns, k)
|
|
105
|
+
|
|
106
|
+
def __iter__(self) -> Iterator[str]:
|
|
107
|
+
return iter(self._s._keys(self._ns))
|
|
108
|
+
|
|
109
|
+
def __len__(self) -> int:
|
|
110
|
+
return self._s._count(self._ns)
|
|
111
|
+
|
|
112
|
+
def add(self, k: str) -> None:
|
|
113
|
+
self._s._put(self._ns, k, True)
|
|
114
|
+
|
|
115
|
+
def discard(self, k: str) -> None:
|
|
116
|
+
self._s._delete(self._ns, k)
|
|
@@ -40,6 +40,7 @@ it again when the call runs. The pending set is in memory: one instance.
|
|
|
40
40
|
|
|
41
41
|
import json
|
|
42
42
|
import os
|
|
43
|
+
import sys
|
|
43
44
|
from datetime import datetime, timedelta, timezone
|
|
44
45
|
from pathlib import Path
|
|
45
46
|
from typing import Any, Callable, Dict, Optional
|
|
@@ -53,6 +54,8 @@ from gargoyle.approval import (REF_APPROVAL_APPROVER_NOT_ELIGIBLE, REF_APPROVAL_
|
|
|
53
54
|
verify_grant)
|
|
54
55
|
from gargoyle.envelope import approval_terms
|
|
55
56
|
from gargoyle.state import state_sha256, valid_description
|
|
57
|
+
from gargoyle.holdstore import ENV_HOLDS_PATH, HoldStore
|
|
58
|
+
from gargoyle.witness import WitnessLog
|
|
56
59
|
from gargoyle.plans import (REF_PLAN_MALFORMED, REF_PLAN_STEP_NOT_PLANNABLE, REF_PLAN_STEP_REFUSED, build_plan,
|
|
57
60
|
sign_plan, valid_step)
|
|
58
61
|
from gargoyle.notify import notifier_from_env
|
|
@@ -100,17 +103,43 @@ def build_issuer_app(
|
|
|
100
103
|
approver_role: str = DEFAULT_APPROVER_ROLE,
|
|
101
104
|
approver_groups: Optional[list] = None,
|
|
102
105
|
console_entra: Optional[Dict[str, str]] = None,
|
|
106
|
+
hold_store: Optional[HoldStore] = None,
|
|
107
|
+
log_events: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
108
|
+
witness: Optional[WitnessLog] = None,
|
|
103
109
|
) -> FastAPI:
|
|
104
110
|
"""`on_hold(envelope, args)` is called once for every new hold (gargoyle.notify
|
|
105
111
|
posts it to a webhook); it is notice, not admission, and its failure
|
|
106
112
|
never affects the hold."""
|
|
107
113
|
app = FastAPI(title="Gargoyle issuing service")
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
app.state.
|
|
112
|
-
app.state.
|
|
113
|
-
app.state.
|
|
114
|
+
# Everything a human may be in the middle of acting on lives in the hold
|
|
115
|
+
# store (gargoyle.holdstore): SQLite on disk in a deployment, memory in tests.
|
|
116
|
+
store = hold_store if hold_store is not None else HoldStore(None)
|
|
117
|
+
app.state.store = store
|
|
118
|
+
app.state.pending = store.dict("pending") # approval_request_id -> envelope
|
|
119
|
+
app.state.pending_args = store.dict("pending_args") # approval_request_id -> the call's arguments
|
|
120
|
+
app.state.pending_state = store.dict("pending_state") # approval_request_id -> the target's state when held
|
|
121
|
+
app.state.grants = store.dict("grants") # approval_request_id -> [grant, ...]
|
|
122
|
+
app.state.declined = store.set("declined") # approval_request_ids a human refused
|
|
123
|
+
app.state.pending_plans = store.dict("pending_plans") # approval_request_id -> signed plan (gargoyle.plans)
|
|
124
|
+
app.state.started_at = now().isoformat()
|
|
125
|
+
|
|
126
|
+
def event(kind: str, **fields: Any) -> None:
|
|
127
|
+
"""One structured line per decision the service makes, for the
|
|
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."""
|
|
131
|
+
if log_events is not None:
|
|
132
|
+
try:
|
|
133
|
+
log_events({"event": kind, "at": now().isoformat(), **fields})
|
|
134
|
+
except Exception:
|
|
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
|
|
114
143
|
registry = approver_registry if approver_registry is not None else ApproverRegistry(None)
|
|
115
144
|
static_approvers = dict(approver_public_keys or {})
|
|
116
145
|
|
|
@@ -166,6 +195,7 @@ def build_issuer_app(
|
|
|
166
195
|
result = issue(policy, body["target_url"], interaction, signing_key=signing_key, key_id=key_id,
|
|
167
196
|
ttl_seconds=ttl_seconds, now=now())
|
|
168
197
|
if result.envelope is None:
|
|
198
|
+
event("refused", principal=who["principal"], tool=body["tool"], reason=result.decision.reason)
|
|
169
199
|
return {"issued": False, "reason": result.decision.reason}
|
|
170
200
|
covered = False
|
|
171
201
|
plan_id = body.get("plan_id")
|
|
@@ -180,6 +210,9 @@ def build_issuer_app(
|
|
|
180
210
|
if plan["plan_id"] == plan_id and _complete(arid_p) and match_step(plan, body["tool"], body["args"]) is not None:
|
|
181
211
|
covered = True
|
|
182
212
|
break
|
|
213
|
+
event("issued", principal=who["principal"], tool=body["tool"], held=bool(result.envelope["requires_approval"]),
|
|
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"])
|
|
183
216
|
if result.envelope["requires_approval"] and not covered:
|
|
184
217
|
arid = result.envelope["approval_request_id"]
|
|
185
218
|
app.state.pending[arid] = result.envelope
|
|
@@ -245,6 +278,7 @@ def build_issuer_app(
|
|
|
245
278
|
approval=approval), signing_key, key_id)
|
|
246
279
|
if held:
|
|
247
280
|
app.state.pending_plans[plan["approval_request_id"]] = plan
|
|
281
|
+
event("plan", principal=who["principal"], steps=len(norm), held=held, plan_id=plan["plan_id"])
|
|
248
282
|
return {"issued": True, "plan": plan, "held": held}
|
|
249
283
|
|
|
250
284
|
def _plan_terms(plan):
|
|
@@ -306,12 +340,15 @@ def build_issuer_app(
|
|
|
306
340
|
return {"accepted": False, "reason": g["reason"]}
|
|
307
341
|
# The receipt's (or plan's) own terms: distinct approvers, from the named set if any.
|
|
308
342
|
terms = approval_terms(env) if env else _plan_terms(plan)
|
|
309
|
-
have = app.state.grants.
|
|
343
|
+
have = list(app.state.grants.get(arid, []))
|
|
310
344
|
if any(h["approver_key_id"] == grant["approver_key_id"] for h in have):
|
|
311
345
|
return {"accepted": False, "reason": REF_APPROVAL_DUPLICATE_APPROVER}
|
|
312
346
|
if terms["approvers"] is not None and grant["approver_key_id"] not in terms["approvers"]:
|
|
313
347
|
return {"accepted": False, "reason": REF_APPROVAL_APPROVER_NOT_ELIGIBLE}
|
|
314
348
|
have.append(grant)
|
|
349
|
+
app.state.grants[arid] = have # written back: the store persists on assignment
|
|
350
|
+
event("granted", approval_request_id=arid, approver=grant["approver_key_id"], collected=len(have), required=terms["required"],
|
|
351
|
+
hold_kind="plan" if plan else "receipt")
|
|
315
352
|
return {"accepted": True, "reason": g["reason"], "collected": len(have), "required": terms["required"],
|
|
316
353
|
"complete": len(have) >= terms["required"]}
|
|
317
354
|
|
|
@@ -319,7 +356,7 @@ def build_issuer_app(
|
|
|
319
356
|
async def decline(request: Request, authorization: Optional[str] = Header(default=None)):
|
|
320
357
|
"""A human's refusal: the hold is dropped, a waiting middleware learns
|
|
321
358
|
at once. No signature is needed to say no; nothing can run on it."""
|
|
322
|
-
authenticate(authorization)
|
|
359
|
+
who = authenticate(authorization)
|
|
323
360
|
try:
|
|
324
361
|
body = await request.json()
|
|
325
362
|
except Exception:
|
|
@@ -333,6 +370,7 @@ def build_issuer_app(
|
|
|
333
370
|
app.state.pending_state.pop(arid, None)
|
|
334
371
|
app.state.grants.pop(arid, None)
|
|
335
372
|
app.state.declined.add(arid)
|
|
373
|
+
event("declined", approval_request_id=arid, by=who["principal"])
|
|
336
374
|
return {"declined": True, "reason": None}
|
|
337
375
|
|
|
338
376
|
@app.get("/grants/{approval_request_id}")
|
|
@@ -369,6 +407,7 @@ def build_issuer_app(
|
|
|
369
407
|
if not isinstance(body, dict) or not valid_public_key_hex(body.get("public_key_hex")):
|
|
370
408
|
raise _refuse(400, REF_ISSUER_BAD_REQUEST)
|
|
371
409
|
result = registry.enroll(who["principal"], body["public_key_hex"], now=now())
|
|
410
|
+
event("enrolled", approver=who["principal"], replaced=result["replaced"])
|
|
372
411
|
return {"enrolled": True, "key_id": who["principal"], "replaced": result["replaced"]}
|
|
373
412
|
|
|
374
413
|
@app.get("/me")
|
|
@@ -381,6 +420,21 @@ def build_issuer_app(
|
|
|
381
420
|
return {"principal": upn, "may_approve": may_enroll(who, role=approver_role, groups=approver_groups) or upn in static_approvers,
|
|
382
421
|
"enrolled_public_key_hex": registry.public_hex().get(upn)}
|
|
383
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
|
+
|
|
429
|
+
@app.get("/health")
|
|
430
|
+
async def health():
|
|
431
|
+
"""Public, for a monitor: the service is up, which policy it runs,
|
|
432
|
+
how much is waiting, and since when. No secrets."""
|
|
433
|
+
return {"ok": True, "policy": policy.pin(), "issuer_key_id": key_id,
|
|
434
|
+
"pending_receipts": len(app.state.pending), "pending_plans": len(app.state.pending_plans),
|
|
435
|
+
"enrolled_approvers": len(registry.public_hex()), "static_approvers": len(static_approvers),
|
|
436
|
+
"entra": bool(console_entra), "persistent": store.path != ":memory:", "started_at": app.state.started_at}
|
|
437
|
+
|
|
384
438
|
@app.get("/approve-config")
|
|
385
439
|
async def approve_config():
|
|
386
440
|
"""Public: how the console signs in. `entra` is null when the site
|
|
@@ -455,6 +509,10 @@ def app_from_env() -> FastAPI:
|
|
|
455
509
|
audience=os.environ.get(ENV_AUDIENCE) or None,
|
|
456
510
|
ttl_seconds=int(os.environ.get(ENV_RECEIPT_TTL, DEFAULT_TTL_SECONDS)),
|
|
457
511
|
on_hold=notifier_from_env(os.environ),
|
|
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),
|
|
515
|
+
log_events=(lambda e: sys.stderr.write(json.dumps(e, sort_keys=True) + "\n")) if os.environ.get("GARGOYLE_LOG_JSON") else None,
|
|
458
516
|
approver_registry=ApproverRegistry(os.environ.get(ENV_APPROVERS_PATH)),
|
|
459
517
|
approver_role=os.environ.get(ENV_APPROVER_ROLE, DEFAULT_APPROVER_ROLE),
|
|
460
518
|
approver_groups=[g.strip() for g in os.environ.get(ENV_APPROVER_GROUPS, "").split(",") if g.strip()] or None,
|
|
@@ -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
|
|