gargoyle-receipts 0.11.0.dev0__tar.gz → 0.12.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.12.0.dev0}/PKG-INFO +15 -2
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/README.md +14 -1
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/cli.py +2 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/consumer.py +21 -1
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/evidence.py +9 -4
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/execution.py +9 -2
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/issuer_service.py +96 -11
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/mcp_server.py +105 -9
- gargoyle_receipts-0.12.0.dev0/gargoyle/plans.py +209 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/policy.py +1 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/rules.py +6 -2
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/static/approve.html +32 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +15 -2
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +2 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/pyproject.toml +1 -1
- gargoyle_receipts-0.12.0.dev0/tests/test_plans.py +187 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/LICENSE +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/NOTICE +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/__init__.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/adapters.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/approval.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/approver_cli.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/approver_trust.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/approvers.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/entra_login.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/envelope.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/fs_server.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/identity.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/issuer.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/jws.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/key_record_source.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/notify.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/opa.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/pending_store.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/published_record_source.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/remote.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/replay_cache.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/revert.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/state.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/static/approve.js +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/verifier.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/setup.cfg +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_annotations.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_approver_cli.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_approvers.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_canonical.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_cli.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_console_js.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_consumer.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_entra_identity.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_entra_login.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_evidence.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_execution.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_fs_server.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_graph.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_historical.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_identity.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_issuer_service.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_jws.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_mcp_proxy.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_mcp_self_issue.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_mcp_server.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_notify.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_opa.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_policy.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_published_record.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_remote.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_revert.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_rules.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_state.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_stdio_encoding.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_threshold.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_typescript_conformance.py +0 -0
- {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_wire_spec.py +0 -0
{gargoyle_receipts-0.11.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.12.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.12.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.12.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`):
|
|
@@ -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.12.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`):
|
|
@@ -311,6 +311,8 @@ def cmd_audit(args) -> int:
|
|
|
311
311
|
why = (" (%s)" % r["verdict"]) if r["kind"] == "refused" and r["verdict"] else ""
|
|
312
312
|
if r.get("released_by"):
|
|
313
313
|
why += " released by " + ", ".join(r["released_by"])
|
|
314
|
+
if r.get("plan"):
|
|
315
|
+
why += " (plan %s step %d)" % (str(r["plan"].get("plan_id") or "?")[:8], r["plan"].get("step", -1) + 1)
|
|
314
316
|
if r.get("state"):
|
|
315
317
|
# A state-bound call: the target as it was approved, and as the run left it.
|
|
316
318
|
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}
|
|
@@ -40,7 +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
|
-
from datetime import datetime, timezone
|
|
43
|
+
from datetime import datetime, timedelta, timezone
|
|
44
44
|
from pathlib import Path
|
|
45
45
|
from typing import Any, Callable, Dict, Optional
|
|
46
46
|
|
|
@@ -53,6 +53,8 @@ from gargoyle.approval import (REF_APPROVAL_APPROVER_NOT_ELIGIBLE, REF_APPROVAL_
|
|
|
53
53
|
verify_grant)
|
|
54
54
|
from gargoyle.envelope import approval_terms
|
|
55
55
|
from gargoyle.state import state_sha256, valid_description
|
|
56
|
+
from gargoyle.plans import (REF_PLAN_MALFORMED, REF_PLAN_STEP_NOT_PLANNABLE, REF_PLAN_STEP_REFUSED, build_plan,
|
|
57
|
+
sign_plan, valid_step)
|
|
56
58
|
from gargoyle.notify import notifier_from_env
|
|
57
59
|
from gargoyle.approvers import (DEFAULT_APPROVER_ROLE, ENV_APPROVER_GROUPS, ENV_APPROVER_ROLE, ENV_APPROVERS_PATH,
|
|
58
60
|
ApproverRegistry, may_enroll, valid_public_key_hex)
|
|
@@ -108,6 +110,7 @@ def build_issuer_app(
|
|
|
108
110
|
app.state.pending_state: Dict[str, Any] = {} # approval_request_id -> the target's state when held
|
|
109
111
|
app.state.grants: Dict[str, Dict[str, Any]] = {} # approval_request_id -> grant
|
|
110
112
|
app.state.declined: set = set() # approval_request_ids a human refused
|
|
113
|
+
app.state.pending_plans: Dict[str, Dict[str, Any]] = {} # approval_request_id -> signed plan (gargoyle.plans)
|
|
111
114
|
registry = approver_registry if approver_registry is not None else ApproverRegistry(None)
|
|
112
115
|
static_approvers = dict(approver_public_keys or {})
|
|
113
116
|
|
|
@@ -164,7 +167,20 @@ def build_issuer_app(
|
|
|
164
167
|
ttl_seconds=ttl_seconds, now=now())
|
|
165
168
|
if result.envelope is None:
|
|
166
169
|
return {"issued": False, "reason": result.decision.reason}
|
|
167
|
-
|
|
170
|
+
covered = False
|
|
171
|
+
plan_id = body.get("plan_id")
|
|
172
|
+
if result.envelope["requires_approval"] and isinstance(plan_id, str):
|
|
173
|
+
# The executor says an approved plan covers this call. No card then:
|
|
174
|
+
# the executor enforces the plan (step single use, grants, expiry)
|
|
175
|
+
# and the console should not show a hold nobody needs to act on.
|
|
176
|
+
# A false claim costs nothing here: without a valid release the
|
|
177
|
+
# executor refuses the call.
|
|
178
|
+
from gargoyle.plans import match_step
|
|
179
|
+
for arid_p, plan in app.state.pending_plans.items():
|
|
180
|
+
if plan["plan_id"] == plan_id and _complete(arid_p) and match_step(plan, body["tool"], body["args"]) is not None:
|
|
181
|
+
covered = True
|
|
182
|
+
break
|
|
183
|
+
if result.envelope["requires_approval"] and not covered:
|
|
168
184
|
arid = result.envelope["approval_request_id"]
|
|
169
185
|
app.state.pending[arid] = result.envelope
|
|
170
186
|
app.state.pending_args[arid] = body["args"]
|
|
@@ -178,11 +194,75 @@ def build_issuer_app(
|
|
|
178
194
|
sys.stderr.write("gargoyle: on_hold failed: %s\n" % exc)
|
|
179
195
|
return {"issued": True, "envelope": result.envelope}
|
|
180
196
|
|
|
197
|
+
@app.post("/plans")
|
|
198
|
+
async def post_plan(request: Request, authorization: Optional[str] = Header(default=None)):
|
|
199
|
+
"""A scoped grant request: every step judged by the policy; the plan
|
|
200
|
+
signed and held when any step would be. Steps with exact arguments
|
|
201
|
+
are judged on them; a pattern step is judged with its patterns as the
|
|
202
|
+
argument values, so a broad pattern fails a narrow rule."""
|
|
203
|
+
who = authenticate(authorization)
|
|
204
|
+
try:
|
|
205
|
+
body = await request.json()
|
|
206
|
+
except Exception:
|
|
207
|
+
raise _refuse(400, REF_ISSUER_BAD_REQUEST)
|
|
208
|
+
steps = body.get("steps") if isinstance(body, dict) else None
|
|
209
|
+
if (not isinstance(body, dict) or not isinstance(body.get("target_url"), str) or not isinstance(steps, list)
|
|
210
|
+
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)
|
|
211
|
+
or not isinstance(body.get("note", ""), str)):
|
|
212
|
+
raise _refuse(400, REF_ISSUER_BAD_REQUEST)
|
|
213
|
+
from gargoyle.envelope import args_sha256
|
|
214
|
+
norm, held, required, approvers = [], False, 1, set()
|
|
215
|
+
for s in steps:
|
|
216
|
+
if "args" in s:
|
|
217
|
+
judged_args, step = s["args"], {"tool": s["tool"], "args_sha256": args_sha256(s["args"])}
|
|
218
|
+
elif "args_sha256" in s:
|
|
219
|
+
return {"issued": False, "reason": REF_PLAN_MALFORMED} # a hash alone cannot be judged
|
|
220
|
+
else:
|
|
221
|
+
judged_args, step = dict(s["match"]), {"tool": s["tool"], "match": dict(s["match"])}
|
|
222
|
+
interaction = {"tool": s["tool"], "args": judged_args, "principal": who["principal"],
|
|
223
|
+
"groups": who.get("groups", []), "roles": who.get("roles", [])}
|
|
224
|
+
try:
|
|
225
|
+
d = policy.decide(body["target_url"], interaction)
|
|
226
|
+
except Exception:
|
|
227
|
+
return {"issued": False, "reason": REF_PLAN_STEP_REFUSED}
|
|
228
|
+
if not d.eligible:
|
|
229
|
+
return {"issued": False, "reason": REF_PLAN_STEP_REFUSED, "step": len(norm)}
|
|
230
|
+
if d.requires_approval:
|
|
231
|
+
if not d.plannable:
|
|
232
|
+
return {"issued": False, "reason": REF_PLAN_STEP_NOT_PLANNABLE, "step": len(norm)}
|
|
233
|
+
held = True
|
|
234
|
+
required = max(required, d.approvals_required)
|
|
235
|
+
if d.approvers:
|
|
236
|
+
approvers.update(d.approvers)
|
|
237
|
+
norm.append(step)
|
|
238
|
+
import uuid
|
|
239
|
+
approval = None
|
|
240
|
+
if required > 1 or approvers:
|
|
241
|
+
approval = {"required": required, **({"approvers": sorted(approvers)} if approvers else {})}
|
|
242
|
+
plan = sign_plan(build_plan(plan_id=str(uuid.uuid4()), approval_request_id=str(uuid.uuid4()), target_url=body["target_url"],
|
|
243
|
+
principal=who["principal"], policy_pin=policy.pin(), steps=norm,
|
|
244
|
+
not_after=now() + timedelta(seconds=min(ttl_seconds * 6, 3600)), note=body.get("note", ""),
|
|
245
|
+
approval=approval), signing_key, key_id)
|
|
246
|
+
if held:
|
|
247
|
+
app.state.pending_plans[plan["approval_request_id"]] = plan
|
|
248
|
+
return {"issued": True, "plan": plan, "held": held}
|
|
249
|
+
|
|
250
|
+
def _plan_terms(plan):
|
|
251
|
+
return approval_terms({"approval": plan["approval"]} if "approval" in plan else {})
|
|
252
|
+
|
|
181
253
|
@app.get("/pending")
|
|
182
254
|
async def pending(authorization: Optional[str] = Header(default=None)):
|
|
183
255
|
authenticate(authorization)
|
|
184
256
|
current = now()
|
|
185
257
|
live = {}
|
|
258
|
+
for arid, plan in list(app.state.pending_plans.items()):
|
|
259
|
+
if datetime.fromisoformat(plan["not_after"]) <= current:
|
|
260
|
+
del app.state.pending_plans[arid]
|
|
261
|
+
app.state.grants.pop(arid, None)
|
|
262
|
+
elif len(app.state.grants.get(arid, [])) < _plan_terms(plan)["required"]:
|
|
263
|
+
have = app.state.grants.get(arid, [])
|
|
264
|
+
live[arid] = {"plan": plan, "collected": len(have), "required": _plan_terms(plan)["required"],
|
|
265
|
+
"approvers": [g["approver_key_id"] for g in have]}
|
|
186
266
|
for arid, env in list(app.state.pending.items()):
|
|
187
267
|
if datetime.fromisoformat(env["not_after"]) <= current:
|
|
188
268
|
del app.state.pending[arid] # an expired hold can never be released
|
|
@@ -199,7 +279,10 @@ def build_issuer_app(
|
|
|
199
279
|
|
|
200
280
|
def _complete(arid: str) -> bool:
|
|
201
281
|
env = app.state.pending.get(arid)
|
|
202
|
-
|
|
282
|
+
if env is None:
|
|
283
|
+
plan = app.state.pending_plans.get(arid)
|
|
284
|
+
return plan is not None and len(app.state.grants.get(arid, [])) >= _plan_terms(plan)["required"]
|
|
285
|
+
return len(app.state.grants.get(arid, [])) >= approval_terms(env)["required"]
|
|
203
286
|
|
|
204
287
|
@app.post("/grants")
|
|
205
288
|
async def post_grant(request: Request, authorization: Optional[str] = Header(default=None)):
|
|
@@ -210,18 +293,19 @@ def build_issuer_app(
|
|
|
210
293
|
raise _refuse(400, REF_ISSUER_BAD_REQUEST)
|
|
211
294
|
arid = grant.get("approval_request_id") if isinstance(grant, dict) else None
|
|
212
295
|
env = app.state.pending.get(arid) if isinstance(arid, str) else None
|
|
213
|
-
if
|
|
296
|
+
plan = app.state.pending_plans.get(arid) if isinstance(arid, str) else None
|
|
297
|
+
if env is None and plan is None:
|
|
214
298
|
return {"accepted": False, "reason": REF_ISSUER_UNKNOWN_HOLD}
|
|
215
299
|
trusted = approver_keys()
|
|
216
300
|
if not trusted:
|
|
217
301
|
return {"accepted": False, "reason": REF_ISSUER_NO_APPROVER_TRUST}
|
|
218
|
-
g = verify_grant(grant, expected_decision_sha256=env["decision_sha256"],
|
|
302
|
+
g = verify_grant(grant, expected_decision_sha256=env["decision_sha256"] if env else plan["plan_sha256"],
|
|
219
303
|
expected_approval_request_id=arid, approver_public_keys=trusted,
|
|
220
304
|
gate_key_id=key_id, now=now())
|
|
221
305
|
if not g["accepted"]:
|
|
222
306
|
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)
|
|
307
|
+
# The receipt's (or plan's) own terms: distinct approvers, from the named set if any.
|
|
308
|
+
terms = approval_terms(env) if env else _plan_terms(plan)
|
|
225
309
|
have = app.state.grants.setdefault(arid, [])
|
|
226
310
|
if any(h["approver_key_id"] == grant["approver_key_id"] for h in have):
|
|
227
311
|
return {"accepted": False, "reason": REF_APPROVAL_DUPLICATE_APPROVER}
|
|
@@ -241,9 +325,10 @@ def build_issuer_app(
|
|
|
241
325
|
except Exception:
|
|
242
326
|
raise _refuse(400, REF_ISSUER_BAD_REQUEST)
|
|
243
327
|
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:
|
|
328
|
+
if not isinstance(arid, str) or (arid not in app.state.pending and arid not in app.state.pending_plans):
|
|
245
329
|
return {"declined": False, "reason": REF_ISSUER_UNKNOWN_HOLD}
|
|
246
|
-
|
|
330
|
+
app.state.pending_plans.pop(arid, None)
|
|
331
|
+
app.state.pending.pop(arid, None)
|
|
247
332
|
app.state.pending_args.pop(arid, None)
|
|
248
333
|
app.state.pending_state.pop(arid, None)
|
|
249
334
|
app.state.grants.pop(arid, None)
|
|
@@ -253,13 +338,13 @@ def build_issuer_app(
|
|
|
253
338
|
@app.get("/grants/{approval_request_id}")
|
|
254
339
|
async def get_grant(approval_request_id: str, authorization: Optional[str] = Header(default=None)):
|
|
255
340
|
authenticate(authorization)
|
|
256
|
-
env = app.state.pending.get(approval_request_id)
|
|
341
|
+
env = app.state.pending.get(approval_request_id) or app.state.pending_plans.get(approval_request_id)
|
|
257
342
|
have = app.state.grants.get(approval_request_id, [])
|
|
258
343
|
complete = _complete(approval_request_id)
|
|
259
344
|
return {"pending": env is not None,
|
|
260
345
|
"grant": have[0] if complete and len(have) == 1 else None,
|
|
261
346
|
"grants": list(have) if complete else [],
|
|
262
|
-
"collected": len(have), "required": approval_terms(env)["required"] if env else None,
|
|
347
|
+
"collected": len(have), "required": (_plan_terms(env) if env and "plan_version" in env else approval_terms(env))["required"] if env else None,
|
|
263
348
|
"declined": approval_request_id in app.state.declined}
|
|
264
349
|
|
|
265
350
|
@app.get("/approvers")
|
|
@@ -138,7 +138,8 @@ class IssuerClient:
|
|
|
138
138
|
def _get(self, path: str) -> Any:
|
|
139
139
|
return self._http.get(self.url + path, headers=self._headers(), timeout=self.request_timeout)
|
|
140
140
|
|
|
141
|
-
def issue(self, target_url: str, tool: str, args: Any, state: Optional[Dict[str, Any]] = None
|
|
141
|
+
def issue(self, target_url: str, tool: str, args: Any, state: Optional[Dict[str, Any]] = None,
|
|
142
|
+
plan_id: Optional[str] = None) -> Dict[str, Any]:
|
|
142
143
|
"""{"envelope": ...} or {"envelope": None, "reason": ...}. The cause of
|
|
143
144
|
an unavailable issuer (TLS, DNS, a refused connection) goes to stderr,
|
|
144
145
|
where an MCP client's server log shows it; the refusal code stays
|
|
@@ -147,6 +148,8 @@ class IssuerClient:
|
|
|
147
148
|
body_out: Dict[str, Any] = {"target_url": target_url, "tool": tool, "args": args}
|
|
148
149
|
if state is not None:
|
|
149
150
|
body_out["state"] = state
|
|
151
|
+
if plan_id is not None:
|
|
152
|
+
body_out["plan_id"] = plan_id
|
|
150
153
|
r = self._post("/issue", body_out)
|
|
151
154
|
except Exception as exc:
|
|
152
155
|
if type(exc).__name__ == "LoginRequired":
|
|
@@ -167,6 +170,19 @@ class IssuerClient:
|
|
|
167
170
|
return {"envelope": None, "reason": body.get("reason") or REF_TARGET_ISSUER_UNAVAILABLE}
|
|
168
171
|
return {"envelope": body.get("envelope"), "reason": None}
|
|
169
172
|
|
|
173
|
+
def plan(self, target_url: str, steps: List[Dict[str, Any]], note: str) -> Dict[str, Any]:
|
|
174
|
+
"""Ask for a scoped grant: {"plan": signed plan, "held": bool} or
|
|
175
|
+
{"plan": None, "reason": ...}."""
|
|
176
|
+
try:
|
|
177
|
+
r = self._post("/plans", {"target_url": target_url, "steps": steps, "note": note})
|
|
178
|
+
body = r.json() if r.status_code == 200 else {}
|
|
179
|
+
except Exception as exc:
|
|
180
|
+
sys.stderr.write("gargoyle: issuer unreachable for a plan: %s\n" % exc)
|
|
181
|
+
return {"plan": None, "reason": REF_TARGET_ISSUER_UNAVAILABLE}
|
|
182
|
+
if not body.get("issued"):
|
|
183
|
+
return {"plan": None, "reason": body.get("reason") or REF_TARGET_ISSUER_UNAVAILABLE}
|
|
184
|
+
return {"plan": body["plan"], "held": bool(body.get("held")), "reason": None}
|
|
185
|
+
|
|
170
186
|
def approver_keys(self) -> Dict[str, Any]:
|
|
171
187
|
"""The issuer's enrolled approvers as {upn: public key}; {} when the
|
|
172
188
|
issuer cannot be reached (the static map then stands alone)."""
|
|
@@ -331,6 +347,7 @@ class ServerState:
|
|
|
331
347
|
executor_key_id=config.get("executor_key_id"),
|
|
332
348
|
)
|
|
333
349
|
self.executed: List[Dict[str, Any]] = []
|
|
350
|
+
self.plans: List[Dict[str, Any]] = [] # approved scoped grants: {"plan", "grants", "consumed": set()}
|
|
334
351
|
self.initialized = False
|
|
335
352
|
self.client: Optional[str] = None # "name version" from the MCP initialize handshake
|
|
336
353
|
|
|
@@ -368,15 +385,24 @@ def _gate(state: ServerState, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
368
385
|
# target's current state goes with the request (gargoyle.state), so
|
|
369
386
|
# a hold is approved against it and bound to it.
|
|
370
387
|
probed = state.adapter.probe(name, args) if state.adapter is not None else None
|
|
371
|
-
|
|
388
|
+
# An approved plan may already cover this call; say so, and the
|
|
389
|
+
# issuer opens no hold for it.
|
|
390
|
+
release = _plan_release_for(state, name, args)
|
|
391
|
+
res = state.issuer_client.issue(state.config["target_id"], name, args, state=probed[0] if probed else None,
|
|
392
|
+
plan_id=release["plan"]["plan_id"] if release else None)
|
|
372
393
|
if res["envelope"] is None:
|
|
373
394
|
return refused(res["reason"])
|
|
374
395
|
envelope, self_issued = res["envelope"], True
|
|
375
396
|
if isinstance(envelope, dict) and envelope.get("requires_approval") is True and grant is None:
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
397
|
+
# A scoped grant the human already signed may cover this call;
|
|
398
|
+
# otherwise wait for a card of its own.
|
|
399
|
+
if release is None:
|
|
400
|
+
waited = state.issuer_client.wait_for_grant(str(envelope.get("approval_request_id")))
|
|
401
|
+
if waited["grant"] is None:
|
|
402
|
+
return refused(waited["reason"], envelope)
|
|
403
|
+
grant = waited["grant"]
|
|
404
|
+
else:
|
|
405
|
+
grant = release
|
|
380
406
|
|
|
381
407
|
# A state-bound receipt is checked against the state as it is now, after
|
|
382
408
|
# any wait for approval: if the target moved, the call does not run.
|
|
@@ -386,10 +412,19 @@ def _gate(state: ServerState, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
386
412
|
from gargoyle.state import state_sha256 as _state_hash
|
|
387
413
|
now_probe = state.adapter.probe(name, args)
|
|
388
414
|
live_state = _state_hash(now_probe[0]) if now_probe is not None else None
|
|
389
|
-
|
|
390
|
-
|
|
415
|
+
from gargoyle.plans import is_plan_release
|
|
416
|
+
if is_plan_release(grant):
|
|
417
|
+
verdict = state.consumer.check(envelope, call_for(name, args, state.config["principal"], live_state),
|
|
418
|
+
plan_release=grant)
|
|
419
|
+
else:
|
|
420
|
+
verdict = state.consumer.check(envelope, call_for(name, args, state.config["principal"], live_state),
|
|
421
|
+
grant=grant)
|
|
391
422
|
if not verdict.honored:
|
|
392
423
|
return refused(verdict.reason, envelope)
|
|
424
|
+
if is_plan_release(grant):
|
|
425
|
+
for active in state.plans:
|
|
426
|
+
if active["plan"]["plan_id"] == grant["plan"]["plan_id"]:
|
|
427
|
+
active["consumed"].add(grant["step"])
|
|
393
428
|
|
|
394
429
|
# Undo material: for a held, state-bound call, keep the target as it is
|
|
395
430
|
# right now (the state the receipt bound and the check just confirmed).
|
|
@@ -405,6 +440,61 @@ def _gate(state: ServerState, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
405
440
|
"self_issued": self_issued}
|
|
406
441
|
|
|
407
442
|
|
|
443
|
+
def _plan_release_for(state: ServerState, tool: str, args: Any) -> Optional[Dict[str, Any]]:
|
|
444
|
+
"""The release record for the first active plan with an unconsumed step
|
|
445
|
+
matching this call, or None."""
|
|
446
|
+
from datetime import datetime, timezone
|
|
447
|
+
from gargoyle.plans import match_step
|
|
448
|
+
now = datetime.now(timezone.utc)
|
|
449
|
+
for active in state.plans:
|
|
450
|
+
plan = active["plan"]
|
|
451
|
+
try:
|
|
452
|
+
if datetime.fromisoformat(plan["not_after"]) <= now:
|
|
453
|
+
continue
|
|
454
|
+
except (ValueError, TypeError):
|
|
455
|
+
continue
|
|
456
|
+
i = match_step(plan, tool, args, active["consumed"])
|
|
457
|
+
if i is not None:
|
|
458
|
+
return {"plan": plan, "grants": active["grants"], "step": i, "args": args}
|
|
459
|
+
return None
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
PLAN_TOOL = "gargoyle_plan"
|
|
463
|
+
PLAN_TOOL_DEF = {
|
|
464
|
+
"name": PLAN_TOOL,
|
|
465
|
+
"description": ("Ask the approver for one approval covering a bounded plan of changes instead of one card per "
|
|
466
|
+
"change. steps: a list of {tool, args} (exact) or {tool, match: {argument: glob}}; note: what the "
|
|
467
|
+
"plan is for, shown to the approver. Each later call matching an unused step runs without its "
|
|
468
|
+
"own card, until the plan expires. Tools the policy keeps individual are refused here."),
|
|
469
|
+
"inputSchema": {"type": "object", "required": ["steps"],
|
|
470
|
+
"properties": {"steps": {"type": "array"}, "note": {"type": "string"}}},
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _handle_plan(state: ServerState, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
475
|
+
"""gargoyle_plan: obtain the signed plan, wait for the human's grant,
|
|
476
|
+
then keep it active for matching calls."""
|
|
477
|
+
if state.issuer_client is None or state.consumer is None:
|
|
478
|
+
return {"content": [{"type": "text", "text": "refused: plans need self-issue mode"}], "isError": True,
|
|
479
|
+
"_meta": {EXECUTED_META_KEY: False, REASON_META_KEY: REF_TARGET_NOT_CONFIGURED}}
|
|
480
|
+
args = params.get("arguments") or {}
|
|
481
|
+
res = state.issuer_client.plan(state.config["target_id"], args.get("steps") or [], str(args.get("note") or ""))
|
|
482
|
+
if res["plan"] is None:
|
|
483
|
+
return {"content": [{"type": "text", "text": "refused: " + str(res["reason"])}], "isError": True,
|
|
484
|
+
"_meta": {EXECUTED_META_KEY: False, REASON_META_KEY: res["reason"]}}
|
|
485
|
+
plan, grants = res["plan"], []
|
|
486
|
+
if res["held"]:
|
|
487
|
+
waited = state.issuer_client.wait_for_grant(plan["approval_request_id"])
|
|
488
|
+
if waited["grant"] is None:
|
|
489
|
+
return {"content": [{"type": "text", "text": "refused: " + str(waited["reason"])}], "isError": True,
|
|
490
|
+
"_meta": {EXECUTED_META_KEY: False, REASON_META_KEY: waited["reason"]}}
|
|
491
|
+
grants = waited["grant"] if isinstance(waited["grant"], list) else [waited["grant"]]
|
|
492
|
+
state.plans.append({"plan": plan, "grants": grants, "consumed": set()})
|
|
493
|
+
text = "plan %s approved: %d step(s) until %s" % (plan["plan_id"][:8], len(plan["steps"]), plan["not_after"])
|
|
494
|
+
return {"content": [{"type": "text", "text": text}], "isError": False,
|
|
495
|
+
"_meta": {EXECUTED_META_KEY: False, REASON_META_KEY: "PLAN_APPROVED", "gargoyle/plan": plan}}
|
|
496
|
+
|
|
497
|
+
|
|
408
498
|
def _audit_meta(state: ServerState, gate: Dict[str, Any], outputs: Any, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
409
499
|
"""What an honored result's _meta carries beyond executed/reason: the
|
|
410
500
|
execution receipt when this server attests, and, when the receipt was
|
|
@@ -484,6 +574,9 @@ def handle_message(state: ServerState, msg: Dict[str, Any]) -> Optional[Dict[str
|
|
|
484
574
|
and isinstance(reply.get("result"), dict) and isinstance(reply["result"].get("tools"), list):
|
|
485
575
|
from gargoyle.revert import REVERT_TOOL_DEF
|
|
486
576
|
reply["result"]["tools"] = list(reply["result"]["tools"]) + [REVERT_TOOL_DEF]
|
|
577
|
+
if method == "tools/list" and state.issuer_client is not None and isinstance(reply, dict) \
|
|
578
|
+
and isinstance(reply.get("result"), dict) and isinstance(reply["result"].get("tools"), list):
|
|
579
|
+
reply["result"]["tools"] = list(reply["result"]["tools"]) + [PLAN_TOOL_DEF]
|
|
487
580
|
return reply
|
|
488
581
|
|
|
489
582
|
if method == "initialize":
|
|
@@ -524,10 +617,13 @@ def handle_message(state: ServerState, msg: Dict[str, Any]) -> Optional[Dict[str
|
|
|
524
617
|
|
|
525
618
|
if method == "tools/list":
|
|
526
619
|
from gargoyle.revert import REVERT_TOOL_DEF
|
|
527
|
-
return _success(msg_id, {"tools": ADVERTISED_TOOLS + ([REVERT_TOOL_DEF] if state.snapshots is not None else [])
|
|
620
|
+
return _success(msg_id, {"tools": ADVERTISED_TOOLS + ([REVERT_TOOL_DEF] if state.snapshots is not None else [])
|
|
621
|
+
+ ([PLAN_TOOL_DEF] if state.issuer_client is not None else [])})
|
|
528
622
|
|
|
529
623
|
if method == "tools/call":
|
|
530
624
|
from gargoyle.revert import REVERT_TOOL, plan_revert
|
|
625
|
+
if params.get("name") == PLAN_TOOL:
|
|
626
|
+
return _success(msg_id, _handle_plan(state, params))
|
|
531
627
|
if params.get("name") == REVERT_TOOL:
|
|
532
628
|
# An undo is a write the middleware composes from its snapshot; it
|
|
533
629
|
# then goes through the gate exactly as the assistant's own writes.
|