protect-mcp 0.7.2 → 0.7.4

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,662 @@
1
+ import {
2
+ meetsMinTier
3
+ } from "./chunk-UBZJ3VI2.mjs";
4
+ import {
5
+ checkRateLimit,
6
+ getToolPolicy,
7
+ parseRateLimit
8
+ } from "./chunk-D2RDY2JR.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 { existsSync, mkdirSync, readdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
335
+ import { join } 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 CONNECTOR_PILOTS = [
341
+ {
342
+ id: "github",
343
+ category: "code",
344
+ name: "GitHub pull-request control",
345
+ status: "usable-pilot",
346
+ description: "Controls GitHub REST/MCP calls for issue, PR, branch, and workflow actions.",
347
+ value: "Useful when agents already have repo access through GitHub MCP, gh, or a GitHub-backed tool server.",
348
+ env: [
349
+ { name: "GITHUB_TOKEN", required: true, description: "Fine-grained token scoped to the pilot repo." },
350
+ { name: "GITHUB_REPOSITORY", required: true, description: "owner/repo target for the pilot." }
351
+ ],
352
+ tools: ["github.rest.request", "github.issue.create", "github.pull_request.merge", "github.workflow.dispatch"],
353
+ actions: [
354
+ { name: "Read repo metadata", tool: "github.rest.request", risk: "low", mode: "observe", description: "GET-only repository and PR inspection." },
355
+ { name: "Create issue or comment", tool: "github.issue.create", risk: "medium", mode: "require_approval", description: "External write to the system of record." },
356
+ { name: "Merge PR / dispatch workflow", tool: "github.pull_request.merge", risk: "high", mode: "require_approval", description: "Code-changing or CI-triggering action." }
357
+ ],
358
+ setup: [
359
+ "Create a fine-grained GitHub token for one repository.",
360
+ "Set GITHUB_TOKEN and GITHUB_REPOSITORY.",
361
+ "Run the agent through protect-mcp and review GitHub tool calls in the dashboard."
362
+ ],
363
+ config: {
364
+ type: "scopeblind.connector_pilot.v1",
365
+ provider: "github",
366
+ target_env: ["GITHUB_TOKEN", "GITHUB_REPOSITORY"],
367
+ safe_read_probe: "GET /repos/{GITHUB_REPOSITORY}",
368
+ controlled_tools: ["github.rest.request", "github.issue.create", "github.pull_request.merge", "github.workflow.dispatch"],
369
+ approval_required_for: ["POST", "PATCH", "PUT", "DELETE", "merge", "workflow_dispatch"],
370
+ receipt_fields: ["method", "path", "repo", "actor", "payload_hash", "approval_reason"]
371
+ },
372
+ cedar: `${defaultPermit2}
373
+ // GitHub pilot: reads are observed; writes and merges need exact-action approval.
374
+ forbid(principal, action == Action::"MCP::Tool::call", resource)
375
+ when { context.tool == "github.pull_request.merge" };
376
+
377
+ forbid(principal, action == Action::"MCP::Tool::call", resource)
378
+ when { context.tool == "github.workflow.dispatch" && !context.approved };
379
+
380
+ forbid(principal, action == Action::"MCP::Tool::call", resource)
381
+ when { context.tool == "github.issue.create" && !context.approved };
382
+ `
383
+ },
384
+ {
385
+ id: "email-gmail",
386
+ category: "communications",
387
+ name: "Gmail self-send / draft approval",
388
+ status: "usable-pilot",
389
+ description: "Uses the existing Gmail OAuth connector path and restricts send mode to email.self for the first production pilot.",
390
+ value: "Makes external communications reviewable before an agent can send mail.",
391
+ env: [
392
+ { name: "GOOGLE_CLIENT_ID", required: true, description: "OAuth client for Gmail." },
393
+ { name: "GOOGLE_CLIENT_SECRET", required: true, description: "OAuth client secret." },
394
+ { name: "CONNECTOR_TOKEN_KEY", required: true, description: "AES-GCM key material for sealed connector tokens." }
395
+ ],
396
+ tools: ["gmail.draft.create", "gmail.send.email_self", "email.send"],
397
+ actions: [
398
+ { name: "Create draft", tool: "gmail.draft.create", risk: "medium", mode: "require_approval", description: "Draft content can leak sensitive information." },
399
+ { name: "Self-send test", tool: "gmail.send.email_self", risk: "medium", mode: "require_approval", description: "First release allows only sending to the account owner." },
400
+ { name: "External send", tool: "email.send", risk: "high", mode: "deny", description: "Direct external send stays blocked until a customer-specific allowlist exists." }
401
+ ],
402
+ setup: [
403
+ "Configure Google OAuth redirect /fn/connectors/gmail/callback.",
404
+ "Connect Gmail through the hosted console or local connector flow.",
405
+ "Keep send mode to email.self until the customer approves recipient allowlists."
406
+ ],
407
+ config: {
408
+ type: "scopeblind.connector_pilot.v1",
409
+ provider: "gmail",
410
+ hosted_functions: ["/fn/connectors/gmail/start", "/fn/connectors/gmail/callback", "/fn/connectors/gmail/send", "/fn/connectors/gmail/status"],
411
+ first_release_scope: "email.self",
412
+ denied_until_configured: ["email.send.external", "email.bulk_send"],
413
+ receipt_fields: ["to_hash", "subject_hash", "body_hash", "approval_reason", "gmail_message_id"]
414
+ },
415
+ cedar: `${defaultPermit2}
416
+ // Email pilot: no direct external send. Draft/self-send require exact approval.
417
+ forbid(principal, action == Action::"MCP::Tool::call", resource)
418
+ when { context.tool == "email.send" };
419
+
420
+ forbid(principal, action == Action::"MCP::Tool::call", resource)
421
+ when { context.tool == "gmail.draft.create" && !context.approved };
422
+
423
+ forbid(principal, action == Action::"MCP::Tool::call", resource)
424
+ when { context.tool == "gmail.send.email_self" && !context.approved };
425
+ `
426
+ },
427
+ {
428
+ id: "filesystem-git",
429
+ category: "local-computer",
430
+ name: "Filesystem and Git control",
431
+ status: "usable-pilot",
432
+ description: "Controls reads, writes, shell commands, and Git mutation in the local project.",
433
+ value: "Immediately useful with Claude Code, Codex, Cursor, and any agent that edits files or runs shell commands.",
434
+ env: [],
435
+ tools: ["Read", "Write", "Edit", "MultiEdit", "Bash", "git.commit", "git.push"],
436
+ actions: [
437
+ { name: "Read files", tool: "Read", risk: "low", mode: "observe", description: "Observe file reads for audit context." },
438
+ { name: "Write/edit files", tool: "Write", risk: "medium", mode: "require_approval", description: "Require approval for sensitive paths or broad rewrites." },
439
+ { name: "Git push/reset", tool: "Bash", risk: "high", mode: "require_approval", description: "Commands that publish, reset, or delete require exact-action approval." }
440
+ ],
441
+ setup: [
442
+ "Run protect-mcp init-hooks in the project.",
443
+ "Install filesystem-safe and Git-safe policy packs.",
444
+ "Review the dashboard before turning on enforce mode."
445
+ ],
446
+ config: {
447
+ type: "scopeblind.connector_pilot.v1",
448
+ provider: "filesystem-git",
449
+ local_only: true,
450
+ protected_paths: [".env", ".ssh", "keys/", "secrets/", "node_modules/"],
451
+ dangerous_command_patterns: ["rm -rf", "git push", "git reset --hard", "curl | sh", "chmod 777"],
452
+ receipt_fields: ["tool", "path_hash", "command_hash", "diff_hash", "approval_reason"]
453
+ },
454
+ cedar: `${defaultPermit2}
455
+ // Filesystem/Git pilot: dangerous shell and protected-path writes need approval.
456
+ forbid(principal, action == Action::"MCP::Tool::call", resource)
457
+ when { context.tool == "Bash" && context.command_pattern.contains("git reset --hard") && !context.approved };
458
+
459
+ forbid(principal, action == Action::"MCP::Tool::call", resource)
460
+ when { context.tool == "Bash" && context.command_pattern.contains("git push") && !context.approved };
461
+
462
+ forbid(principal, action == Action::"MCP::Tool::call", resource)
463
+ when { ["Write", "Edit", "MultiEdit"].contains(context.tool) && context.path.contains(".env") && !context.approved };
464
+ `
465
+ },
466
+ {
467
+ id: "slack-teams",
468
+ category: "communications",
469
+ name: "Slack or Teams outbound approval",
470
+ status: "usable-pilot",
471
+ description: "Controls messages to Slack channels or Microsoft Teams webhooks.",
472
+ value: "Makes high-impact internal broadcasts and client channels approval-gated.",
473
+ env: [
474
+ { name: "SLACK_BOT_TOKEN", required: false, description: "Slack bot token for chat.postMessage pilots." },
475
+ { name: "SLACK_CHANNEL_ID", required: false, description: "Default Slack channel for the pilot." },
476
+ { name: "TEAMS_WEBHOOK_URL", required: false, description: "Teams incoming webhook URL if Teams is preferred." }
477
+ ],
478
+ tools: ["slack.chat.postMessage", "slack.files.upload", "teams.webhook.post"],
479
+ actions: [
480
+ { name: "Post internal message", tool: "slack.chat.postMessage", risk: "medium", mode: "require_approval", description: "Message text and channel are read back before send." },
481
+ { name: "Upload file", tool: "slack.files.upload", risk: "high", mode: "require_approval", description: "Files can leak customer data and need explicit approval." },
482
+ { name: "Teams webhook post", tool: "teams.webhook.post", risk: "medium", mode: "require_approval", description: "Webhook destination and payload hash are receipted." }
483
+ ],
484
+ setup: [
485
+ "Choose Slack or Teams for the first pilot, not both.",
486
+ "Set the relevant token/webhook environment variables.",
487
+ "Start with a private test channel and exact-action approval for every send."
488
+ ],
489
+ config: {
490
+ type: "scopeblind.connector_pilot.v1",
491
+ provider: "slack-or-teams",
492
+ supported_modes: ["slack.chat.postMessage", "teams.webhook.post"],
493
+ require_channel_allowlist: true,
494
+ receipt_fields: ["channel_hash", "message_hash", "file_hash", "approval_reason", "provider_message_id"]
495
+ },
496
+ cedar: `${defaultPermit2}
497
+ // Slack/Teams pilot: all outbound posts and uploads require approval by default.
498
+ forbid(principal, action == Action::"MCP::Tool::call", resource)
499
+ when { ["slack.chat.postMessage", "slack.files.upload", "teams.webhook.post"].contains(context.tool) && !context.approved };
500
+ `
501
+ },
502
+ {
503
+ id: "finance-pms",
504
+ category: "finance",
505
+ name: "Finance PMS mock-to-real adapter",
506
+ status: "usable-pilot",
507
+ description: "Stages orders into a PMS adapter contract, with mock mode locally and real mode through PMS_ADAPTER_URL.",
508
+ value: "Gives hedge funds the controlled booking path: parse, mandate-check, approve, book, corroborate, receipt.",
509
+ env: [
510
+ { name: "PMS_ADAPTER_URL", required: false, description: "Customer-owned adapter endpoint. Omit for local mock mode." },
511
+ { name: "PMS_ADAPTER_TOKEN", required: false, description: "Bearer token for the customer-owned PMS adapter." }
512
+ ],
513
+ tools: ["pms.order.stage", "pms.order.book", "pms.order.cancel", "pms.reconcile"],
514
+ actions: [
515
+ { name: "Stage order", tool: "pms.order.stage", risk: "medium", mode: "require_approval", description: "Creates a booking ticket but does not execute." },
516
+ { name: "Book order", tool: "pms.order.book", risk: "high", mode: "require_approval", description: "Must pass mandate checks and human readback." },
517
+ { name: "Cancel/order correction", tool: "pms.order.cancel", risk: "high", mode: "require_approval", description: "Mutates book state and requires approval." }
518
+ ],
519
+ setup: [
520
+ "Run local mock mode first with the Legate finance pilot pack.",
521
+ "Point PMS_ADAPTER_URL at a customer-owned bridge when ready.",
522
+ "Require mandate checks and exact-action approval before pms.order.book."
523
+ ],
524
+ config: {
525
+ type: "scopeblind.connector_pilot.v1",
526
+ provider: "finance-pms",
527
+ mode: "mock-first",
528
+ adapter_contract: {
529
+ stage: "POST /orders/stage",
530
+ book: "POST /orders/book",
531
+ cancel: "POST /orders/{client_order_id}/cancel",
532
+ reconcile: "GET /orders/{client_order_id}"
533
+ },
534
+ receipt_fields: ["client_order_id", "side", "symbol_hash", "qty", "price", "mandate_digest", "approval_reason", "external_confirmation_hash"]
535
+ },
536
+ cedar: `${defaultPermit2}
537
+ // Finance/PMS pilot: booking actions require mandate pass and exact approval.
538
+ forbid(principal, action == Action::"MCP::Tool::call", resource)
539
+ when { ["pms.order.stage", "pms.order.book", "pms.order.cancel"].contains(context.tool) && !context.approved };
540
+
541
+ forbid(principal, action == Action::"MCP::Tool::call", resource)
542
+ when { context.tool == "pms.order.book" && context.mandate_passed != true };
543
+ `
544
+ }
545
+ ];
546
+ function connectorPilotIds() {
547
+ return CONNECTOR_PILOTS.map((pilot) => pilot.id);
548
+ }
549
+ function getConnectorPilot(id) {
550
+ return CONNECTOR_PILOTS.find((pilot) => pilot.id === id);
551
+ }
552
+ function connectorDirectory(dir) {
553
+ return join(dir, ".protect-mcp", "connectors");
554
+ }
555
+ function writeConnectorPilots(opts) {
556
+ const directory = connectorDirectory(opts.dir);
557
+ mkdirSync(directory, { recursive: true });
558
+ const selected = opts.ids && opts.ids.length > 0 && !opts.ids.includes("all") ? opts.ids.map((id) => {
559
+ const pilot = getConnectorPilot(id);
560
+ if (!pilot) throw new Error(`Unknown connector pilot: ${id}`);
561
+ return pilot;
562
+ }) : CONNECTOR_PILOTS;
563
+ const written = [];
564
+ for (const pilot of selected) {
565
+ const configPath = join(directory, `${pilot.id}.json`);
566
+ const policyPath = join(directory, `${pilot.id}.cedar`);
567
+ if (!opts.force && (existsSync(configPath) || existsSync(policyPath))) {
568
+ throw new Error(`Refusing to overwrite ${pilot.id}. Re-run with --force if intentional.`);
569
+ }
570
+ 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");
571
+ writeFileSync(policyPath, pilot.cedar.endsWith("\n") ? pilot.cedar : `${pilot.cedar}
572
+ `);
573
+ written.push(configPath, policyPath);
574
+ }
575
+ writeFileSync(join(directory, "README.md"), renderConnectorReadme(selected));
576
+ written.push(join(directory, "README.md"));
577
+ return { written, pilots: selected, directory };
578
+ }
579
+ function readInstalledConnectorPilots(dir) {
580
+ const directory = connectorDirectory(dir);
581
+ if (!existsSync(directory)) return [];
582
+ return readdirSync(directory).filter((name) => name.endsWith(".json")).map((name) => {
583
+ const configPath = join(directory, name);
584
+ try {
585
+ const parsed = JSON.parse(readFileSync2(configPath, "utf-8"));
586
+ const id = String(parsed.id || name.replace(/\.json$/, ""));
587
+ const pilot = getConnectorPilot(id);
588
+ return {
589
+ id,
590
+ name: String(parsed.name || pilot?.name || id),
591
+ category: String(parsed.category || pilot?.category || "unknown"),
592
+ status: String(parsed.status || parsed.type || "installed"),
593
+ config_path: configPath,
594
+ policy_path: join(directory, `${id}.cedar`)
595
+ };
596
+ } catch {
597
+ return null;
598
+ }
599
+ }).filter(Boolean);
600
+ }
601
+ function connectorDoctor(dir, env = process.env) {
602
+ const installed = new Set(readInstalledConnectorPilots(dir).map((pilot) => pilot.id));
603
+ return CONNECTOR_PILOTS.map((pilot) => {
604
+ const envRows = pilot.env.map((item) => ({
605
+ name: item.name,
606
+ required: item.required,
607
+ present: Boolean(env[item.name]),
608
+ description: item.description
609
+ }));
610
+ const missingRequired = envRows.filter((item) => item.required && !item.present).map((item) => item.name);
611
+ const optionalPresent = envRows.filter((item) => !item.required && item.present).map((item) => item.name);
612
+ const optionalProviderReady = pilot.id === "slack-teams" ? Boolean(env.SLACK_BOT_TOKEN || env.TEAMS_WEBHOOK_URL) : pilot.id === "finance-pms" ? Boolean(env.PMS_ADAPTER_URL) : false;
613
+ const mockModeReady = pilot.id === "finance-pms";
614
+ return {
615
+ id: pilot.id,
616
+ name: pilot.name,
617
+ category: pilot.category,
618
+ installed: installed.has(pilot.id),
619
+ usable: missingRequired.length === 0 && (pilot.env.some((item) => item.required) || pilot.env.length === 0 || optionalProviderReady || mockModeReady),
620
+ mode: pilot.id === "finance-pms" && !env.PMS_ADAPTER_URL ? "mock" : pilot.id === "slack-teams" && !env.SLACK_BOT_TOKEN && !env.TEAMS_WEBHOOK_URL ? "needs_provider_choice" : "configured_or_local",
621
+ missing_required: missingRequired,
622
+ optional_present: optionalPresent,
623
+ tools: pilot.tools,
624
+ 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}`
625
+ };
626
+ });
627
+ }
628
+ function renderConnectorReadme(pilots) {
629
+ return `# protect-mcp connector pilots
630
+
631
+ These files make real tool classes visible and controllable without uploading raw prompts or payloads.
632
+
633
+ ${pilots.map((pilot) => `## ${pilot.name}
634
+
635
+ ${pilot.description}
636
+
637
+ Value: ${pilot.value}
638
+
639
+ Tools: ${pilot.tools.map((tool) => `\`${tool}\``).join(", ")}
640
+
641
+ Setup:
642
+ ${pilot.setup.map((step) => `- ${step}`).join("\n")}
643
+ `).join("\n")}
644
+ Next: run \`npx protect-mcp dashboard --open\` and review tool inventory, policy coverage, approvals, and receipts.
645
+ `;
646
+ }
647
+
648
+ export {
649
+ parseLogFile,
650
+ simulate,
651
+ formatSimulation,
652
+ POLICY_PACKS,
653
+ getPolicyPack,
654
+ policyPackIds,
655
+ CONNECTOR_PILOTS,
656
+ connectorPilotIds,
657
+ getConnectorPilot,
658
+ connectorDirectory,
659
+ writeConnectorPilots,
660
+ readInstalledConnectorPilots,
661
+ connectorDoctor
662
+ };