gargoyle-receipts 0.11.0.dev0__tar.gz → 0.13.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.11.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.13.0.dev0}/PKG-INFO +17 -2
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/README.md +16 -1
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/cli.py +33 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/consumer.py +21 -1
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/evidence.py +9 -4
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/execution.py +9 -2
- gargoyle_receipts-0.13.0.dev0/gargoyle/holdstore.py +116 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/issuer_service.py +141 -18
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/mcp_server.py +105 -9
- gargoyle_receipts-0.13.0.dev0/gargoyle/plans.py +209 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/policy.py +1 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/rules.py +6 -2
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/static/approve.html +32 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +17 -2
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +3 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/pyproject.toml +1 -1
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_issuer_service.py +30 -0
- gargoyle_receipts-0.13.0.dev0/tests/test_plans.py +187 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/adapters.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/approval.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/approvers.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/envelope.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/identity.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/published_record_source.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/remote.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/replay_cache.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/revert.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/state.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/static/approve.js +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle/verifier.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_approvers.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_cli.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_console_js.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_entra_identity.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_evidence.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_graph.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_historical.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_mcp_self_issue.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_remote.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_revert.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_rules.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_state.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_threshold.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.13.0.dev0}/tests/test_wire_spec.py +0 -0
{gargoyle_receipts-0.11.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.13.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.13.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.13.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
|
|
@@ -292,6 +292,19 @@ and `gargoyle audit` verifies them later offline.
|
|
|
292
292
|
and through the alternative with OPA in Docker for sites that run a policy
|
|
293
293
|
engine.
|
|
294
294
|
|
|
295
|
+
## Scoped grants
|
|
296
|
+
|
|
297
|
+
One card per held call is right for a mailbox rule and wrong for a long
|
|
298
|
+
task. The assistant can ask for a plan (`gargoyle_plan`): a list of steps,
|
|
299
|
+
each a tool with exact arguments or argument patterns, and a note saying
|
|
300
|
+
why. The issuer judges every step under the policy, signs the plan, and
|
|
301
|
+
holds it as one card; the approver signs once; each later call matching
|
|
302
|
+
an unused step runs without a card of its own until the plan expires.
|
|
303
|
+
Every step is still bound, claimed once, and recorded with the plan and
|
|
304
|
+
step that released it. The rules file decides what may be planned
|
|
305
|
+
(`"plan": false` keeps a tool individual; sends should be), because a
|
|
306
|
+
plan approves scope rather than content.
|
|
307
|
+
|
|
295
308
|
## Microsoft 365 and other targets: adapters
|
|
296
309
|
|
|
297
310
|
What a held call changes is the adapter's business (`gargoyle.adapters`):
|
|
@@ -355,6 +368,8 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
355
368
|
| `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` |
|
|
356
369
|
| 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 |
|
|
357
370
|
| 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
|
+
| 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 |
|
|
372
|
+
| issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
|
|
358
373
|
| `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 |
|
|
359
374
|
| `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 |
|
|
360
375
|
| `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.13.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
|
|
@@ -254,6 +254,19 @@ and `gargoyle audit` verifies them later offline.
|
|
|
254
254
|
and through the alternative with OPA in Docker for sites that run a policy
|
|
255
255
|
engine.
|
|
256
256
|
|
|
257
|
+
## Scoped grants
|
|
258
|
+
|
|
259
|
+
One card per held call is right for a mailbox rule and wrong for a long
|
|
260
|
+
task. The assistant can ask for a plan (`gargoyle_plan`): a list of steps,
|
|
261
|
+
each a tool with exact arguments or argument patterns, and a note saying
|
|
262
|
+
why. The issuer judges every step under the policy, signs the plan, and
|
|
263
|
+
holds it as one card; the approver signs once; each later call matching
|
|
264
|
+
an unused step runs without a card of its own until the plan expires.
|
|
265
|
+
Every step is still bound, claimed once, and recorded with the plan and
|
|
266
|
+
step that released it. The rules file decides what may be planned
|
|
267
|
+
(`"plan": false` keeps a tool individual; sends should be), because a
|
|
268
|
+
plan approves scope rather than content.
|
|
269
|
+
|
|
257
270
|
## Microsoft 365 and other targets: adapters
|
|
258
271
|
|
|
259
272
|
What a held call changes is the adapter's business (`gargoyle.adapters`):
|
|
@@ -317,6 +330,8 @@ cannot attach receipts work unchanged. Configuration is by environment:
|
|
|
317
330
|
| `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` |
|
|
318
331
|
| 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 |
|
|
319
332
|
| 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
|
+
| 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 |
|
|
334
|
+
| issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
|
|
320
335
|
| `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 |
|
|
321
336
|
| `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 |
|
|
322
337
|
| `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,8 @@ 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_LOG_JSON", "1")
|
|
263
294
|
app = app_from_env()
|
|
264
295
|
# The middleware reads the policy pin from a file; write it next to the
|
|
265
296
|
# config so the client-side entry finds it at the configured path.
|
|
@@ -311,6 +342,8 @@ def cmd_audit(args) -> int:
|
|
|
311
342
|
why = (" (%s)" % r["verdict"]) if r["kind"] == "refused" and r["verdict"] else ""
|
|
312
343
|
if r.get("released_by"):
|
|
313
344
|
why += " released by " + ", ".join(r["released_by"])
|
|
345
|
+
if r.get("plan"):
|
|
346
|
+
why += " (plan %s step %d)" % (str(r["plan"].get("plan_id") or "?")[:8], r["plan"].get("step", -1) + 1)
|
|
314
347
|
if r.get("state"):
|
|
315
348
|
# A state-bound call: the target as it was approved, and as the run left it.
|
|
316
349
|
pre, post = (r["state"].get("pre") or "")[:8], (r["state"].get("post") or "")[:8]
|
|
@@ -273,6 +273,7 @@ class Consumer:
|
|
|
273
273
|
*,
|
|
274
274
|
grant: Any = None,
|
|
275
275
|
now: Optional[datetime] = None,
|
|
276
|
+
plan_release: Any = None,
|
|
276
277
|
) -> Verdict:
|
|
277
278
|
"""Decide whether to honor `envelope` for `call` ({"tool", "args",
|
|
278
279
|
"principal"}, the principal being the one this consumer
|
|
@@ -321,7 +322,26 @@ class Consumer:
|
|
|
321
322
|
# inside the signed region, so they are the issuer's word, not the
|
|
322
323
|
# caller's. The grant is checked and claimed before the receipt is
|
|
323
324
|
# claimed, so a refused grant leaves the receipt unspent.
|
|
324
|
-
if envelope["requires_approval"]:
|
|
325
|
+
if envelope["requires_approval"] and plan_release is not None:
|
|
326
|
+
# A scoped grant (gargoyle.plans): the plan's step is claimed once
|
|
327
|
+
# in place of a per-call grant id.
|
|
328
|
+
from gargoyle.plans import verify_plan_release
|
|
329
|
+
approver_keys = self._approver_keys(key_record_view, envelope.get("issuer_key_id"), now)
|
|
330
|
+
if not approver_keys:
|
|
331
|
+
return Verdict(False, REF_TARGET_NO_APPROVER_TRUST)
|
|
332
|
+
r = verify_plan_release(plan_release, envelope, call, issuer_public_keys=pinned_public_keys,
|
|
333
|
+
approver_public_keys=approver_keys, now=now, clock_skew=self.clock_skew)
|
|
334
|
+
if not r["accepted"]:
|
|
335
|
+
return Verdict(False, r["reason"])
|
|
336
|
+
plan = plan_release["plan"]
|
|
337
|
+
try:
|
|
338
|
+
plan_exp = datetime.fromisoformat(plan["not_after"]) + self.clock_skew
|
|
339
|
+
except (ValueError, TypeError, KeyError):
|
|
340
|
+
plan_exp = None
|
|
341
|
+
if not self.replay_cache.check_and_claim("plan:%s:%d" % (plan["plan_id"], r["step"]), plan_exp, now=now):
|
|
342
|
+
from gargoyle.plans import REF_PLAN_STEP_CONSUMED
|
|
343
|
+
return Verdict(False, REF_PLAN_STEP_CONSUMED)
|
|
344
|
+
elif envelope["requires_approval"]:
|
|
325
345
|
grants = as_grant_list(grant)
|
|
326
346
|
if not grants:
|
|
327
347
|
return Verdict(False, REF_TARGET_APPROVAL_REQUIRED)
|
|
@@ -192,7 +192,7 @@ def verify_log(
|
|
|
192
192
|
rc = rc if isinstance(rc, dict) else {}
|
|
193
193
|
report.append({"seq": None, "kind": None, "tool": rc.get("tool"), "principal": rc.get("principal"),
|
|
194
194
|
"accepted": False, "reason": REF_EVIDENCE_UNCHAINED if legacy else REF_EVIDENCE_MALFORMED,
|
|
195
|
-
"verdict": None, "released_by": [], "state": None, "client": None})
|
|
195
|
+
"verdict": None, "released_by": [], "state": None, "client": None, "plan": None})
|
|
196
196
|
all_ok = False
|
|
197
197
|
continue
|
|
198
198
|
e = raw
|
|
@@ -218,14 +218,19 @@ def verify_log(
|
|
|
218
218
|
now=_parse_at(e["at"]), historical=True)
|
|
219
219
|
ok, reason = p["accepted"], (ACCEPT_ENTRY_VERIFIED if p["accepted"] else p["reason"])
|
|
220
220
|
counts[e["kind"]] += 1
|
|
221
|
-
|
|
222
|
-
|
|
221
|
+
plan_ref = None
|
|
222
|
+
grant_set = e["grant"]
|
|
223
|
+
if isinstance(grant_set, dict) and "plan" in grant_set and "step" in grant_set:
|
|
224
|
+
plan_ref = {"plan_id": (grant_set["plan"] or {}).get("plan_id"), "step": grant_set["step"]}
|
|
225
|
+
grant_set = grant_set.get("grants")
|
|
226
|
+
released_by = [g.get("approver_key_id") for g in (grant_set if isinstance(grant_set, list) else [grant_set])
|
|
227
|
+
if isinstance(g, dict)] if grant_set is not None else []
|
|
223
228
|
ex = e["execution"] if isinstance(e["execution"], dict) else {}
|
|
224
229
|
state = ({"pre": ex.get("pre_state_sha256"), "post": ex.get("post_state_sha256")}
|
|
225
230
|
if ex.get("pre_state_sha256") or ex.get("post_state_sha256") else None)
|
|
226
231
|
report.append({"seq": e["seq"], "kind": e["kind"], "tool": e["tool"], "principal": e["principal"],
|
|
227
232
|
"accepted": ok, "reason": reason, "verdict": e["reason"], "released_by": released_by,
|
|
228
|
-
"state": state, "client": e.get("client")})
|
|
233
|
+
"state": state, "client": e.get("client"), "plan": plan_ref})
|
|
229
234
|
all_ok = all_ok and ok
|
|
230
235
|
# The chain continues from what the file says, so one bad entry is
|
|
231
236
|
# reported once rather than cascading into every later one.
|
|
@@ -218,10 +218,17 @@ def verify_chain(
|
|
|
218
218
|
return {"accepted": False, "reason": provenance["reason"], "steps": steps}
|
|
219
219
|
|
|
220
220
|
if envelope["requires_approval"]:
|
|
221
|
-
|
|
221
|
+
from gargoyle.plans import is_plan_release, verify_plan_release
|
|
222
|
+
if is_plan_release(grant):
|
|
223
|
+
# Released by a scoped grant: the plan, its grants and the step,
|
|
224
|
+
# re-matched against the arguments the release recorded.
|
|
225
|
+
g = verify_plan_release(grant, envelope, {}, issuer_public_keys=issuer_public_keys,
|
|
226
|
+
approver_public_keys=approver_public_keys or {}, now=executed_at)
|
|
227
|
+
elif not as_grant_list(grant):
|
|
222
228
|
steps.append(("approval", REF_EXEC_GRANT_MISSING))
|
|
223
229
|
return {"accepted": False, "reason": REF_EXEC_GRANT_MISSING, "steps": steps}
|
|
224
|
-
|
|
230
|
+
else:
|
|
231
|
+
g = verify_grants(grant, envelope=envelope, approver_public_keys=approver_public_keys or {}, now=executed_at)
|
|
225
232
|
steps.append(("approval", g["reason"]))
|
|
226
233
|
if not g["accepted"]:
|
|
227
234
|
return {"accepted": False, "reason": g["reason"], "steps": steps}
|
|
@@ -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,7 +40,8 @@ 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
|
-
|
|
43
|
+
import sys
|
|
44
|
+
from datetime import datetime, timedelta, timezone
|
|
44
45
|
from pathlib import Path
|
|
45
46
|
from typing import Any, Callable, Dict, Optional
|
|
46
47
|
|
|
@@ -53,6 +54,9 @@ 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.plans import (REF_PLAN_MALFORMED, REF_PLAN_STEP_NOT_PLANNABLE, REF_PLAN_STEP_REFUSED, build_plan,
|
|
59
|
+
sign_plan, valid_step)
|
|
56
60
|
from gargoyle.notify import notifier_from_env
|
|
57
61
|
from gargoyle.approvers import (DEFAULT_APPROVER_ROLE, ENV_APPROVER_GROUPS, ENV_APPROVER_ROLE, ENV_APPROVERS_PATH,
|
|
58
62
|
ApproverRegistry, may_enroll, valid_public_key_hex)
|
|
@@ -98,16 +102,33 @@ def build_issuer_app(
|
|
|
98
102
|
approver_role: str = DEFAULT_APPROVER_ROLE,
|
|
99
103
|
approver_groups: Optional[list] = None,
|
|
100
104
|
console_entra: Optional[Dict[str, str]] = None,
|
|
105
|
+
hold_store: Optional[HoldStore] = None,
|
|
106
|
+
log_events: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
101
107
|
) -> FastAPI:
|
|
102
108
|
"""`on_hold(envelope, args)` is called once for every new hold (gargoyle.notify
|
|
103
109
|
posts it to a webhook); it is notice, not admission, and its failure
|
|
104
110
|
never affects the hold."""
|
|
105
111
|
app = FastAPI(title="Gargoyle issuing service")
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
app.state.
|
|
110
|
-
app.state.
|
|
112
|
+
# Everything a human may be in the middle of acting on lives in the hold
|
|
113
|
+
# store (gargoyle.holdstore): SQLite on disk in a deployment, memory in tests.
|
|
114
|
+
store = hold_store if hold_store is not None else HoldStore(None)
|
|
115
|
+
app.state.store = store
|
|
116
|
+
app.state.pending = store.dict("pending") # approval_request_id -> envelope
|
|
117
|
+
app.state.pending_args = store.dict("pending_args") # approval_request_id -> the call's arguments
|
|
118
|
+
app.state.pending_state = store.dict("pending_state") # approval_request_id -> the target's state when held
|
|
119
|
+
app.state.grants = store.dict("grants") # approval_request_id -> [grant, ...]
|
|
120
|
+
app.state.declined = store.set("declined") # approval_request_ids a human refused
|
|
121
|
+
app.state.pending_plans = store.dict("pending_plans") # approval_request_id -> signed plan (gargoyle.plans)
|
|
122
|
+
app.state.started_at = now().isoformat()
|
|
123
|
+
|
|
124
|
+
def event(kind: str, **fields: Any) -> None:
|
|
125
|
+
"""One structured line per decision the service makes, for the
|
|
126
|
+
operator's log; never the admission path."""
|
|
127
|
+
if log_events is not None:
|
|
128
|
+
try:
|
|
129
|
+
log_events({"event": kind, "at": now().isoformat(), **fields})
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
111
132
|
registry = approver_registry if approver_registry is not None else ApproverRegistry(None)
|
|
112
133
|
static_approvers = dict(approver_public_keys or {})
|
|
113
134
|
|
|
@@ -163,8 +184,24 @@ def build_issuer_app(
|
|
|
163
184
|
result = issue(policy, body["target_url"], interaction, signing_key=signing_key, key_id=key_id,
|
|
164
185
|
ttl_seconds=ttl_seconds, now=now())
|
|
165
186
|
if result.envelope is None:
|
|
187
|
+
event("refused", principal=who["principal"], tool=body["tool"], reason=result.decision.reason)
|
|
166
188
|
return {"issued": False, "reason": result.decision.reason}
|
|
167
|
-
|
|
189
|
+
covered = False
|
|
190
|
+
plan_id = body.get("plan_id")
|
|
191
|
+
if result.envelope["requires_approval"] and isinstance(plan_id, str):
|
|
192
|
+
# The executor says an approved plan covers this call. No card then:
|
|
193
|
+
# the executor enforces the plan (step single use, grants, expiry)
|
|
194
|
+
# and the console should not show a hold nobody needs to act on.
|
|
195
|
+
# A false claim costs nothing here: without a valid release the
|
|
196
|
+
# executor refuses the call.
|
|
197
|
+
from gargoyle.plans import match_step
|
|
198
|
+
for arid_p, plan in app.state.pending_plans.items():
|
|
199
|
+
if plan["plan_id"] == plan_id and _complete(arid_p) and match_step(plan, body["tool"], body["args"]) is not None:
|
|
200
|
+
covered = True
|
|
201
|
+
break
|
|
202
|
+
event("issued", principal=who["principal"], tool=body["tool"], held=bool(result.envelope["requires_approval"]),
|
|
203
|
+
covered=covered, decision_id=result.envelope.get("decision_id"))
|
|
204
|
+
if result.envelope["requires_approval"] and not covered:
|
|
168
205
|
arid = result.envelope["approval_request_id"]
|
|
169
206
|
app.state.pending[arid] = result.envelope
|
|
170
207
|
app.state.pending_args[arid] = body["args"]
|
|
@@ -178,11 +215,76 @@ def build_issuer_app(
|
|
|
178
215
|
sys.stderr.write("gargoyle: on_hold failed: %s\n" % exc)
|
|
179
216
|
return {"issued": True, "envelope": result.envelope}
|
|
180
217
|
|
|
218
|
+
@app.post("/plans")
|
|
219
|
+
async def post_plan(request: Request, authorization: Optional[str] = Header(default=None)):
|
|
220
|
+
"""A scoped grant request: every step judged by the policy; the plan
|
|
221
|
+
signed and held when any step would be. Steps with exact arguments
|
|
222
|
+
are judged on them; a pattern step is judged with its patterns as the
|
|
223
|
+
argument values, so a broad pattern fails a narrow rule."""
|
|
224
|
+
who = authenticate(authorization)
|
|
225
|
+
try:
|
|
226
|
+
body = await request.json()
|
|
227
|
+
except Exception:
|
|
228
|
+
raise _refuse(400, REF_ISSUER_BAD_REQUEST)
|
|
229
|
+
steps = body.get("steps") if isinstance(body, dict) else None
|
|
230
|
+
if (not isinstance(body, dict) or not isinstance(body.get("target_url"), str) or not isinstance(steps, list)
|
|
231
|
+
or not steps or len(steps) > 200 or not all(valid_step(s) or ("args" in s and isinstance(s.get("tool"), str)) for s in steps)
|
|
232
|
+
or not isinstance(body.get("note", ""), str)):
|
|
233
|
+
raise _refuse(400, REF_ISSUER_BAD_REQUEST)
|
|
234
|
+
from gargoyle.envelope import args_sha256
|
|
235
|
+
norm, held, required, approvers = [], False, 1, set()
|
|
236
|
+
for s in steps:
|
|
237
|
+
if "args" in s:
|
|
238
|
+
judged_args, step = s["args"], {"tool": s["tool"], "args_sha256": args_sha256(s["args"])}
|
|
239
|
+
elif "args_sha256" in s:
|
|
240
|
+
return {"issued": False, "reason": REF_PLAN_MALFORMED} # a hash alone cannot be judged
|
|
241
|
+
else:
|
|
242
|
+
judged_args, step = dict(s["match"]), {"tool": s["tool"], "match": dict(s["match"])}
|
|
243
|
+
interaction = {"tool": s["tool"], "args": judged_args, "principal": who["principal"],
|
|
244
|
+
"groups": who.get("groups", []), "roles": who.get("roles", [])}
|
|
245
|
+
try:
|
|
246
|
+
d = policy.decide(body["target_url"], interaction)
|
|
247
|
+
except Exception:
|
|
248
|
+
return {"issued": False, "reason": REF_PLAN_STEP_REFUSED}
|
|
249
|
+
if not d.eligible:
|
|
250
|
+
return {"issued": False, "reason": REF_PLAN_STEP_REFUSED, "step": len(norm)}
|
|
251
|
+
if d.requires_approval:
|
|
252
|
+
if not d.plannable:
|
|
253
|
+
return {"issued": False, "reason": REF_PLAN_STEP_NOT_PLANNABLE, "step": len(norm)}
|
|
254
|
+
held = True
|
|
255
|
+
required = max(required, d.approvals_required)
|
|
256
|
+
if d.approvers:
|
|
257
|
+
approvers.update(d.approvers)
|
|
258
|
+
norm.append(step)
|
|
259
|
+
import uuid
|
|
260
|
+
approval = None
|
|
261
|
+
if required > 1 or approvers:
|
|
262
|
+
approval = {"required": required, **({"approvers": sorted(approvers)} if approvers else {})}
|
|
263
|
+
plan = sign_plan(build_plan(plan_id=str(uuid.uuid4()), approval_request_id=str(uuid.uuid4()), target_url=body["target_url"],
|
|
264
|
+
principal=who["principal"], policy_pin=policy.pin(), steps=norm,
|
|
265
|
+
not_after=now() + timedelta(seconds=min(ttl_seconds * 6, 3600)), note=body.get("note", ""),
|
|
266
|
+
approval=approval), signing_key, key_id)
|
|
267
|
+
if held:
|
|
268
|
+
app.state.pending_plans[plan["approval_request_id"]] = plan
|
|
269
|
+
event("plan", principal=who["principal"], steps=len(norm), held=held, plan_id=plan["plan_id"])
|
|
270
|
+
return {"issued": True, "plan": plan, "held": held}
|
|
271
|
+
|
|
272
|
+
def _plan_terms(plan):
|
|
273
|
+
return approval_terms({"approval": plan["approval"]} if "approval" in plan else {})
|
|
274
|
+
|
|
181
275
|
@app.get("/pending")
|
|
182
276
|
async def pending(authorization: Optional[str] = Header(default=None)):
|
|
183
277
|
authenticate(authorization)
|
|
184
278
|
current = now()
|
|
185
279
|
live = {}
|
|
280
|
+
for arid, plan in list(app.state.pending_plans.items()):
|
|
281
|
+
if datetime.fromisoformat(plan["not_after"]) <= current:
|
|
282
|
+
del app.state.pending_plans[arid]
|
|
283
|
+
app.state.grants.pop(arid, None)
|
|
284
|
+
elif len(app.state.grants.get(arid, [])) < _plan_terms(plan)["required"]:
|
|
285
|
+
have = app.state.grants.get(arid, [])
|
|
286
|
+
live[arid] = {"plan": plan, "collected": len(have), "required": _plan_terms(plan)["required"],
|
|
287
|
+
"approvers": [g["approver_key_id"] for g in have]}
|
|
186
288
|
for arid, env in list(app.state.pending.items()):
|
|
187
289
|
if datetime.fromisoformat(env["not_after"]) <= current:
|
|
188
290
|
del app.state.pending[arid] # an expired hold can never be released
|
|
@@ -199,7 +301,10 @@ def build_issuer_app(
|
|
|
199
301
|
|
|
200
302
|
def _complete(arid: str) -> bool:
|
|
201
303
|
env = app.state.pending.get(arid)
|
|
202
|
-
|
|
304
|
+
if env is None:
|
|
305
|
+
plan = app.state.pending_plans.get(arid)
|
|
306
|
+
return plan is not None and len(app.state.grants.get(arid, [])) >= _plan_terms(plan)["required"]
|
|
307
|
+
return len(app.state.grants.get(arid, [])) >= approval_terms(env)["required"]
|
|
203
308
|
|
|
204
309
|
@app.post("/grants")
|
|
205
310
|
async def post_grant(request: Request, authorization: Optional[str] = Header(default=None)):
|
|
@@ -210,24 +315,28 @@ def build_issuer_app(
|
|
|
210
315
|
raise _refuse(400, REF_ISSUER_BAD_REQUEST)
|
|
211
316
|
arid = grant.get("approval_request_id") if isinstance(grant, dict) else None
|
|
212
317
|
env = app.state.pending.get(arid) if isinstance(arid, str) else None
|
|
213
|
-
if
|
|
318
|
+
plan = app.state.pending_plans.get(arid) if isinstance(arid, str) else None
|
|
319
|
+
if env is None and plan is None:
|
|
214
320
|
return {"accepted": False, "reason": REF_ISSUER_UNKNOWN_HOLD}
|
|
215
321
|
trusted = approver_keys()
|
|
216
322
|
if not trusted:
|
|
217
323
|
return {"accepted": False, "reason": REF_ISSUER_NO_APPROVER_TRUST}
|
|
218
|
-
g = verify_grant(grant, expected_decision_sha256=env["decision_sha256"],
|
|
324
|
+
g = verify_grant(grant, expected_decision_sha256=env["decision_sha256"] if env else plan["plan_sha256"],
|
|
219
325
|
expected_approval_request_id=arid, approver_public_keys=trusted,
|
|
220
326
|
gate_key_id=key_id, now=now())
|
|
221
327
|
if not g["accepted"]:
|
|
222
328
|
return {"accepted": False, "reason": g["reason"]}
|
|
223
|
-
# The receipt's own terms: distinct approvers, from the named set if any.
|
|
224
|
-
terms = approval_terms(env)
|
|
225
|
-
have = app.state.grants.
|
|
329
|
+
# The receipt's (or plan's) own terms: distinct approvers, from the named set if any.
|
|
330
|
+
terms = approval_terms(env) if env else _plan_terms(plan)
|
|
331
|
+
have = list(app.state.grants.get(arid, []))
|
|
226
332
|
if any(h["approver_key_id"] == grant["approver_key_id"] for h in have):
|
|
227
333
|
return {"accepted": False, "reason": REF_APPROVAL_DUPLICATE_APPROVER}
|
|
228
334
|
if terms["approvers"] is not None and grant["approver_key_id"] not in terms["approvers"]:
|
|
229
335
|
return {"accepted": False, "reason": REF_APPROVAL_APPROVER_NOT_ELIGIBLE}
|
|
230
336
|
have.append(grant)
|
|
337
|
+
app.state.grants[arid] = have # written back: the store persists on assignment
|
|
338
|
+
event("granted", approval_request_id=arid, approver=grant["approver_key_id"], collected=len(have), required=terms["required"],
|
|
339
|
+
hold_kind="plan" if plan else "receipt")
|
|
231
340
|
return {"accepted": True, "reason": g["reason"], "collected": len(have), "required": terms["required"],
|
|
232
341
|
"complete": len(have) >= terms["required"]}
|
|
233
342
|
|
|
@@ -235,31 +344,33 @@ def build_issuer_app(
|
|
|
235
344
|
async def decline(request: Request, authorization: Optional[str] = Header(default=None)):
|
|
236
345
|
"""A human's refusal: the hold is dropped, a waiting middleware learns
|
|
237
346
|
at once. No signature is needed to say no; nothing can run on it."""
|
|
238
|
-
authenticate(authorization)
|
|
347
|
+
who = authenticate(authorization)
|
|
239
348
|
try:
|
|
240
349
|
body = await request.json()
|
|
241
350
|
except Exception:
|
|
242
351
|
raise _refuse(400, REF_ISSUER_BAD_REQUEST)
|
|
243
352
|
arid = body.get("approval_request_id") if isinstance(body, dict) else None
|
|
244
|
-
if not isinstance(arid, str) or arid not in app.state.pending:
|
|
353
|
+
if not isinstance(arid, str) or (arid not in app.state.pending and arid not in app.state.pending_plans):
|
|
245
354
|
return {"declined": False, "reason": REF_ISSUER_UNKNOWN_HOLD}
|
|
246
|
-
|
|
355
|
+
app.state.pending_plans.pop(arid, None)
|
|
356
|
+
app.state.pending.pop(arid, None)
|
|
247
357
|
app.state.pending_args.pop(arid, None)
|
|
248
358
|
app.state.pending_state.pop(arid, None)
|
|
249
359
|
app.state.grants.pop(arid, None)
|
|
250
360
|
app.state.declined.add(arid)
|
|
361
|
+
event("declined", approval_request_id=arid, by=who["principal"])
|
|
251
362
|
return {"declined": True, "reason": None}
|
|
252
363
|
|
|
253
364
|
@app.get("/grants/{approval_request_id}")
|
|
254
365
|
async def get_grant(approval_request_id: str, authorization: Optional[str] = Header(default=None)):
|
|
255
366
|
authenticate(authorization)
|
|
256
|
-
env = app.state.pending.get(approval_request_id)
|
|
367
|
+
env = app.state.pending.get(approval_request_id) or app.state.pending_plans.get(approval_request_id)
|
|
257
368
|
have = app.state.grants.get(approval_request_id, [])
|
|
258
369
|
complete = _complete(approval_request_id)
|
|
259
370
|
return {"pending": env is not None,
|
|
260
371
|
"grant": have[0] if complete and len(have) == 1 else None,
|
|
261
372
|
"grants": list(have) if complete else [],
|
|
262
|
-
"collected": len(have), "required": approval_terms(env)["required"] if env else None,
|
|
373
|
+
"collected": len(have), "required": (_plan_terms(env) if env and "plan_version" in env else approval_terms(env))["required"] if env else None,
|
|
263
374
|
"declined": approval_request_id in app.state.declined}
|
|
264
375
|
|
|
265
376
|
@app.get("/approvers")
|
|
@@ -284,6 +395,7 @@ def build_issuer_app(
|
|
|
284
395
|
if not isinstance(body, dict) or not valid_public_key_hex(body.get("public_key_hex")):
|
|
285
396
|
raise _refuse(400, REF_ISSUER_BAD_REQUEST)
|
|
286
397
|
result = registry.enroll(who["principal"], body["public_key_hex"], now=now())
|
|
398
|
+
event("enrolled", approver=who["principal"], replaced=result["replaced"])
|
|
287
399
|
return {"enrolled": True, "key_id": who["principal"], "replaced": result["replaced"]}
|
|
288
400
|
|
|
289
401
|
@app.get("/me")
|
|
@@ -296,6 +408,15 @@ def build_issuer_app(
|
|
|
296
408
|
return {"principal": upn, "may_approve": may_enroll(who, role=approver_role, groups=approver_groups) or upn in static_approvers,
|
|
297
409
|
"enrolled_public_key_hex": registry.public_hex().get(upn)}
|
|
298
410
|
|
|
411
|
+
@app.get("/health")
|
|
412
|
+
async def health():
|
|
413
|
+
"""Public, for a monitor: the service is up, which policy it runs,
|
|
414
|
+
how much is waiting, and since when. No secrets."""
|
|
415
|
+
return {"ok": True, "policy": policy.pin(), "issuer_key_id": key_id,
|
|
416
|
+
"pending_receipts": len(app.state.pending), "pending_plans": len(app.state.pending_plans),
|
|
417
|
+
"enrolled_approvers": len(registry.public_hex()), "static_approvers": len(static_approvers),
|
|
418
|
+
"entra": bool(console_entra), "persistent": store.path != ":memory:", "started_at": app.state.started_at}
|
|
419
|
+
|
|
299
420
|
@app.get("/approve-config")
|
|
300
421
|
async def approve_config():
|
|
301
422
|
"""Public: how the console signs in. `entra` is null when the site
|
|
@@ -370,6 +491,8 @@ def app_from_env() -> FastAPI:
|
|
|
370
491
|
audience=os.environ.get(ENV_AUDIENCE) or None,
|
|
371
492
|
ttl_seconds=int(os.environ.get(ENV_RECEIPT_TTL, DEFAULT_TTL_SECONDS)),
|
|
372
493
|
on_hold=notifier_from_env(os.environ),
|
|
494
|
+
hold_store=HoldStore(os.environ.get(ENV_HOLDS_PATH) or None),
|
|
495
|
+
log_events=(lambda e: sys.stderr.write(json.dumps(e, sort_keys=True) + "\n")) if os.environ.get("GARGOYLE_LOG_JSON") else None,
|
|
373
496
|
approver_registry=ApproverRegistry(os.environ.get(ENV_APPROVERS_PATH)),
|
|
374
497
|
approver_role=os.environ.get(ENV_APPROVER_ROLE, DEFAULT_APPROVER_ROLE),
|
|
375
498
|
approver_groups=[g.strip() for g in os.environ.get(ENV_APPROVER_GROUPS, "").split(",") if g.strip()] or None,
|