protect-mcp 0.7.3 → 0.7.5

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.
@@ -0,0 +1,1053 @@
1
+ import {
2
+ meetsMinTier
3
+ } from "./chunk-VTPZ4G5I.mjs";
4
+ import {
5
+ checkRateLimit,
6
+ getToolPolicy,
7
+ parseRateLimit
8
+ } from "./chunk-WIPWNWMJ.mjs";
9
+
10
+ // src/simulate.ts
11
+ import { readFileSync } from "fs";
12
+ function parseLogFile(path) {
13
+ const raw = readFileSync(path, "utf-8");
14
+ const entries = [];
15
+ for (const line of raw.split("\n")) {
16
+ const trimmed = line.trim();
17
+ if (!trimmed) continue;
18
+ const jsonStr = trimmed.replace(/^\[PROTECT_MCP\]\s*/, "");
19
+ try {
20
+ const parsed = JSON.parse(jsonStr);
21
+ if (parsed.tool && parsed.decision) {
22
+ entries.push(parsed);
23
+ }
24
+ } catch {
25
+ }
26
+ }
27
+ return entries;
28
+ }
29
+ function simulate(entries, policy, tier = "unknown") {
30
+ const rateLimitStore = /* @__PURE__ */ new Map();
31
+ const toolResults = /* @__PURE__ */ new Map();
32
+ const totals = {
33
+ allow: 0,
34
+ block: 0,
35
+ rate_limited: 0,
36
+ require_approval: 0,
37
+ tier_insufficient: 0
38
+ };
39
+ const originalTotals = { allow: 0, deny: 0 };
40
+ const changes = [];
41
+ for (const entry of entries) {
42
+ const toolName = entry.tool;
43
+ const toolPolicy = getToolPolicy(toolName, policy);
44
+ if (entry.decision === "allow") {
45
+ originalTotals.allow++;
46
+ } else {
47
+ originalTotals.deny++;
48
+ }
49
+ let newDecision;
50
+ if (toolPolicy.block) {
51
+ newDecision = "block";
52
+ } else if (toolPolicy.min_tier && !meetsMinTier(tier, toolPolicy.min_tier)) {
53
+ newDecision = "tier_insufficient";
54
+ } else if (toolPolicy.require_approval) {
55
+ newDecision = "require_approval";
56
+ } else if (toolPolicy.rate_limit) {
57
+ const limit = parseRateLimit(toolPolicy.rate_limit);
58
+ const result = checkRateLimit(toolName, limit, rateLimitStore);
59
+ newDecision = result.allowed ? "allow" : "rate_limited";
60
+ } else {
61
+ newDecision = "allow";
62
+ }
63
+ totals[newDecision]++;
64
+ if (!toolResults.has(toolName)) {
65
+ toolResults.set(toolName, {
66
+ tool: toolName,
67
+ calls: 0,
68
+ results: { allow: 0, block: 0, rate_limited: 0, require_approval: 0, tier_insufficient: 0 },
69
+ original: { allow: 0, deny: 0 }
70
+ });
71
+ }
72
+ const tr = toolResults.get(toolName);
73
+ tr.calls++;
74
+ tr.results[newDecision]++;
75
+ if (entry.decision === "allow") {
76
+ tr.original.allow++;
77
+ } else {
78
+ tr.original.deny++;
79
+ }
80
+ }
81
+ for (const [tool, result] of toolResults) {
82
+ const wasAllBlocked = result.original.allow === 0;
83
+ const nowAllBlocked = result.results.allow === 0;
84
+ const wasAllAllowed = result.original.deny === 0;
85
+ if (wasAllAllowed && result.results.block > 0) {
86
+ changes.push(`${tool}: ${result.results.block} calls would be blocked (was: all allowed)`);
87
+ }
88
+ if (wasAllAllowed && result.results.rate_limited > 0) {
89
+ changes.push(`${tool}: ${result.results.rate_limited} calls would be rate-limited (was: all allowed)`);
90
+ }
91
+ if (wasAllAllowed && result.results.require_approval > 0) {
92
+ changes.push(`${tool}: ${result.results.require_approval} calls would require approval (was: all allowed)`);
93
+ }
94
+ if (wasAllAllowed && result.results.tier_insufficient > 0) {
95
+ changes.push(`${tool}: ${result.results.tier_insufficient} calls would fail tier check (was: all allowed)`);
96
+ }
97
+ if (wasAllBlocked && result.results.allow > 0 && !nowAllBlocked) {
98
+ changes.push(`${tool}: ${result.results.allow} calls would now be allowed (was: all blocked)`);
99
+ }
100
+ }
101
+ return {
102
+ policy_file: "",
103
+ log_file: "",
104
+ total_calls: entries.length,
105
+ results: totals,
106
+ original: originalTotals,
107
+ tool_breakdown: Array.from(toolResults.values()).sort((a, b) => b.calls - a.calls),
108
+ changes
109
+ };
110
+ }
111
+ function formatSimulation(summary) {
112
+ const lines = [];
113
+ lines.push(`Simulating ${summary.policy_file} against ${summary.total_calls} recorded tool calls:
114
+ `);
115
+ const maxToolLen = Math.max(...summary.tool_breakdown.map((t) => t.tool.length), 4);
116
+ for (const tr of summary.tool_breakdown) {
117
+ const parts = [];
118
+ if (tr.results.allow > 0) parts.push(`${tr.results.allow} allow`);
119
+ if (tr.results.block > 0) parts.push(`\x1B[31m${tr.results.block} blocked\x1B[0m`);
120
+ if (tr.results.rate_limited > 0) parts.push(`\x1B[33m${tr.results.rate_limited} rate_limited\x1B[0m`);
121
+ if (tr.results.require_approval > 0) parts.push(`\x1B[36m${tr.results.require_approval} require_approval\x1B[0m`);
122
+ if (tr.results.tier_insufficient > 0) parts.push(`\x1B[35m${tr.results.tier_insufficient} tier_insufficient\x1B[0m`);
123
+ const originalParts = [];
124
+ if (tr.original.allow > 0) originalParts.push(`${tr.original.allow} allow`);
125
+ if (tr.original.deny > 0) originalParts.push(`${tr.original.deny} deny`);
126
+ lines.push(` ${tr.tool.padEnd(maxToolLen)} \xD7 ${String(tr.calls).padStart(3)} \u2192 ${parts.join(", ")} (was: ${originalParts.join(", ")})`);
127
+ }
128
+ lines.push("");
129
+ lines.push(`Summary: ${summary.results.allow} allow, ${summary.results.block} blocked, ${summary.results.rate_limited} rate_limited, ${summary.results.require_approval} require_approval, ${summary.results.tier_insufficient} tier_insufficient`);
130
+ lines.push(` vs original: ${summary.original.allow} allow, ${summary.original.deny} deny`);
131
+ if (summary.changes.length > 0) {
132
+ lines.push("");
133
+ lines.push("Changes:");
134
+ for (const change of summary.changes) {
135
+ lines.push(` \u2022 ${change}`);
136
+ }
137
+ }
138
+ return lines.join("\n");
139
+ }
140
+
141
+ // src/policy-packs.ts
142
+ var header = (id, description) => `// ScopeBlind protect-mcp policy pack: ${id}
143
+ // ${description}
144
+ // Start in shadow mode, review receipts, then run with --enforce.
145
+
146
+ `;
147
+ var defaultPermit = `
148
+ // Default posture: allow non-matching calls so teams can start in shadow mode.
149
+ // Tighten this after reviewing your local action dashboard.
150
+ permit(principal, action == Action::"MCP::Tool::call", resource);
151
+ `;
152
+ var filesystemSafe = `${header("filesystem-safe", "Block common destructive filesystem and secret-file access patterns.")}// Destructive file tools are never safe as an unattended default.
153
+ forbid(principal, action == Action::"MCP::Tool::call", resource == Tool::"delete_file");
154
+ forbid(principal, action == Action::"MCP::Tool::call", resource == Tool::"remove_file");
155
+
156
+ // Secret-like reads by path.
157
+ forbid(principal, action == Action::"MCP::Tool::call", resource) when {
158
+ context has "input" && context.input has "path" && (
159
+ context.input.path like "*/.env*" ||
160
+ context.input.path like "*/id_rsa*" ||
161
+ context.input.path like "*/.ssh/*" ||
162
+ context.input.path like "*secret*" ||
163
+ context.input.path like "*credential*"
164
+ )
165
+ };
166
+
167
+ // Dangerous shell operations that mutate or destroy local state.
168
+ forbid(principal, action == Action::"MCP::Tool::call", resource == Tool::"Bash") when {
169
+ context has "command" && (
170
+ context.command like "*rm -rf*" ||
171
+ context.command like "*mkfs*" ||
172
+ context.command like "*dd if=*" ||
173
+ context.command like "*chmod -R 777*" ||
174
+ context.command like "*chown -R*"
175
+ )
176
+ };
177
+ ${defaultPermit}`;
178
+ var gitSafe = `${header("git-safe", "Prevent unattended history rewrites, force pushes, and destructive repo cleanup.")}forbid(principal, action == Action::"MCP::Tool::call", resource == Tool::"Bash") when {
179
+ context has "command" && (
180
+ context.command like "*git push --force*" ||
181
+ context.command like "*git push -f*" ||
182
+ context.command like "*git reset --hard*" ||
183
+ context.command like "*git clean -fd*" ||
184
+ context.command like "*git checkout --*" ||
185
+ context.command like "*git branch -D*" ||
186
+ context.command like "*gh repo delete*"
187
+ )
188
+ };
189
+ ${defaultPermit}`;
190
+ var emailSafe = `${header("email-safe", "Permit drafting but block unattended external sends.")}forbid(principal, action == Action::"MCP::Tool::call", resource == Tool::"mail.send");
191
+ forbid(principal, action == Action::"MCP::Tool::call", resource == Tool::"email.send");
192
+ forbid(principal, action == Action::"MCP::Tool::call", resource == Tool::"send_email");
193
+ forbid(principal, action == Action::"MCP::Tool::call", resource == Tool::"gmail.send");
194
+
195
+ // Shell fallbacks that send mail are blocked too.
196
+ forbid(principal, action == Action::"MCP::Tool::call", resource == Tool::"Bash") when {
197
+ context has "command" && (
198
+ context.command like "*sendmail*" ||
199
+ context.command like "*mailx*" ||
200
+ context.command like "*smtp*"
201
+ )
202
+ };
203
+ ${defaultPermit}`;
204
+ var databaseSafe = `${header("database-safe", "Allow reads, block write/admin SQL unless explicitly approved elsewhere.")}forbid(principal, action == Action::"MCP::Tool::call", resource) when {
205
+ context has "input" && context.input has "query" && (
206
+ context.input.query like "*DROP *" ||
207
+ context.input.query like "*TRUNCATE *" ||
208
+ context.input.query like "*DELETE *" ||
209
+ context.input.query like "*UPDATE *" ||
210
+ context.input.query like "*INSERT *" ||
211
+ context.input.query like "*ALTER *" ||
212
+ context.input.query like "*GRANT *" ||
213
+ context.input.query like "*REVOKE *"
214
+ )
215
+ };
216
+ ${defaultPermit}`;
217
+ var cloudSpendSafe = `${header("cloud-spend-safe", "Block cloud actions that can create spend or destroy infrastructure.")}forbid(principal, action == Action::"MCP::Tool::call", resource == Tool::"Bash") when {
218
+ context has "command" && (
219
+ context.command like "*terraform destroy*" ||
220
+ context.command like "*terraform apply*" ||
221
+ context.command like "*pulumi up*" ||
222
+ context.command like "*pulumi destroy*" ||
223
+ context.command like "*aws ec2 run-instances*" ||
224
+ context.command like "*aws rds create*" ||
225
+ context.command like "*gcloud compute instances create*" ||
226
+ context.command like "*az vm create*" ||
227
+ context.command like "*kubectl delete*"
228
+ )
229
+ };
230
+ ${defaultPermit}`;
231
+ var secretsSafe = `${header("secrets-safe", "Block secret exfiltration from files, env, shell, and common credential tools.")}forbid(principal, action == Action::"MCP::Tool::call", resource) when {
232
+ context has "input" && context.input has "path" && (
233
+ context.input.path like "*/.env*" ||
234
+ context.input.path like "*/.aws/credentials*" ||
235
+ context.input.path like "*/.npmrc*" ||
236
+ context.input.path like "*/.netrc*" ||
237
+ context.input.path like "*/id_rsa*" ||
238
+ context.input.path like "*secret*" ||
239
+ context.input.path like "*token*"
240
+ )
241
+ };
242
+
243
+ forbid(principal, action == Action::"MCP::Tool::call", resource == Tool::"Bash") when {
244
+ context has "command" && (
245
+ context.command like "*printenv*" ||
246
+ context.command like "*env |*" ||
247
+ context.command like "*security find-generic-password*" ||
248
+ context.command like "*aws secretsmanager get-secret-value*" ||
249
+ context.command like "*gcloud secrets versions access*" ||
250
+ context.command like "*op read*"
251
+ )
252
+ };
253
+ ${defaultPermit}`;
254
+ var financeMandateSafe = `${header("finance-mandate-safe", "Block restricted-list and concentration-limit breaches in booking tools.")}forbid(principal, action == Action::"MCP::Tool::call", resource == Tool::"pms.book") when {
255
+ context has "input" && context.input has "on_restricted_list" && context.input.on_restricted_list == true
256
+ };
257
+ forbid(principal, action == Action::"MCP::Tool::call", resource == Tool::"booking.execute") when {
258
+ context has "input" && context.input has "on_restricted_list" && context.input.on_restricted_list == true
259
+ };
260
+ forbid(principal, action == Action::"MCP::Tool::call", resource == Tool::"booking.ticket") when {
261
+ context has "input" && context.input has "on_restricted_list" && context.input.on_restricted_list == true
262
+ };
263
+
264
+ // Default example caps: single-name > 10%, gross > 200%, net > 100%.
265
+ forbid(principal, action == Action::"MCP::Tool::call", resource) when {
266
+ context has "input" && context.input has "post_trade_weight_bps" && context.input.post_trade_weight_bps > 1000
267
+ };
268
+ forbid(principal, action == Action::"MCP::Tool::call", resource) when {
269
+ context has "input" && context.input has "post_trade_gross_exposure_bps" && context.input.post_trade_gross_exposure_bps > 20000
270
+ };
271
+ forbid(principal, action == Action::"MCP::Tool::call", resource) when {
272
+ context has "input" && context.input has "post_trade_net_exposure_bps" && context.input.post_trade_net_exposure_bps > 10000
273
+ };
274
+ ${defaultPermit}`;
275
+ var POLICY_PACKS = [
276
+ {
277
+ id: "filesystem-safe",
278
+ name: "Filesystem Safe",
279
+ description: "Blocks destructive filesystem calls and secret-like path reads.",
280
+ recommendedMode: "shadow-first",
281
+ files: [{ path: "filesystem-safe.cedar", contents: filesystemSafe }]
282
+ },
283
+ {
284
+ id: "git-safe",
285
+ name: "Git Safe",
286
+ description: "Blocks force pushes, hard resets, destructive cleanup, and repo deletion.",
287
+ recommendedMode: "shadow-first",
288
+ files: [{ path: "git-safe.cedar", contents: gitSafe }]
289
+ },
290
+ {
291
+ id: "email-safe",
292
+ name: "Email Safe",
293
+ description: "Allows drafting workflows while blocking unattended sends.",
294
+ recommendedMode: "shadow-first",
295
+ files: [{ path: "email-safe.cedar", contents: emailSafe }]
296
+ },
297
+ {
298
+ id: "database-safe",
299
+ name: "Database Safe",
300
+ description: "Allows read-oriented DB tools while blocking mutating/admin SQL.",
301
+ recommendedMode: "shadow-first",
302
+ files: [{ path: "database-safe.cedar", contents: databaseSafe }]
303
+ },
304
+ {
305
+ id: "cloud-spend-safe",
306
+ name: "Cloud Spend Safe",
307
+ description: "Blocks obvious cloud spend creation and infrastructure destruction.",
308
+ recommendedMode: "shadow-first",
309
+ files: [{ path: "cloud-spend-safe.cedar", contents: cloudSpendSafe }]
310
+ },
311
+ {
312
+ id: "secrets-safe",
313
+ name: "Secrets Safe",
314
+ description: "Blocks common file, env, shell, and cloud secret exfiltration paths.",
315
+ recommendedMode: "enforce-ready",
316
+ files: [{ path: "secrets-safe.cedar", contents: secretsSafe }]
317
+ },
318
+ {
319
+ id: "finance-mandate-safe",
320
+ name: "Finance Mandate Safe",
321
+ description: "Blocks restricted-list and concentration breaches in booking flows.",
322
+ recommendedMode: "shadow-first",
323
+ files: [{ path: "finance-mandate-safe.cedar", contents: financeMandateSafe }]
324
+ }
325
+ ];
326
+ function getPolicyPack(id) {
327
+ return POLICY_PACKS.find((pack) => pack.id === id);
328
+ }
329
+ function policyPackIds() {
330
+ return POLICY_PACKS.map((pack) => pack.id);
331
+ }
332
+
333
+ // src/connector-pilots.ts
334
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
335
+ import { dirname, join, normalize } from "path";
336
+ var defaultPermit2 = `
337
+ // Default posture: observe all non-matching tools so the connector can be piloted in shadow mode.
338
+ permit(principal, action == Action::"MCP::Tool::call", resource);
339
+ `;
340
+ var nautilusBridgePy = String.raw`#!/usr/bin/env python3
341
+ """
342
+ ScopeBlind external bridge for NautilusTrader-compatible pilots.
343
+
344
+ This file is intentionally outside NautilusTrader. It gives protect-mcp a stable
345
+ JSONL command boundary for staging, approval-gated submission, cancellation, and
346
+ event export while keeping the trading engine customer-owned.
347
+
348
+ Mock mode runs without NautilusTrader installed. Real mode is enabled by setting
349
+ NAUTILUS_BRIDGE_MODULE to "module.path:ClassName"; the class may implement:
350
+ submit_order(order), modify_order(order), cancel_order(order), reconcile(order),
351
+ export_events(since=None)
352
+ """
353
+
354
+ from __future__ import annotations
355
+
356
+ import hashlib
357
+ import importlib
358
+ import json
359
+ import os
360
+ import sys
361
+ import time
362
+ from dataclasses import dataclass, field
363
+ from pathlib import Path
364
+ from typing import Any, Callable
365
+
366
+
367
+ def canonical_json(value: Any) -> str:
368
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
369
+
370
+
371
+ def sha256_json(value: Any) -> str:
372
+ return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
373
+
374
+
375
+ def now_ms() -> int:
376
+ return int(time.time() * 1000)
377
+
378
+
379
+ @dataclass
380
+ class BridgeState:
381
+ root: Path = field(default_factory=lambda: Path(os.environ.get("SCOPEBLIND_NAUTILUS_STATE_DIR", ".protect-mcp/nautilus")))
382
+
383
+ def __post_init__(self) -> None:
384
+ self.root.mkdir(parents=True, exist_ok=True)
385
+ self.orders_path.touch(exist_ok=True)
386
+ self.events_path.touch(exist_ok=True)
387
+
388
+ @property
389
+ def orders_path(self) -> Path:
390
+ return self.root / "orders.jsonl"
391
+
392
+ @property
393
+ def events_path(self) -> Path:
394
+ return self.root / "events.jsonl"
395
+
396
+ def append_order(self, order: dict[str, Any]) -> None:
397
+ with self.orders_path.open("a", encoding="utf-8") as handle:
398
+ handle.write(canonical_json(order) + "\n")
399
+
400
+ def append_event(self, event: dict[str, Any]) -> dict[str, Any]:
401
+ enriched = {
402
+ "event_id": event.get("event_id") or f"nt-{now_ms()}-{len(event)}",
403
+ "observed_at_ms": now_ms(),
404
+ **event,
405
+ }
406
+ enriched["event_digest"] = sha256_json(enriched)
407
+ with self.events_path.open("a", encoding="utf-8") as handle:
408
+ handle.write(canonical_json(enriched) + "\n")
409
+ return enriched
410
+
411
+ def events(self) -> list[dict[str, Any]]:
412
+ rows: list[dict[str, Any]] = []
413
+ with self.events_path.open("r", encoding="utf-8") as handle:
414
+ for line in handle:
415
+ if line.strip():
416
+ rows.append(json.loads(line))
417
+ return rows
418
+
419
+
420
+ class ScopeBlindNautilusBridge:
421
+ def __init__(self) -> None:
422
+ self.state = BridgeState()
423
+ self.real = self._load_real_bridge()
424
+
425
+ def _load_real_bridge(self) -> Any | None:
426
+ target = os.environ.get("NAUTILUS_BRIDGE_MODULE")
427
+ if not target:
428
+ return None
429
+ module_name, _, class_name = target.partition(":")
430
+ if not module_name or not class_name:
431
+ raise ValueError("NAUTILUS_BRIDGE_MODULE must be module.path:ClassName")
432
+ module = importlib.import_module(module_name)
433
+ return getattr(module, class_name)()
434
+
435
+ def handle(self, command: dict[str, Any]) -> dict[str, Any]:
436
+ action = command.get("action")
437
+ handlers: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
438
+ "stage_order": self.stage_order,
439
+ "submit_order": self.submit_order,
440
+ "modify_order": self.modify_order,
441
+ "cancel_order": self.cancel_order,
442
+ "reconcile": self.reconcile,
443
+ "export_events": self.export_events,
444
+ }
445
+ if action not in handlers:
446
+ return self.error(command, "unknown_action", f"Unsupported action: {action}")
447
+ try:
448
+ return handlers[action](command)
449
+ except Exception as exc:
450
+ return self.error(command, "bridge_error", str(exc))
451
+
452
+ def require(self, command: dict[str, Any], *fields: str) -> None:
453
+ missing = [field for field in fields if command.get(field) in (None, "")]
454
+ if missing:
455
+ raise ValueError(f"missing required field(s): {', '.join(missing)}")
456
+
457
+ def require_approved(self, command: dict[str, Any]) -> None:
458
+ self.require(command, "approval_receipt")
459
+ if command.get("mandate_passed") is not True:
460
+ raise ValueError("mandate_passed must be true before live order mutation")
461
+
462
+ def stage_order(self, command: dict[str, Any]) -> dict[str, Any]:
463
+ self.require(command, "client_order_id", "instrument_id", "side", "quantity")
464
+ order = self.order_projection(command, status="staged")
465
+ self.state.append_order(order)
466
+ event = self.state.append_event({
467
+ "type": "scopeblind.nautilus.order_staged.v1",
468
+ "client_order_id": order["client_order_id"],
469
+ "order_digest": sha256_json(order),
470
+ "disclosure": "position_blind",
471
+ })
472
+ return self.ok(command, {"status": "staged", "order": order, "event": event})
473
+
474
+ def submit_order(self, command: dict[str, Any]) -> dict[str, Any]:
475
+ self.require_approved(command)
476
+ order = self.order_projection(command, status="submitted")
477
+ if self.real and hasattr(self.real, "submit_order"):
478
+ external = self.real.submit_order(order)
479
+ else:
480
+ external = {"mode": "mock", "external_order_id": f"MOCK-{order['client_order_id']}"}
481
+ event = self.state.append_event({
482
+ "type": "scopeblind.nautilus.order_submitted.v1",
483
+ "client_order_id": order["client_order_id"],
484
+ "order_digest": sha256_json(order),
485
+ "external_digest": sha256_json(external),
486
+ "disclosure": "position_blind",
487
+ })
488
+ return self.ok(command, {"status": "submitted", "order": order, "external": external, "event": event})
489
+
490
+ def modify_order(self, command: dict[str, Any]) -> dict[str, Any]:
491
+ self.require_approved(command)
492
+ self.require(command, "client_order_id")
493
+ if self.real and hasattr(self.real, "modify_order"):
494
+ external = self.real.modify_order(command)
495
+ else:
496
+ external = {"mode": "mock", "modified": command["client_order_id"]}
497
+ event = self.state.append_event({
498
+ "type": "scopeblind.nautilus.order_modified.v1",
499
+ "client_order_id": command["client_order_id"],
500
+ "command_digest": sha256_json(command),
501
+ "external_digest": sha256_json(external),
502
+ "disclosure": "position_blind",
503
+ })
504
+ return self.ok(command, {"status": "modified", "external": external, "event": event})
505
+
506
+ def cancel_order(self, command: dict[str, Any]) -> dict[str, Any]:
507
+ self.require_approved(command)
508
+ self.require(command, "client_order_id")
509
+ if self.real and hasattr(self.real, "cancel_order"):
510
+ external = self.real.cancel_order(command)
511
+ else:
512
+ external = {"mode": "mock", "cancelled": command["client_order_id"]}
513
+ event = self.state.append_event({
514
+ "type": "scopeblind.nautilus.order_cancelled.v1",
515
+ "client_order_id": command["client_order_id"],
516
+ "command_digest": sha256_json(command),
517
+ "external_digest": sha256_json(external),
518
+ "disclosure": "position_blind",
519
+ })
520
+ return self.ok(command, {"status": "cancelled", "external": external, "event": event})
521
+
522
+ def reconcile(self, command: dict[str, Any]) -> dict[str, Any]:
523
+ self.require(command, "client_order_id")
524
+ if self.real and hasattr(self.real, "reconcile"):
525
+ external = self.real.reconcile(command)
526
+ else:
527
+ external = {"mode": "mock", "client_order_id": command["client_order_id"], "state": "accepted"}
528
+ event = self.state.append_event({
529
+ "type": "scopeblind.nautilus.reconciled.v1",
530
+ "client_order_id": command["client_order_id"],
531
+ "external_digest": sha256_json(external),
532
+ "disclosure": "position_blind",
533
+ })
534
+ return self.ok(command, {"status": "reconciled", "external": external, "event": event})
535
+
536
+ def export_events(self, command: dict[str, Any]) -> dict[str, Any]:
537
+ if self.real and hasattr(self.real, "export_events"):
538
+ external_events = self.real.export_events(command.get("since"))
539
+ else:
540
+ external_events = self.state.events()
541
+ return self.ok(command, {
542
+ "status": "exported",
543
+ "event_count": len(external_events),
544
+ "commitment_root": sha256_json(external_events),
545
+ "events": external_events,
546
+ })
547
+
548
+ def order_projection(self, command: dict[str, Any], status: str) -> dict[str, Any]:
549
+ return {
550
+ "client_order_id": command["client_order_id"],
551
+ "instrument_id": command["instrument_id"],
552
+ "side": command["side"],
553
+ "quantity": command["quantity"],
554
+ "price": command.get("price"),
555
+ "time_in_force": command.get("time_in_force", "GTC"),
556
+ "strategy_id": command.get("strategy_id"),
557
+ "mandate_digest": command.get("mandate_digest"),
558
+ "approval_receipt": command.get("approval_receipt"),
559
+ "status": status,
560
+ "created_at_ms": now_ms(),
561
+ }
562
+
563
+ def ok(self, command: dict[str, Any], result: dict[str, Any]) -> dict[str, Any]:
564
+ return {
565
+ "ok": True,
566
+ "bridge": "scopeblind.nautilus.external.v1",
567
+ "mode": "real" if self.real else "mock",
568
+ "request_digest": sha256_json(command),
569
+ **result,
570
+ }
571
+
572
+ def error(self, command: dict[str, Any], code: str, message: str) -> dict[str, Any]:
573
+ return {
574
+ "ok": False,
575
+ "bridge": "scopeblind.nautilus.external.v1",
576
+ "mode": "real" if self.real else "mock",
577
+ "error": {"code": code, "message": message},
578
+ "request_digest": sha256_json(command),
579
+ }
580
+
581
+
582
+ def main() -> int:
583
+ bridge = ScopeBlindNautilusBridge()
584
+ for line in sys.stdin:
585
+ if not line.strip():
586
+ continue
587
+ command = json.loads(line)
588
+ print(canonical_json(bridge.handle(command)), flush=True)
589
+ return 0
590
+
591
+
592
+ if __name__ == "__main__":
593
+ raise SystemExit(main())
594
+ `;
595
+ var nautilusAdapterReadme = `# NautilusTrader-compatible external bridge
596
+
597
+ This connector is intentionally external to NautilusTrader. It lets protect-mcp
598
+ control and receipt high-risk order actions while a customer-owned Nautilus
599
+ process remains the trading engine.
600
+
601
+ ## Local mock run
602
+
603
+ \`\`\`bash
604
+ python3 .protect-mcp/connectors/nautilus-trader/bridge.py <<'JSONL'
605
+ {"action":"stage_order","client_order_id":"SB-1","instrument_id":"AAPL.NASDAQ","side":"BUY","quantity":"50","price":"182.40","mandate_digest":"demo"}
606
+ {"action":"submit_order","client_order_id":"SB-1","instrument_id":"AAPL.NASDAQ","side":"BUY","quantity":"50","price":"182.40","mandate_digest":"demo","mandate_passed":true,"approval_receipt":"receipt-demo"}
607
+ {"action":"export_events"}
608
+ JSONL
609
+ \`\`\`
610
+
611
+ ## Real mode
612
+
613
+ Set \`NAUTILUS_BRIDGE_MODULE=customer_module:BridgeClass\`. The class can
614
+ implement \`submit_order\`, \`modify_order\`, \`cancel_order\`, \`reconcile\`,
615
+ and \`export_events\`. Keep that glue in the customer's repository so Nautilus
616
+ licensing, credentials, and trading logic stay outside ScopeBlind.
617
+
618
+ ## Upstream contribution posture
619
+
620
+ The best NautilusTrader contribution is not this bridge or a UI. It is a small,
621
+ vendor-neutral audit/event sink RFC: a documented way to export normalized order
622
+ commands, execution reports, fills, cancels, and reconciliation events so
623
+ external compliance wrappers can prove what happened without mutating the
624
+ engine.
625
+ `;
626
+ var CONNECTOR_PILOTS = [
627
+ {
628
+ id: "github",
629
+ category: "code",
630
+ name: "GitHub pull-request control",
631
+ status: "usable-pilot",
632
+ description: "Controls GitHub REST/MCP calls for issue, PR, branch, and workflow actions.",
633
+ value: "Useful when agents already have repo access through GitHub MCP, gh, or a GitHub-backed tool server.",
634
+ env: [
635
+ { name: "GITHUB_TOKEN", required: true, description: "Fine-grained token scoped to the pilot repo." },
636
+ { name: "GITHUB_REPOSITORY", required: true, description: "owner/repo target for the pilot." }
637
+ ],
638
+ tools: ["github.rest.request", "github.issue.create", "github.pull_request.merge", "github.workflow.dispatch"],
639
+ actions: [
640
+ { name: "Read repo metadata", tool: "github.rest.request", risk: "low", mode: "observe", description: "GET-only repository and PR inspection." },
641
+ { name: "Create issue or comment", tool: "github.issue.create", risk: "medium", mode: "require_approval", description: "External write to the system of record." },
642
+ { name: "Merge PR / dispatch workflow", tool: "github.pull_request.merge", risk: "high", mode: "require_approval", description: "Code-changing or CI-triggering action." }
643
+ ],
644
+ setup: [
645
+ "Create a fine-grained GitHub token for one repository.",
646
+ "Set GITHUB_TOKEN and GITHUB_REPOSITORY.",
647
+ "Run the agent through protect-mcp and review GitHub tool calls in the dashboard."
648
+ ],
649
+ config: {
650
+ type: "scopeblind.connector_pilot.v1",
651
+ provider: "github",
652
+ target_env: ["GITHUB_TOKEN", "GITHUB_REPOSITORY"],
653
+ safe_read_probe: "GET /repos/{GITHUB_REPOSITORY}",
654
+ controlled_tools: ["github.rest.request", "github.issue.create", "github.pull_request.merge", "github.workflow.dispatch"],
655
+ approval_required_for: ["POST", "PATCH", "PUT", "DELETE", "merge", "workflow_dispatch"],
656
+ receipt_fields: ["method", "path", "repo", "actor", "payload_hash", "approval_reason"]
657
+ },
658
+ cedar: `${defaultPermit2}
659
+ // GitHub pilot: reads are observed; writes and merges need exact-action approval.
660
+ forbid(principal, action == Action::"MCP::Tool::call", resource)
661
+ when { context.tool == "github.pull_request.merge" };
662
+
663
+ forbid(principal, action == Action::"MCP::Tool::call", resource)
664
+ when { context.tool == "github.workflow.dispatch" && !context.approved };
665
+
666
+ forbid(principal, action == Action::"MCP::Tool::call", resource)
667
+ when { context.tool == "github.issue.create" && !context.approved };
668
+ `
669
+ },
670
+ {
671
+ id: "email-gmail",
672
+ category: "communications",
673
+ name: "Gmail self-send / draft approval",
674
+ status: "usable-pilot",
675
+ description: "Uses the existing Gmail OAuth connector path and restricts send mode to email.self for the first production pilot.",
676
+ value: "Makes external communications reviewable before an agent can send mail.",
677
+ env: [
678
+ { name: "GOOGLE_CLIENT_ID", required: true, description: "OAuth client for Gmail." },
679
+ { name: "GOOGLE_CLIENT_SECRET", required: true, description: "OAuth client secret." },
680
+ { name: "CONNECTOR_TOKEN_KEY", required: true, description: "AES-GCM key material for sealed connector tokens." }
681
+ ],
682
+ tools: ["gmail.draft.create", "gmail.send.email_self", "email.send"],
683
+ actions: [
684
+ { name: "Create draft", tool: "gmail.draft.create", risk: "medium", mode: "require_approval", description: "Draft content can leak sensitive information." },
685
+ { name: "Self-send test", tool: "gmail.send.email_self", risk: "medium", mode: "require_approval", description: "First release allows only sending to the account owner." },
686
+ { name: "External send", tool: "email.send", risk: "high", mode: "deny", description: "Direct external send stays blocked until a customer-specific allowlist exists." }
687
+ ],
688
+ setup: [
689
+ "Configure Google OAuth redirect /fn/connectors/gmail/callback.",
690
+ "Connect Gmail through the hosted console or local connector flow.",
691
+ "Keep send mode to email.self until the customer approves recipient allowlists."
692
+ ],
693
+ config: {
694
+ type: "scopeblind.connector_pilot.v1",
695
+ provider: "gmail",
696
+ hosted_functions: ["/fn/connectors/gmail/start", "/fn/connectors/gmail/callback", "/fn/connectors/gmail/send", "/fn/connectors/gmail/status"],
697
+ first_release_scope: "email.self",
698
+ denied_until_configured: ["email.send.external", "email.bulk_send"],
699
+ receipt_fields: ["to_hash", "subject_hash", "body_hash", "approval_reason", "gmail_message_id"]
700
+ },
701
+ cedar: `${defaultPermit2}
702
+ // Email pilot: no direct external send. Draft/self-send require exact approval.
703
+ forbid(principal, action == Action::"MCP::Tool::call", resource)
704
+ when { context.tool == "email.send" };
705
+
706
+ forbid(principal, action == Action::"MCP::Tool::call", resource)
707
+ when { context.tool == "gmail.draft.create" && !context.approved };
708
+
709
+ forbid(principal, action == Action::"MCP::Tool::call", resource)
710
+ when { context.tool == "gmail.send.email_self" && !context.approved };
711
+ `
712
+ },
713
+ {
714
+ id: "filesystem-git",
715
+ category: "local-computer",
716
+ name: "Filesystem and Git control",
717
+ status: "usable-pilot",
718
+ description: "Controls reads, writes, shell commands, and Git mutation in the local project.",
719
+ value: "Immediately useful with Claude Code, Codex, Cursor, and any agent that edits files or runs shell commands.",
720
+ env: [],
721
+ tools: ["Read", "Write", "Edit", "MultiEdit", "Bash", "git.commit", "git.push"],
722
+ actions: [
723
+ { name: "Read files", tool: "Read", risk: "low", mode: "observe", description: "Observe file reads for audit context." },
724
+ { name: "Write/edit files", tool: "Write", risk: "medium", mode: "require_approval", description: "Require approval for sensitive paths or broad rewrites." },
725
+ { name: "Git push/reset", tool: "Bash", risk: "high", mode: "require_approval", description: "Commands that publish, reset, or delete require exact-action approval." }
726
+ ],
727
+ setup: [
728
+ "Run protect-mcp init-hooks in the project.",
729
+ "Install filesystem-safe and Git-safe policy packs.",
730
+ "Review the dashboard before turning on enforce mode."
731
+ ],
732
+ config: {
733
+ type: "scopeblind.connector_pilot.v1",
734
+ provider: "filesystem-git",
735
+ local_only: true,
736
+ protected_paths: [".env", ".ssh", "keys/", "secrets/", "node_modules/"],
737
+ dangerous_command_patterns: ["rm -rf", "git push", "git reset --hard", "curl | sh", "chmod 777"],
738
+ receipt_fields: ["tool", "path_hash", "command_hash", "diff_hash", "approval_reason"]
739
+ },
740
+ cedar: `${defaultPermit2}
741
+ // Filesystem/Git pilot: dangerous shell and protected-path writes need approval.
742
+ forbid(principal, action == Action::"MCP::Tool::call", resource)
743
+ when { context.tool == "Bash" && context.command_pattern.contains("git reset --hard") && !context.approved };
744
+
745
+ forbid(principal, action == Action::"MCP::Tool::call", resource)
746
+ when { context.tool == "Bash" && context.command_pattern.contains("git push") && !context.approved };
747
+
748
+ forbid(principal, action == Action::"MCP::Tool::call", resource)
749
+ when { ["Write", "Edit", "MultiEdit"].contains(context.tool) && context.path.contains(".env") && !context.approved };
750
+ `
751
+ },
752
+ {
753
+ id: "slack-teams",
754
+ category: "communications",
755
+ name: "Slack or Teams outbound approval",
756
+ status: "usable-pilot",
757
+ description: "Controls messages to Slack channels or Microsoft Teams webhooks.",
758
+ value: "Makes high-impact internal broadcasts and client channels approval-gated.",
759
+ env: [
760
+ { name: "SLACK_BOT_TOKEN", required: false, description: "Slack bot token for chat.postMessage pilots." },
761
+ { name: "SLACK_CHANNEL_ID", required: false, description: "Default Slack channel for the pilot." },
762
+ { name: "TEAMS_WEBHOOK_URL", required: false, description: "Teams incoming webhook URL if Teams is preferred." }
763
+ ],
764
+ tools: ["slack.chat.postMessage", "slack.files.upload", "teams.webhook.post"],
765
+ actions: [
766
+ { name: "Post internal message", tool: "slack.chat.postMessage", risk: "medium", mode: "require_approval", description: "Message text and channel are read back before send." },
767
+ { name: "Upload file", tool: "slack.files.upload", risk: "high", mode: "require_approval", description: "Files can leak customer data and need explicit approval." },
768
+ { name: "Teams webhook post", tool: "teams.webhook.post", risk: "medium", mode: "require_approval", description: "Webhook destination and payload hash are receipted." }
769
+ ],
770
+ setup: [
771
+ "Choose Slack or Teams for the first pilot, not both.",
772
+ "Set the relevant token/webhook environment variables.",
773
+ "Start with a private test channel and exact-action approval for every send."
774
+ ],
775
+ config: {
776
+ type: "scopeblind.connector_pilot.v1",
777
+ provider: "slack-or-teams",
778
+ supported_modes: ["slack.chat.postMessage", "teams.webhook.post"],
779
+ require_channel_allowlist: true,
780
+ receipt_fields: ["channel_hash", "message_hash", "file_hash", "approval_reason", "provider_message_id"]
781
+ },
782
+ cedar: `${defaultPermit2}
783
+ // Slack/Teams pilot: all outbound posts and uploads require approval by default.
784
+ forbid(principal, action == Action::"MCP::Tool::call", resource)
785
+ when { ["slack.chat.postMessage", "slack.files.upload", "teams.webhook.post"].contains(context.tool) && !context.approved };
786
+ `
787
+ },
788
+ {
789
+ id: "finance-pms",
790
+ category: "finance",
791
+ name: "Finance PMS mock-to-real adapter",
792
+ status: "usable-pilot",
793
+ description: "Stages orders into a PMS adapter contract, with mock mode locally and real mode through PMS_ADAPTER_URL.",
794
+ value: "Gives hedge funds the controlled booking path: parse, mandate-check, approve, book, corroborate, receipt.",
795
+ env: [
796
+ { name: "PMS_ADAPTER_URL", required: false, description: "Customer-owned adapter endpoint. Omit for local mock mode." },
797
+ { name: "PMS_ADAPTER_TOKEN", required: false, description: "Bearer token for the customer-owned PMS adapter." }
798
+ ],
799
+ tools: ["pms.order.stage", "pms.order.book", "pms.order.cancel", "pms.reconcile"],
800
+ actions: [
801
+ { name: "Stage order", tool: "pms.order.stage", risk: "medium", mode: "require_approval", description: "Creates a booking ticket but does not execute." },
802
+ { name: "Book order", tool: "pms.order.book", risk: "high", mode: "require_approval", description: "Must pass mandate checks and human readback." },
803
+ { name: "Cancel/order correction", tool: "pms.order.cancel", risk: "high", mode: "require_approval", description: "Mutates book state and requires approval." }
804
+ ],
805
+ setup: [
806
+ "Run local mock mode first with the Legate finance pilot pack.",
807
+ "Point PMS_ADAPTER_URL at a customer-owned bridge when ready.",
808
+ "Require mandate checks and exact-action approval before pms.order.book."
809
+ ],
810
+ config: {
811
+ type: "scopeblind.connector_pilot.v1",
812
+ provider: "finance-pms",
813
+ mode: "mock-first",
814
+ adapter_contract: {
815
+ stage: "POST /orders/stage",
816
+ book: "POST /orders/book",
817
+ cancel: "POST /orders/{client_order_id}/cancel",
818
+ reconcile: "GET /orders/{client_order_id}"
819
+ },
820
+ receipt_fields: ["client_order_id", "side", "symbol_hash", "qty", "price", "mandate_digest", "approval_reason", "external_confirmation_hash"]
821
+ },
822
+ cedar: `${defaultPermit2}
823
+ // Finance/PMS pilot: booking actions require mandate pass and exact approval.
824
+ forbid(principal, action == Action::"MCP::Tool::call", resource)
825
+ when { ["pms.order.stage", "pms.order.book", "pms.order.cancel"].contains(context.tool) && !context.approved };
826
+
827
+ forbid(principal, action == Action::"MCP::Tool::call", resource)
828
+ when { context.tool == "pms.order.book" && context.mandate_passed != true };
829
+ `
830
+ },
831
+ {
832
+ id: "nautilus-trader",
833
+ category: "finance",
834
+ name: "NautilusTrader-compatible external bridge",
835
+ status: "usable-pilot",
836
+ description: "Controls NautilusTrader-compatible staged orders through an external JSONL bridge, with local mock mode and customer-owned real mode.",
837
+ value: "Turns Nautilus into a strong Legate demo target: mandate-check, exact approval, external order event, position-blind audit bundle, and later reconciliation.",
838
+ env: [
839
+ { name: "NAUTILUS_BRIDGE_MODULE", required: false, description: "Optional customer glue in module.path:ClassName form for real Nautilus submission." },
840
+ { name: "SCOPEBLIND_NAUTILUS_STATE_DIR", required: false, description: "Optional state directory for local mock events. Defaults to .protect-mcp/nautilus." },
841
+ { name: "NAUTILUS_TRADER_PROJECT", required: false, description: "Optional path to the customer Nautilus project when running real mode." }
842
+ ],
843
+ tools: [
844
+ "nautilus.order.stage",
845
+ "nautilus.order.submit",
846
+ "nautilus.order.modify",
847
+ "nautilus.order.cancel",
848
+ "nautilus.strategy.deploy",
849
+ "nautilus.event.export",
850
+ "nautilus.reconcile"
851
+ ],
852
+ actions: [
853
+ { name: "Stage order", tool: "nautilus.order.stage", risk: "medium", mode: "require_approval", description: "Creates a position-blind booking intent and event commitment." },
854
+ { name: "Submit order", tool: "nautilus.order.submit", risk: "high", mode: "require_approval", description: "Requires mandate pass plus exact approval before live order mutation." },
855
+ { name: "Modify or cancel order", tool: "nautilus.order.modify", risk: "high", mode: "require_approval", description: "Mutates live order state and must carry a fresh approval receipt." },
856
+ { name: "Deploy strategy", tool: "nautilus.strategy.deploy", risk: "high", mode: "require_approval", description: "Requires signed strategy pack, mandate scope, and operator approval." },
857
+ { name: "Export event log", tool: "nautilus.event.export", risk: "low", mode: "observe", description: "Exports normalized event commitments for receipt corroboration." }
858
+ ],
859
+ setup: [
860
+ "Run mock mode first: protect-mcp connectors init nautilus-trader --force.",
861
+ "Pipe stage/submit/reconcile JSONL through .protect-mcp/connectors/nautilus-trader/bridge.py.",
862
+ "For real mode, set NAUTILUS_BRIDGE_MODULE to customer-owned glue that calls NautilusTrader APIs.",
863
+ "Open an upstream NautilusTrader RFC for a neutral audit/event sink before proposing any PR."
864
+ ],
865
+ config: {
866
+ type: "scopeblind.connector_pilot.v1",
867
+ provider: "nautilus-trader-compatible",
868
+ mode: "external-bridge-mock-first",
869
+ license_boundary: "No NautilusTrader code is bundled. Real mode calls a customer-owned process/module.",
870
+ adapter_contract: {
871
+ protocol: "stdin/stdout JSONL",
872
+ bridge: ".protect-mcp/connectors/nautilus-trader/bridge.py",
873
+ real_mode_env: "NAUTILUS_BRIDGE_MODULE=module.path:ClassName",
874
+ actions: ["stage_order", "submit_order", "modify_order", "cancel_order", "reconcile", "export_events"]
875
+ },
876
+ controlled_tools: [
877
+ "nautilus.order.stage",
878
+ "nautilus.order.submit",
879
+ "nautilus.order.modify",
880
+ "nautilus.order.cancel",
881
+ "nautilus.strategy.deploy",
882
+ "nautilus.event.export",
883
+ "nautilus.reconcile"
884
+ ],
885
+ approval_required_for: ["submit_order", "modify_order", "cancel_order", "strategy_deploy"],
886
+ receipt_fields: [
887
+ "client_order_id",
888
+ "instrument_id_hash",
889
+ "side",
890
+ "quantity",
891
+ "price",
892
+ "mandate_digest",
893
+ "approval_receipt",
894
+ "external_event_digest",
895
+ "commitment_root"
896
+ ],
897
+ upstream_rfc: {
898
+ title: "[RFC] Add a vendor-neutral order/execution audit event sink",
899
+ non_goals: ["ScopeBlind dependency", "UI dashboard", "AI tooling", "new venue adapter"]
900
+ }
901
+ },
902
+ artifacts: [
903
+ { path: "nautilus-trader/bridge.py", contents: nautilusBridgePy, executable: true },
904
+ { path: "nautilus-trader/README.md", contents: nautilusAdapterReadme }
905
+ ],
906
+ cedar: `${defaultPermit2}
907
+ // NautilusTrader-compatible pilot: stage can be observed, but any live mutation requires exact approval.
908
+ forbid(principal, action == Action::"MCP::Tool::call", resource)
909
+ when { ["nautilus.order.submit", "nautilus.order.modify", "nautilus.order.cancel", "nautilus.strategy.deploy"].contains(context.tool) && !context.approved };
910
+
911
+ forbid(principal, action == Action::"MCP::Tool::call", resource)
912
+ when { ["nautilus.order.submit", "nautilus.order.modify", "nautilus.order.cancel"].contains(context.tool) && context.mandate_passed != true };
913
+
914
+ forbid(principal, action == Action::"MCP::Tool::call", resource)
915
+ when { context.tool == "nautilus.strategy.deploy" && context.strategy_pack_signed != true };
916
+ `
917
+ }
918
+ ];
919
+ function connectorPilotIds() {
920
+ return CONNECTOR_PILOTS.map((pilot) => pilot.id);
921
+ }
922
+ function getConnectorPilot(id) {
923
+ return CONNECTOR_PILOTS.find((pilot) => pilot.id === id);
924
+ }
925
+ function connectorDirectory(dir) {
926
+ return join(dir, ".protect-mcp", "connectors");
927
+ }
928
+ function writeConnectorPilots(opts) {
929
+ const directory = connectorDirectory(opts.dir);
930
+ mkdirSync(directory, { recursive: true });
931
+ const selected = opts.ids && opts.ids.length > 0 && !opts.ids.includes("all") ? opts.ids.map((id) => {
932
+ const pilot = getConnectorPilot(id);
933
+ if (!pilot) throw new Error(`Unknown connector pilot: ${id}`);
934
+ return pilot;
935
+ }) : CONNECTOR_PILOTS;
936
+ const written = [];
937
+ for (const pilot of selected) {
938
+ const configPath = join(directory, `${pilot.id}.json`);
939
+ const policyPath = join(directory, `${pilot.id}.cedar`);
940
+ if (!opts.force && (existsSync(configPath) || existsSync(policyPath))) {
941
+ throw new Error(`Refusing to overwrite ${pilot.id}. Re-run with --force if intentional.`);
942
+ }
943
+ writeFileSync(configPath, JSON.stringify({ ...pilot.config, id: pilot.id, name: pilot.name, category: pilot.category, tools: pilot.tools, actions: pilot.actions, setup: pilot.setup }, null, 2) + "\n");
944
+ writeFileSync(policyPath, pilot.cedar.endsWith("\n") ? pilot.cedar : `${pilot.cedar}
945
+ `);
946
+ written.push(configPath, policyPath);
947
+ for (const artifact of pilot.artifacts || []) {
948
+ const artifactPath = connectorArtifactPath(directory, artifact.path);
949
+ mkdirSync(dirname(artifactPath), { recursive: true });
950
+ writeFileSync(artifactPath, artifact.contents.endsWith("\n") ? artifact.contents : `${artifact.contents}
951
+ `);
952
+ if (artifact.executable) chmodSync(artifactPath, 493);
953
+ written.push(artifactPath);
954
+ }
955
+ }
956
+ writeFileSync(join(directory, "README.md"), renderConnectorReadme(selected));
957
+ written.push(join(directory, "README.md"));
958
+ return { written, pilots: selected, directory };
959
+ }
960
+ function connectorArtifactPath(directory, relativePath) {
961
+ const clean = normalize(relativePath).replace(/^(\.\.(\/|\\|$))+/, "");
962
+ if (clean.startsWith("/") || clean.includes("..")) {
963
+ throw new Error(`Unsafe connector artifact path: ${relativePath}`);
964
+ }
965
+ return join(directory, clean);
966
+ }
967
+ function readInstalledConnectorPilots(dir) {
968
+ const directory = connectorDirectory(dir);
969
+ if (!existsSync(directory)) return [];
970
+ return readdirSync(directory).filter((name) => name.endsWith(".json")).map((name) => {
971
+ const configPath = join(directory, name);
972
+ try {
973
+ const parsed = JSON.parse(readFileSync2(configPath, "utf-8"));
974
+ const id = String(parsed.id || name.replace(/\.json$/, ""));
975
+ const pilot = getConnectorPilot(id);
976
+ return {
977
+ id,
978
+ name: String(parsed.name || pilot?.name || id),
979
+ category: String(parsed.category || pilot?.category || "unknown"),
980
+ status: String(parsed.status || parsed.type || "installed"),
981
+ config_path: configPath,
982
+ policy_path: join(directory, `${id}.cedar`)
983
+ };
984
+ } catch {
985
+ return null;
986
+ }
987
+ }).filter(Boolean);
988
+ }
989
+ function connectorDoctor(dir, env = process.env) {
990
+ const installed = new Set(readInstalledConnectorPilots(dir).map((pilot) => pilot.id));
991
+ return CONNECTOR_PILOTS.map((pilot) => {
992
+ const envRows = pilot.env.map((item) => ({
993
+ name: item.name,
994
+ required: item.required,
995
+ present: Boolean(env[item.name]),
996
+ description: item.description
997
+ }));
998
+ const missingRequired = envRows.filter((item) => item.required && !item.present).map((item) => item.name);
999
+ const optionalPresent = envRows.filter((item) => !item.required && item.present).map((item) => item.name);
1000
+ const optionalProviderReady = pilot.id === "slack-teams" ? Boolean(env.SLACK_BOT_TOKEN || env.TEAMS_WEBHOOK_URL) : pilot.id === "finance-pms" ? Boolean(env.PMS_ADAPTER_URL) : pilot.id === "nautilus-trader" ? Boolean(env.NAUTILUS_BRIDGE_MODULE || env.NAUTILUS_TRADER_PROJECT) : false;
1001
+ const mockModeReady = pilot.id === "finance-pms" || pilot.id === "nautilus-trader";
1002
+ return {
1003
+ id: pilot.id,
1004
+ name: pilot.name,
1005
+ category: pilot.category,
1006
+ installed: installed.has(pilot.id),
1007
+ usable: missingRequired.length === 0 && (pilot.env.some((item) => item.required) || pilot.env.length === 0 || optionalProviderReady || mockModeReady),
1008
+ mode: pilot.id === "finance-pms" && !env.PMS_ADAPTER_URL ? "mock" : pilot.id === "nautilus-trader" && !env.NAUTILUS_BRIDGE_MODULE ? "mock_bridge" : pilot.id === "slack-teams" && !env.SLACK_BOT_TOKEN && !env.TEAMS_WEBHOOK_URL ? "needs_provider_choice" : "configured_or_local",
1009
+ missing_required: missingRequired,
1010
+ optional_present: optionalPresent,
1011
+ tools: pilot.tools,
1012
+ next: missingRequired.length > 0 ? `Set ${missingRequired.join(", ")}` : installed.has(pilot.id) ? "Run through protect-mcp and inspect the dashboard." : `Install with protect-mcp connectors init ${pilot.id}`
1013
+ };
1014
+ });
1015
+ }
1016
+ function renderConnectorReadme(pilots) {
1017
+ return `# protect-mcp connector pilots
1018
+
1019
+ These files make real tool classes visible and controllable without uploading raw prompts or payloads.
1020
+
1021
+ ${pilots.map((pilot) => `## ${pilot.name}
1022
+
1023
+ ${pilot.description}
1024
+
1025
+ Value: ${pilot.value}
1026
+
1027
+ Tools: ${pilot.tools.map((tool) => `\`${tool}\``).join(", ")}
1028
+
1029
+ Setup:
1030
+ ${pilot.setup.map((step) => `- ${step}`).join("\n")}
1031
+ ${pilot.artifacts?.length ? `
1032
+ Generated files:
1033
+ ${pilot.artifacts.map((artifact) => `- \`${artifact.path}\``).join("\n")}
1034
+ ` : ""}`).join("\n")}
1035
+ Next: run \`npx protect-mcp dashboard --open\` and review tool inventory, policy coverage, approvals, and receipts.
1036
+ `;
1037
+ }
1038
+
1039
+ export {
1040
+ parseLogFile,
1041
+ simulate,
1042
+ formatSimulation,
1043
+ POLICY_PACKS,
1044
+ getPolicyPack,
1045
+ policyPackIds,
1046
+ CONNECTOR_PILOTS,
1047
+ connectorPilotIds,
1048
+ getConnectorPilot,
1049
+ connectorDirectory,
1050
+ writeConnectorPilots,
1051
+ readInstalledConnectorPilots,
1052
+ connectorDoctor
1053
+ };