gargoyle-receipts 0.14.2.dev0__tar.gz → 0.15.1.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.

Potentially problematic release.


This version of gargoyle-receipts might be problematic. Click here for more details.

Files changed (84) hide show
  1. {gargoyle_receipts-0.14.2.dev0/gargoyle_receipts.egg-info → gargoyle_receipts-0.15.1.dev0}/PKG-INFO +12 -2
  2. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/README.md +11 -1
  3. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/cli.py +19 -0
  4. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/mcp_server.py +27 -0
  5. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/remote.py +4 -0
  6. gargoyle_receipts-0.15.1.dev0/gargoyle/review.py +213 -0
  7. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0/gargoyle_receipts.egg-info}/PKG-INFO +12 -2
  8. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle_receipts.egg-info/SOURCES.txt +1 -0
  9. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/pyproject.toml +1 -1
  10. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_cli.py +19 -0
  11. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_mcp_self_issue.py +24 -0
  12. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_remote.py +1 -0
  13. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/LICENSE +0 -0
  14. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/NOTICE +0 -0
  15. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/__init__.py +0 -0
  16. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/adapters.py +0 -0
  17. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/approval.py +0 -0
  18. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/approver_cli.py +0 -0
  19. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/approver_trust.py +0 -0
  20. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/approvers.py +0 -0
  21. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/consumer.py +0 -0
  22. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/entra_login.py +0 -0
  23. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/envelope.py +0 -0
  24. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/evidence.py +0 -0
  25. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/execution.py +0 -0
  26. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/fs_server.py +0 -0
  27. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/holdstore.py +0 -0
  28. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/identity.py +0 -0
  29. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/issuer.py +0 -0
  30. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/issuer_service.py +0 -0
  31. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/jws.py +0 -0
  32. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/key_record_source.py +0 -0
  33. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/notify.py +0 -0
  34. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/opa.py +0 -0
  35. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/pending_store.py +0 -0
  36. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/plans.py +0 -0
  37. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/policy.py +0 -0
  38. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/published_record_source.py +0 -0
  39. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/reconcile.py +0 -0
  40. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/replay_cache.py +0 -0
  41. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/revert.py +0 -0
  42. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/rules.py +0 -0
  43. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/specs/graph-reference.json +0 -0
  44. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/state.py +0 -0
  45. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/static/approve.html +0 -0
  46. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/static/approve.js +0 -0
  47. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/verifier.py +0 -0
  48. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle/witness.py +0 -0
  49. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle_receipts.egg-info/dependency_links.txt +0 -0
  50. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle_receipts.egg-info/entry_points.txt +0 -0
  51. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle_receipts.egg-info/requires.txt +0 -0
  52. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/gargoyle_receipts.egg-info/top_level.txt +0 -0
  53. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/setup.cfg +0 -0
  54. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_annotations.py +0 -0
  55. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_approver_cli.py +0 -0
  56. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_approvers.py +0 -0
  57. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_canonical.py +0 -0
  58. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_console_js.py +0 -0
  59. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_consumer.py +0 -0
  60. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_entra_identity.py +0 -0
  61. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_entra_login.py +0 -0
  62. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_evidence.py +0 -0
  63. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_execution.py +0 -0
  64. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_fs_server.py +0 -0
  65. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_graph.py +0 -0
  66. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_historical.py +0 -0
  67. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_identity.py +0 -0
  68. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_issuer_service.py +0 -0
  69. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_jws.py +0 -0
  70. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_mcp_proxy.py +0 -0
  71. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_mcp_server.py +0 -0
  72. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_notify.py +0 -0
  73. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_opa.py +0 -0
  74. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_plans.py +0 -0
  75. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_policy.py +0 -0
  76. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_published_record.py +0 -0
  77. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_revert.py +0 -0
  78. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_rules.py +0 -0
  79. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_state.py +0 -0
  80. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_stdio_encoding.py +0 -0
  81. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_threshold.py +0 -0
  82. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_typescript_conformance.py +0 -0
  83. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_wire_spec.py +0 -0
  84. {gargoyle_receipts-0.14.2.dev0 → gargoyle_receipts-0.15.1.dev0}/tests/test_witness.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.14.2.dev0
3
+ Version: 0.15.1.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.14.2.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
62
+ Status: pre-release (`0.15.1.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
@@ -272,6 +272,15 @@ field. A hold can also be announced to Slack or any webhook
272
272
  (`GARGOYLE_HOLD_WEBHOOK_URL`); the notice carries no key, so nothing in a
273
273
  chat channel can approve anything.
274
274
 
275
+ ## Reviewing it
276
+
277
+ `gargoyle review-packet --out packet.zip` builds one archive for an outside
278
+ reviewer: the spec, the vectors, the verification source, a reviewer
279
+ prompt, and `verify_stdlib.py`, which recomputes every conformance vector
280
+ (RFC 8785, SHA-256, Ed25519) with nothing but the Python standard library,
281
+ so a reviewer with no pip and no network can still check every hash and
282
+ signature rather than take the README's word.
283
+
275
284
  ## Deploy it
276
285
 
277
286
  pip install gargoyle-receipts
@@ -383,6 +392,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
383
392
  | issuer `GARGOYLE_WITNESS_PATH` | the issuer's signed, hash-chained record of every decision (`gargoyle issuer` sets `witness.jsonl` beside the keys); `gargoyle audit --witness` reconciles a client's evidence against it, `--check-tree DIR` finds files changed outside the gate |
384
393
  | issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
385
394
  | `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 |
395
+ | `GARGOYLE_MCP_INSTRUCTIONS` | replaces the standing instructions the gate hands the model in the MCP handshake (default: all work on the target goes through these tools, a wait is normal, a refusal is final, plans and undo exist); the user never has to name the server |
386
396
  | `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 |
387
397
  | `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
388
398
  | `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
@@ -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.14.2.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
24
+ Status: pre-release (`0.15.1.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
@@ -234,6 +234,15 @@ field. A hold can also be announced to Slack or any webhook
234
234
  (`GARGOYLE_HOLD_WEBHOOK_URL`); the notice carries no key, so nothing in a
235
235
  chat channel can approve anything.
236
236
 
237
+ ## Reviewing it
238
+
239
+ `gargoyle review-packet --out packet.zip` builds one archive for an outside
240
+ reviewer: the spec, the vectors, the verification source, a reviewer
241
+ prompt, and `verify_stdlib.py`, which recomputes every conformance vector
242
+ (RFC 8785, SHA-256, Ed25519) with nothing but the Python standard library,
243
+ so a reviewer with no pip and no network can still check every hash and
244
+ signature rather than take the README's word.
245
+
237
246
  ## Deploy it
238
247
 
239
248
  pip install gargoyle-receipts
@@ -345,6 +354,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
345
354
  | issuer `GARGOYLE_WITNESS_PATH` | the issuer's signed, hash-chained record of every decision (`gargoyle issuer` sets `witness.jsonl` beside the keys); `gargoyle audit --witness` reconciles a client's evidence against it, `--check-tree DIR` finds files changed outside the gate |
346
355
  | issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
347
356
  | `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 |
357
+ | `GARGOYLE_MCP_INSTRUCTIONS` | replaces the standing instructions the gate hands the model in the MCP handshake (default: all work on the target goes through these tools, a wait is normal, a refusal is final, plans and undo exist); the user never has to name the server |
348
358
  | `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 |
349
359
  | `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
350
360
  | `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
@@ -14,6 +14,8 @@ policy engine, a container runtime, or any client changes.
14
14
  gargoyle remote --out DIR [--upstream-url URL | -- <command>] the gate as an HTTPS MCP
15
15
  server, for hosted connectors
16
16
  gargoyle remote-token --out DIR --principal UPN a capability URL for one person
17
+ gargoyle review-packet --out packet.zip [--audit-excerpt FILE] everything an outside
18
+ reviewer needs, verifier included
17
19
  gargoyle audit <receipt.json> <execution.json> [grant.json]
18
20
  gargoyle audit --log audit.jsonl verify the middleware's evidence log
19
21
 
@@ -282,6 +284,18 @@ def cmd_remote(args) -> int:
282
284
  return 0
283
285
 
284
286
 
287
+ def cmd_review_packet(args) -> int:
288
+ """One archive for an outside reviewer: spec, README, vectors, the
289
+ verification source, a reviewer prompt, and verify_stdlib.py, which
290
+ recomputes every vector with the standard library alone."""
291
+ from gargoyle.review import build_packet
292
+ root = Path(__file__).resolve().parent.parent
293
+ excerpt = Path(args.audit_excerpt).read_text(encoding="utf-8") if args.audit_excerpt else None
294
+ result = build_packet(str(root), args.out, audit_excerpt=excerpt, extra_files=args.extra or [])
295
+ print("wrote %s (%d files); give the reviewer the archive and REVIEWER_PROMPT.txt" % (result["path"], len(result["files"])))
296
+ return 0
297
+
298
+
285
299
  def cmd_issuer(args) -> int:
286
300
  import uvicorn
287
301
  from gargoyle.issuer_service import app_from_env
@@ -457,6 +471,11 @@ def main(argv=None) -> int:
457
471
  p.add_argument("--days", type=int, default=365)
458
472
  p.add_argument("--url", help="the gate's public base URL for the printed connector URL")
459
473
  p.set_defaults(fn=cmd_remote_token)
474
+ p = sub.add_parser("review-packet", help="build the archive an outside reviewer needs")
475
+ p.add_argument("--out", default="gargoyle-review-packet.zip")
476
+ p.add_argument("--audit-excerpt", help="a text file with real audit output to include")
477
+ p.add_argument("--extra", action="append", help="additional files to include (repeatable)")
478
+ p.set_defaults(fn=cmd_review_packet)
460
479
  p = sub.add_parser("audit", help="verify a receipt chain offline, or a whole evidence log")
461
480
  p.add_argument("receipt", nargs="?"); p.add_argument("execution", nargs="?"); p.add_argument("grant", nargs="?")
462
481
  p.add_argument("--log", help="the middleware's audit.jsonl: verify every entry")
@@ -100,6 +100,7 @@ ENV_POLL_SECONDS = "GARGOYLE_MCP_POLL_SECONDS"
100
100
  ENV_AUDIT_LOG = "GARGOYLE_MCP_AUDIT_LOG"
101
101
  ENV_STATE_PROBE = "GARGOYLE_MCP_STATE_PROBE"
102
102
  ENV_ADAPTER_SPEC = "GARGOYLE_MCP_ADAPTER_SPEC" # with GARGOYLE_MCP_STATE_PROBE=graph: the tool mapping JSON
103
+ ENV_INSTRUCTIONS = "GARGOYLE_MCP_INSTRUCTIONS" # override the standing instructions the gate gives the model (default: gate_instructions())
103
104
  ENV_SNAPSHOT_DIR = "GARGOYLE_MCP_SNAPSHOT_DIR" # where pre-change snapshots live (default: snapshots/ beside the audit log); "none" disables undo # "filesystem" (default): bind held calls to the target file's state; "none" to disable
104
105
  REF_TARGET_LOGIN_REQUIRED = "REF_TARGET_LOGIN_REQUIRED" # Entra login mode and no usable token: run `gargoyle login` # the evidence log: one hash-chained JSON line per decision, honored or refused
105
106
 
@@ -284,6 +285,26 @@ def _without_gargoyle_meta(params: Dict[str, Any]) -> Dict[str, Any]:
284
285
  return forwarded
285
286
 
286
287
 
288
+ def gate_instructions(target_id: str, upstream_instructions: Optional[str] = None, override: Optional[str] = None) -> str:
289
+ """The standing instructions an MCP server may hand the model in its
290
+ initialize answer; clients place them in the model's context for the
291
+ session. This is how the gate is mandated rather than named by the
292
+ user each time: the model is told, by the server itself, that all work
293
+ on this target goes through these tools, that a refusal is final, and
294
+ that there is nothing to type. The upstream's own instructions, if
295
+ any, follow the gate's."""
296
+ text = override if override else (
297
+ "Every operation on %s goes through this server's tools; do not use any other tool, built-in "
298
+ "capability or workaround for that target. Reads run at once. A write may pause for a human "
299
+ "approval: wait for the result, it is normal. A refused call is a decision, not an error: report "
300
+ "the reason to the user and stop; do not retry or route around it. For a task with several "
301
+ "writes, gargoyle_plan may ask for one approval covering them; gargoyle_revert undoes an approved "
302
+ "change through the same approval. The user never needs to name this server." % target_id)
303
+ if upstream_instructions:
304
+ text += "\n\n" + upstream_instructions
305
+ return text
306
+
307
+
287
308
  def client_label(params: Any) -> Optional[str]:
288
309
  """"name version" from an initialize request's clientInfo, or None."""
289
310
  info = params.get("clientInfo") if isinstance(params, dict) else None
@@ -348,6 +369,8 @@ class ServerState:
348
369
  )
349
370
  self.executed: List[Dict[str, Any]] = []
350
371
  self.plans: List[Dict[str, Any]] = [] # approved scoped grants: {"plan", "grants", "consumed": set()}
372
+ import os as _os
373
+ self.instructions: Optional[str] = _os.environ.get(ENV_INSTRUCTIONS) or None
351
374
  self.initialized = False
352
375
  self.client: Optional[str] = None # "name version" from the MCP initialize handshake
353
376
 
@@ -576,6 +599,9 @@ def handle_message(state: ServerState, msg: Dict[str, Any]) -> Optional[Dict[str
576
599
  if method == "initialize":
577
600
  state.client = client_label(params)
578
601
  reply = _relay(state, msg)
602
+ if method == "initialize" and isinstance(reply, dict) and isinstance(reply.get("result"), dict) and state.config:
603
+ reply["result"]["instructions"] = gate_instructions(state.config.get("target_id", "the gated target"),
604
+ reply["result"].get("instructions"), state.instructions)
579
605
  if method == "tools/list" and state.snapshots is not None and isinstance(reply, dict) \
580
606
  and isinstance(reply.get("result"), dict) and isinstance(reply["result"].get("tools"), list):
581
607
  from gargoyle.revert import REVERT_TOOL_DEF
@@ -595,6 +621,7 @@ def handle_message(state: ServerState, msg: Dict[str, Any]) -> Optional[Dict[str
595
621
  "protocolVersion": PROTOCOL_VERSION,
596
622
  "capabilities": {"tools": {"listChanged": False}},
597
623
  "serverInfo": SERVER_INFO,
624
+ "instructions": gate_instructions((state.config or {}).get("target_id", "the gated target"), None, state.instructions),
598
625
  },
599
626
  )
600
627
 
@@ -168,6 +168,10 @@ def build_remote_app(
168
168
  upstream_init["serverInfo"] = {"name": "gargoyle+" + str(upstream_init.get("serverInfo", {}).get("name", "upstream")),
169
169
  "version": SERVER_INFO["version"]}
170
170
  upstream.send({"jsonrpc": "2.0", "method": "notifications/initialized"})
171
+ from gargoyle.mcp_server import gate_instructions
172
+ import os as _os
173
+ upstream_init["instructions"] = gate_instructions(config.get("target_id", "the gated target"), upstream_init.get("instructions"),
174
+ _os.environ.get("GARGOYLE_MCP_INSTRUCTIONS") or None)
171
175
 
172
176
  def new_state(principal: str, issuer_token: str) -> ServerState:
173
177
  nonlocal shared_evidence, shared_snapshots
@@ -0,0 +1,213 @@
1
+ """A review packet: everything an outside reviewer needs in one archive,
2
+ including a verifier that needs nothing but the Python standard library.
3
+
4
+ `gargoyle review-packet` writes a zip with SPEC.md, README.md, the
5
+ conformance vectors (current and historical), the verification modules
6
+ as source, the live audit excerpt if given, and verify_stdlib.py: RFC
7
+ 8785 canonicalization, SHA-256 and Ed25519 verification implemented with
8
+ hashlib and integers only, so a reviewer in a sandbox with no pip and no
9
+ network can recompute every vector and check every signature themselves.
10
+ """
11
+
12
+ import io
13
+ import json
14
+ import zipfile
15
+ from pathlib import Path
16
+ from typing import Any, Dict, Iterable, List, Optional
17
+
18
+ VERIFY_STDLIB = r'''#!/usr/bin/env python3
19
+ """Recompute Gargoyle's conformance vectors with the standard library only.
20
+
21
+ No third-party packages: RFC 8785 canonical JSON, SHA-256 (hashlib) and
22
+ Ed25519 verification (RFC 8032, implemented here with integers) are enough
23
+ to check that every hash and signature in wire_vectors.json is what the
24
+ specification says. Run: python3 verify_stdlib.py wire_vectors.json
25
+ Exit code 0 means every check passed; each check prints its own line.
26
+ """
27
+ import hashlib, json, sys
28
+
29
+ # ---- RFC 8785 canonical JSON ----
30
+ def _es6_number(x):
31
+ if x != x or x in (float("inf"), float("-inf")): raise ValueError("non-finite")
32
+ if x == 0: return "0"
33
+ sign = "-" if x < 0 else ""
34
+ r = repr(abs(x)); mantissa, _, exp = r.partition("e"); whole, _, frac = mantissa.partition(".")
35
+ digits = (whole + frac).lstrip("0"); n = len(whole) + (int(exp) if exp else 0)
36
+ n -= len(whole + frac) - len((whole + frac).lstrip("0")); digits = digits.rstrip("0") or "0"; k = len(digits)
37
+ if k <= n <= 21: return sign + digits + "0" * (n - k)
38
+ if 0 < n <= 21: return sign + digits[:n] + "." + digits[n:]
39
+ if -6 < n <= 0: return sign + "0." + "0" * (-n) + digits
40
+ e = n - 1; et = "e" + ("+" if e >= 0 else "-") + str(abs(e))
41
+ return sign + (digits if k == 1 else digits[0] + "." + digits[1:]) + et
42
+
43
+ _ESC = {"\b": "\\b", "\f": "\\f", "\n": "\\n", "\r": "\\r", "\t": "\\t", '"': '\\"', "\\": "\\\\"}
44
+ def _str(s):
45
+ return '"' + "".join(_ESC.get(c, c if c >= " " else "\\u%04x" % ord(c)) for c in s) + '"'
46
+
47
+ def canonical_json(v):
48
+ if v is None: return "null"
49
+ if v is True: return "true"
50
+ if v is False: return "false"
51
+ if isinstance(v, int): return str(v)
52
+ if isinstance(v, float): return _es6_number(v)
53
+ if isinstance(v, str): return _str(v)
54
+ if isinstance(v, list): return "[" + ",".join(canonical_json(x) for x in v) + "]"
55
+ if isinstance(v, dict):
56
+ return "{" + ",".join(_str(k) + ":" + canonical_json(v[k]) for k in sorted(v, key=lambda k: k.encode("utf-16-be"))) + "}"
57
+ raise TypeError(type(v))
58
+
59
+ def sha256_hex(text): return hashlib.sha256(text.encode("utf-8")).hexdigest()
60
+
61
+ # ---- Ed25519 (RFC 8032) with integers only; slow, and that is fine for a review ----
62
+ P = 2**255 - 19; L = 2**252 + 27742317777372353535851937790883648493
63
+ D = (-121665 * pow(121666, P - 2, P)) % P
64
+ def _inv(x): return pow(x, P - 2, P)
65
+ def _pt_add(A, B):
66
+ x1, y1, z1, t1 = A; x2, y2, z2, t2 = B
67
+ a = (y1 - x1) * (y2 - x2) % P; b = (y1 + x1) * (y2 + x2) % P; c = 2 * t1 * t2 * D % P; d = 2 * z1 * z2 % P
68
+ e, f, g, h = b - a, d - c, d + c, b + a
69
+ return (e * f % P, g * h % P, f * g % P, e * h % P)
70
+ def _pt_mul(k, A):
71
+ R = (0, 1, 1, 0)
72
+ while k:
73
+ if k & 1: R = _pt_add(R, A)
74
+ A = _pt_add(A, A); k >>= 1
75
+ return R
76
+ def _pt_eq(A, B):
77
+ x1, y1, z1, _ = A; x2, y2, z2, _ = B
78
+ return (x1 * z2 - x2 * z1) % P == 0 and (y1 * z2 - y2 * z1) % P == 0
79
+ def _decode(b):
80
+ y = int.from_bytes(b, "little"); sign = y >> 255; y &= (1 << 255) - 1
81
+ if y >= P: return None
82
+ x2 = (y * y - 1) * _inv(D * y * y + 1) % P
83
+ x = pow(x2, (P + 3) // 8, P)
84
+ if (x * x - x2) % P != 0: x = x * pow(2, (P - 1) // 4, P) % P
85
+ if (x * x - x2) % P != 0: return None
86
+ if x & 1 != sign: x = P - x
87
+ return (x, y, 1, x * y % P)
88
+ _B = _decode(bytes.fromhex("5866666666666666666666666666666666666666666666666666666666666666"))
89
+ def ed25519_verify(public_key_hex, message_bytes, signature_hex):
90
+ A = _decode(bytes.fromhex(public_key_hex)); sig = bytes.fromhex(signature_hex)
91
+ if A is None or len(sig) != 64: return False
92
+ R = _decode(sig[:32]); S = int.from_bytes(sig[32:], "little")
93
+ if R is None or S >= L: return False
94
+ h = hashlib.sha512(sig[:32] + bytes.fromhex(public_key_hex) + message_bytes).digest()
95
+ k = int.from_bytes(h, "little") % L
96
+ return _pt_eq(_pt_mul(S, _B), _pt_add(R, _pt_mul(k, A)))
97
+
98
+ HASH_EXCLUDED = ("decision_sha256", "timestamp_utc", "issuer_key_id", "issuer_signature", "not_after", "decision_id", "approval_request_id")
99
+
100
+ def check(label, ok):
101
+ print(("PASS " if ok else "FAIL ") + label); return ok
102
+
103
+ def verify_receipt(signed, issuer_pub, results):
104
+ region = {k: v for k, v in signed.items() if k not in HASH_EXCLUDED}
105
+ results.append(check("decision_sha256 recomputes over the RFC 8785 hash region", sha256_hex(canonical_json(region)) == signed["decision_sha256"]))
106
+ srg = {k: v for k, v in signed.items() if k not in ("issuer_signature", "timestamp_utc")}
107
+ results.append(check("issuer_signature verifies (Ed25519, pure Python)", ed25519_verify(issuer_pub, canonical_json(srg).encode(), signed["issuer_signature"])))
108
+
109
+ def verify_grant(grant, approver_pub, decision_sha256, results):
110
+ region = {k: v for k, v in grant.items() if k != "approver_signature"}
111
+ results.append(check("grant binds the decision (%s)" % grant["approver_key_id"], grant["decision_sha256"] == decision_sha256))
112
+ results.append(check("approver_signature verifies (%s)" % grant["approver_key_id"], ed25519_verify(approver_pub, canonical_json(region).encode(), grant["approver_signature"])))
113
+
114
+ def verify_execution(ex, executor_pub, signed, results):
115
+ region = {k: v for k, v in ex.items() if k != "executor_signature"}
116
+ results.append(check("execution receipt binds the receipt field for field",
117
+ all(canonical_json(ex[k]) == canonical_json(signed[k]) for k in ("decision_id", "decision_sha256", "policy", "target_url", "request_context"))))
118
+ results.append(check("executor_signature verifies", ed25519_verify(executor_pub, canonical_json(region).encode(), ex["executor_signature"])))
119
+
120
+ def main(path):
121
+ V = json.load(open(path, encoding="utf-8")); results = []
122
+ print("== primary vector ==")
123
+ results.append(check("args_sha256 recomputes", sha256_hex(canonical_json(V["call"]["args"])) == V["args_sha256"]))
124
+ results.append(check("hash_region bytes match", canonical_json({k: v for k, v in V["signed"].items() if k not in HASH_EXCLUDED}) == V["hash_region"]))
125
+ verify_receipt(V["signed"], V["issuer_public_key_hex"], results)
126
+ verify_grant(V["grant"], V["approver_public_key_hex"], V["signed"]["decision_sha256"], results)
127
+ verify_execution(V["execution"], V["executor_public_key_hex"], V["signed"], results)
128
+ tampered = dict(V["signed"], target_url="https://elsewhere.example/")
129
+ srg = {k: v for k, v in tampered.items() if k not in ("issuer_signature", "timestamp_utc")}
130
+ results.append(check("a tampered receipt does NOT verify", not ed25519_verify(V["issuer_public_key_hex"], canonical_json(srg).encode(), tampered["issuer_signature"])))
131
+ if "threshold" in V:
132
+ T = V["threshold"]; print("== threshold vector (two approvers) ==")
133
+ verify_receipt(T["signed"], V["issuer_public_key_hex"], results)
134
+ results.append(check("approval terms are inside the hash region", '"approval":{"approvers":["approver-alice","approver-bob"],"required":2}' in T["hash_region"]))
135
+ for g, pub in zip(T["grants"], (V["approver_public_key_hex"], T["approver_2_public_key_hex"])):
136
+ verify_grant(g, pub, T["signed"]["decision_sha256"], results)
137
+ verify_execution(T["execution"], V["executor_public_key_hex"], T["signed"], results)
138
+ print("%d checks, %d failed" % (len(results), results.count(False)))
139
+ return 0 if all(results) else 1
140
+
141
+ if __name__ == "__main__":
142
+ sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "wire_vectors.json"))
143
+ '''
144
+
145
+ SOURCE_FILES = ["envelope.py", "verifier.py", "approval.py", "execution.py", "evidence.py", "state.py", "plans.py",
146
+ "witness.py", "reconcile.py", "revert.py", "adapters.py", "consumer.py", "issuer.py", "jws.py"]
147
+
148
+ REVIEWER_PROMPT = '''You are an independent, skeptical reviewer of a prerelease open-source project. You
149
+ have this archive and nothing else; it is sufficient. Do not say "I could not access
150
+ the repository": every file you need is here, and verify_stdlib.py needs only Python's
151
+ standard library (no pip, no network).
152
+
153
+ Read SPEC.md in full, then README.md. Then:
154
+
155
+ 1. Run `python3 verify_stdlib.py wire_vectors.json`. Report every PASS/FAIL line and
156
+ the count. If you cannot execute, read verify_stdlib.py and state which specific
157
+ step you would have expected to fail and why; a reviewer who cannot run it must
158
+ still read it.
159
+ 2. Read the source files (envelope.py, verifier.py, approval.py, execution.py,
160
+ evidence.py, plans.py, witness.py, reconcile.py, state.py, revert.py, adapters.py,
161
+ consumer.py). For each attack below, point to the exact function and line that
162
+ stops it, or show the input that gets through:
163
+ a. a receipt honored for arguments other than the ones it binds;
164
+ b. a flagged receipt run without a grant from a trusted approver key;
165
+ c. a receipt, grant or plan step reused;
166
+ d. an execution receipt or evidence entry forged with the executor's own key, that
167
+ the audit accepts, with and without an issuer witness present;
168
+ e. a revert the audit marks exact that was not;
169
+ f. a file changed under a gated folder that the tree check misses;
170
+ g. a trust statement in SPEC.md sections 6.1, 8.3, 12, 15.1, 17 the code does not
171
+ uphold;
172
+ h. any place the code fails open: a missing key, an unreachable service, a
173
+ malformed input that is admitted rather than refused.
174
+ 3. If audit_excerpt.txt is present, read it as a real deployment's audit output and
175
+ say what it does and does not establish.
176
+ 4. Maturity for a first client beta: what you would require before letting this gate a
177
+ shared folder or a mailbox, citing the file or spec section that falls short.
178
+
179
+ Findings first, ordered by severity, each with file and line or the exact input. No
180
+ praise, no summary of the README. Where you did not verify, say exactly that.
181
+ '''
182
+
183
+
184
+ def build_packet(repo_root: str, out_path: str, audit_excerpt: Optional[str] = None,
185
+ extra_files: Optional[Iterable[str]] = None) -> Dict[str, Any]:
186
+ root = Path(repo_root)
187
+ names: List[str] = []
188
+ with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as z:
189
+ def add(arc: str, data: bytes) -> None:
190
+ z.writestr(arc, data); names.append(arc)
191
+ for rel in ("SPEC.md", "README.md", "docs/beta-checklist.md", "docs/ally-design.md"):
192
+ p = root / rel
193
+ if p.exists():
194
+ add(Path(rel).name if rel.startswith("docs/") else rel, p.read_bytes())
195
+ for rel in ("tests/fixtures/wire_vectors.json", "tests/fixtures/wire_vectors_v2.json"):
196
+ p = root / rel
197
+ if p.exists():
198
+ add(Path(rel).name, p.read_bytes())
199
+ for name in SOURCE_FILES:
200
+ p = root / "gargoyle" / name
201
+ if p.exists():
202
+ add("source/" + name, p.read_bytes())
203
+ add("verify_stdlib.py", VERIFY_STDLIB.encode("utf-8"))
204
+ add("REVIEWER_PROMPT.txt", REVIEWER_PROMPT.encode("utf-8"))
205
+ if audit_excerpt:
206
+ add("audit_excerpt.txt", audit_excerpt.encode("utf-8"))
207
+ for extra in extra_files or []:
208
+ p = Path(extra)
209
+ if p.exists():
210
+ add("extra/" + p.name, p.read_bytes())
211
+ manifest = {"files": names, "note": "Built by gargoyle review-packet. verify_stdlib.py needs only the Python standard library."}
212
+ z.writestr("MANIFEST.json", json.dumps(manifest, indent=2))
213
+ return {"path": out_path, "files": names + ["MANIFEST.json"]}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gargoyle-receipts
3
- Version: 0.14.2.dev0
3
+ Version: 0.15.1.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.14.2.dev0`, on PyPI as `gargoyle-receipts`). The wire format is `gargoyle/3` and may
62
+ Status: pre-release (`0.15.1.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
@@ -272,6 +272,15 @@ field. A hold can also be announced to Slack or any webhook
272
272
  (`GARGOYLE_HOLD_WEBHOOK_URL`); the notice carries no key, so nothing in a
273
273
  chat channel can approve anything.
274
274
 
275
+ ## Reviewing it
276
+
277
+ `gargoyle review-packet --out packet.zip` builds one archive for an outside
278
+ reviewer: the spec, the vectors, the verification source, a reviewer
279
+ prompt, and `verify_stdlib.py`, which recomputes every conformance vector
280
+ (RFC 8785, SHA-256, Ed25519) with nothing but the Python standard library,
281
+ so a reviewer with no pip and no network can still check every hash and
282
+ signature rather than take the README's word.
283
+
275
284
  ## Deploy it
276
285
 
277
286
  pip install gargoyle-receipts
@@ -383,6 +392,7 @@ cannot attach receipts work unchanged. Configuration is by environment:
383
392
  | issuer `GARGOYLE_WITNESS_PATH` | the issuer's signed, hash-chained record of every decision (`gargoyle issuer` sets `witness.jsonl` beside the keys); `gargoyle audit --witness` reconciles a client's evidence against it, `--check-tree DIR` finds files changed outside the gate |
384
393
  | issuer `GARGOYLE_LOG_JSON` | one JSON line per decision on stderr: issued, refused, granted, declined, plan, enrolled |
385
394
  | `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 |
395
+ | `GARGOYLE_MCP_INSTRUCTIONS` | replaces the standing instructions the gate hands the model in the MCP handshake (default: all work on the target goes through these tools, a wait is normal, a refusal is final, plans and undo exist); the user never has to name the server |
386
396
  | `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 |
387
397
  | `GARGOYLE_MCP_ADAPTER_SPEC` | with `GARGOYLE_MCP_STATE_PROBE=graph`: the tool-to-resource mapping JSON (default: the reference naming) |
388
398
  | `GARGOYLE_MCP_UPSTREAM_URL`, `GARGOYLE_MCP_UPSTREAM_TOKEN` | remote mode: the MCP server to front over Streamable HTTP, and its bearer |
@@ -32,6 +32,7 @@ gargoyle/reconcile.py
32
32
  gargoyle/remote.py
33
33
  gargoyle/replay_cache.py
34
34
  gargoyle/revert.py
35
+ gargoyle/review.py
35
36
  gargoyle/rules.py
36
37
  gargoyle/state.py
37
38
  gargoyle/verifier.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gargoyle-receipts"
7
- version = "0.14.2.dev0"
7
+ version = "0.15.1.dev0"
8
8
  description = "Signed, single-use, argument-bound receipts and human-approval holds for agent tool calls."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -119,3 +119,22 @@ def test_init_for_a_windows_client_and_audit_log_verification(tmp_path, monkeypa
119
119
  assert main(["audit", "--log", str(log), "--out", str(out)]) == 1
120
120
  out_text = capsys.readouterr().out
121
121
  assert "1 failed" in out_text and "REF_EVIDENCE_HASH_MISMATCH" in out_text
122
+
123
+
124
+ def test_review_packet_builds_and_its_verifier_runs_on_plain_python(tmp_path):
125
+ """The packet verifies the real vectors with the standard library only,
126
+ run through an isolated interpreter that has no site-packages."""
127
+ import subprocess, sys, zipfile
128
+ from gargoyle.cli import main
129
+ out = tmp_path / "packet.zip"
130
+ (tmp_path / "audit.txt").write_text("45 entries (42 honored, 3 refused), 0 failed")
131
+ assert main(["review-packet", "--out", str(out), "--audit-excerpt", str(tmp_path / "audit.txt")]) == 0
132
+ with zipfile.ZipFile(out) as z:
133
+ names = set(z.namelist())
134
+ assert {"SPEC.md", "README.md", "wire_vectors.json", "wire_vectors_v2.json", "verify_stdlib.py", "REVIEWER_PROMPT.txt",
135
+ "audit_excerpt.txt", "source/verifier.py", "source/plans.py", "MANIFEST.json"} <= names
136
+ z.extractall(tmp_path / "x")
137
+ r = subprocess.run([sys.executable, "-I", "-S", str(tmp_path / "x" / "verify_stdlib.py"), str(tmp_path / "x" / "wire_vectors.json")],
138
+ capture_output=True, text=True)
139
+ assert r.returncode == 0, r.stdout + r.stderr
140
+ assert "18 checks, 0 failed" in r.stdout and "a tampered receipt does NOT verify" in r.stdout
@@ -229,3 +229,27 @@ def test_every_decision_lands_in_the_evidence_log(tmp_path):
229
229
  approver_public_keys={"approver-alice": approver.public_key()})
230
230
  assert v["accepted"] and (v["honored"], v["refused"]) == (1, 2)
231
231
  assert [r["reason"] for r in v["entries"]] == [ACCEPT_CHAIN_VERIFIED, ACCEPT_ENTRY_VERIFIED, ACCEPT_ENTRY_VERIFIED]
232
+
233
+
234
+ def test_the_gate_hands_the_model_standing_instructions(monkeypatch):
235
+ """The initialize answer carries instructions in both modes, so the user
236
+ never has to name the server; an operator may override the text."""
237
+ from gargoyle.mcp_server import gate_instructions
238
+ client = TestClient(issuer_app())
239
+ state = middleware(client)
240
+ r = handle_message(state, {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})
241
+ text = r["result"]["instructions"]
242
+ assert TARGET in text and "do not use any other tool" in text and "gargoyle_plan" in text and "never needs to name this server" in text
243
+ # Through a real upstream, the upstream's own instructions follow the gate's.
244
+ fixture = str(Path(__file__).parent / "fixtures" / "echo_mcp_server.py")
245
+ up = StdioUpstream([sys.executable, "-I", fixture])
246
+ try:
247
+ state = middleware(client); state.upstream = up
248
+ r = handle_message(state, {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})
249
+ assert r["result"]["instructions"].startswith("Every operation on " + TARGET)
250
+ finally:
251
+ up.close()
252
+ monkeypatch.setenv("GARGOYLE_MCP_INSTRUCTIONS", "House rule: files only.")
253
+ state = middleware(client)
254
+ assert handle_message(state, {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}})["result"]["instructions"] == "House rule: files only."
255
+ assert gate_instructions("t", "Upstream says hi").endswith("Upstream says hi")
@@ -79,6 +79,7 @@ def test_remote_gate_over_stdio_upstream(tmp_path):
79
79
  assert r.status_code == 200 and r.headers[SESSION_HEADER]
80
80
  sid = r.headers[SESSION_HEADER]
81
81
  assert r.json()["result"]["serverInfo"]["name"].startswith("gargoyle+")
82
+ assert "do not use any other tool" in r.json()["result"]["instructions"]
82
83
  assert rpc(c, alice, {"jsonrpc": "2.0", "method": "notifications/initialized"}, sid).status_code == 202
83
84
  # Tools: the upstream's plus undo.
84
85
  tools = rpc(c, alice, {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, sid).json()["result"]["tools"]