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.
Files changed (79) hide show
  1. {gargoyle_receipts-0.11.0.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.12.0.dev0}/PKG-INFO +15 -2
  2. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/README.md +14 -1
  3. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/cli.py +2 -0
  4. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/consumer.py +21 -1
  5. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/evidence.py +9 -4
  6. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/execution.py +9 -2
  7. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/issuer_service.py +96 -11
  8. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/mcp_server.py +105 -9
  9. gargoyle_receipts-0.12.0.dev0/gargoyle/plans.py +209 -0
  10. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/policy.py +1 -0
  11. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/rules.py +6 -2
  12. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/static/approve.html +32 -0
  13. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0/gargoyle_receipts.egg-info}/PKG-INFO +15 -2
  14. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +2 -0
  15. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/pyproject.toml +1 -1
  16. gargoyle_receipts-0.12.0.dev0/tests/test_plans.py +187 -0
  17. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/LICENSE +0 -0
  18. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/NOTICE +0 -0
  19. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/__init__.py +0 -0
  20. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/adapters.py +0 -0
  21. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/approval.py +0 -0
  22. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/approver_cli.py +0 -0
  23. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/approver_trust.py +0 -0
  24. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/approvers.py +0 -0
  25. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/entra_login.py +0 -0
  26. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/envelope.py +0 -0
  27. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/fs_server.py +0 -0
  28. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/identity.py +0 -0
  29. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/issuer.py +0 -0
  30. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/jws.py +0 -0
  31. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/key_record_source.py +0 -0
  32. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/notify.py +0 -0
  33. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/opa.py +0 -0
  34. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/pending_store.py +0 -0
  35. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/published_record_source.py +0 -0
  36. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/remote.py +0 -0
  37. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/replay_cache.py +0 -0
  38. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/revert.py +0 -0
  39. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/specs/graph-reference.json +0 -0
  40. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/state.py +0 -0
  41. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/static/approve.js +0 -0
  42. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle/verifier.py +0 -0
  43. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  44. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  45. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  46. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  47. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/setup.cfg +0 -0
  48. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_annotations.py +0 -0
  49. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_approver_cli.py +0 -0
  50. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_approvers.py +0 -0
  51. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_canonical.py +0 -0
  52. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_cli.py +0 -0
  53. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_console_js.py +0 -0
  54. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_consumer.py +0 -0
  55. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_entra_identity.py +0 -0
  56. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_entra_login.py +0 -0
  57. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_evidence.py +0 -0
  58. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_execution.py +0 -0
  59. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_fs_server.py +0 -0
  60. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_graph.py +0 -0
  61. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_historical.py +0 -0
  62. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_identity.py +0 -0
  63. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_issuer_service.py +0 -0
  64. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_jws.py +0 -0
  65. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_mcp_proxy.py +0 -0
  66. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_mcp_self_issue.py +0 -0
  67. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_mcp_server.py +0 -0
  68. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_notify.py +0 -0
  69. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_opa.py +0 -0
  70. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_policy.py +0 -0
  71. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_published_record.py +0 -0
  72. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_remote.py +0 -0
  73. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_revert.py +0 -0
  74. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_rules.py +0 -0
  75. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_state.py +0 -0
  76. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_stdio_encoding.py +0 -0
  77. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_threshold.py +0 -0
  78. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_typescript_conformance.py +0 -0
  79. {gargoyle_receipts-0.11.0.dev0 → gargoyle_receipts-0.12.0.dev0}/tests/test_wire_spec.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.11.0.dev0
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
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
61
61
 
62
- Status: pre-release (`0.11.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
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
  [![PyPI](https://img.shields.io/pypi/v/gargoyle-receipts)](https://pypi.org/project/gargoyle-receipts/)
23
23
 
24
- Status: pre-release (`0.11.0.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
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
- released_by = [g.get("approver_key_id") for g in (e["grant"] if isinstance(e["grant"], list) else [e["grant"]])
222
- if isinstance(g, dict)] if e["grant"] is not None else []
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
- if not as_grant_list(grant):
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
- g = verify_grants(grant, envelope=envelope, approver_public_keys=approver_public_keys or {}, now=executed_at)
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
- if result.envelope["requires_approval"]:
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
- return env is not None and len(app.state.grants.get(arid, [])) >= approval_terms(env)["required"]
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 env is None:
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
- del app.state.pending[arid]
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) -> Dict[str, Any]:
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
- res = state.issuer_client.issue(state.config["target_id"], name, args, state=probed[0] if probed else None)
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
- waited = state.issuer_client.wait_for_grant(str(envelope.get("approval_request_id")))
377
- if waited["grant"] is None:
378
- return refused(waited["reason"], envelope)
379
- grant = waited["grant"]
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
- verdict = state.consumer.check(envelope, call_for(name, args, state.config["principal"], live_state),
390
- grant=grant)
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.