@switchboard-mcp/core 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -1
  3. package/dist/audit/audit-log.d.ts +8 -1
  4. package/dist/audit/audit-log.d.ts.map +1 -1
  5. package/dist/audit/audit-log.js +13 -4
  6. package/dist/audit/audit-log.js.map +1 -1
  7. package/dist/audit/repo-audit.d.ts +37 -0
  8. package/dist/audit/repo-audit.d.ts.map +1 -0
  9. package/dist/audit/repo-audit.js +261 -0
  10. package/dist/audit/repo-audit.js.map +1 -0
  11. package/dist/authority/authority-map.d.ts +112 -0
  12. package/dist/authority/authority-map.d.ts.map +1 -0
  13. package/dist/authority/authority-map.js +296 -0
  14. package/dist/authority/authority-map.js.map +1 -0
  15. package/dist/authority/authority-status.d.ts +24 -0
  16. package/dist/authority/authority-status.d.ts.map +1 -0
  17. package/dist/authority/authority-status.js +76 -0
  18. package/dist/authority/authority-status.js.map +1 -0
  19. package/dist/config/load-config.d.ts.map +1 -1
  20. package/dist/config/load-config.js +2 -1
  21. package/dist/config/load-config.js.map +1 -1
  22. package/dist/import/import-plan.d.ts +78 -3
  23. package/dist/import/import-plan.d.ts.map +1 -1
  24. package/dist/import/import-plan.js +609 -20
  25. package/dist/import/import-plan.js.map +1 -1
  26. package/dist/index.d.ts +5 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +5 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/mandates/mandates.d.ts +91 -5
  31. package/dist/mandates/mandates.d.ts.map +1 -1
  32. package/dist/mandates/mandates.js +78 -3
  33. package/dist/mandates/mandates.js.map +1 -1
  34. package/dist/manifest/route-diff.d.ts +44 -0
  35. package/dist/manifest/route-diff.d.ts.map +1 -0
  36. package/dist/manifest/route-diff.js +95 -0
  37. package/dist/manifest/route-diff.js.map +1 -0
  38. package/dist/next-actions/next-actions.d.ts +12 -0
  39. package/dist/next-actions/next-actions.d.ts.map +1 -0
  40. package/dist/next-actions/next-actions.js +40 -0
  41. package/dist/next-actions/next-actions.js.map +1 -0
  42. package/dist/providers/provider-add.js +1 -1
  43. package/dist/providers/provider-add.js.map +1 -1
  44. package/dist/providers/provider-templates.d.ts.map +1 -1
  45. package/dist/providers/provider-templates.js +125 -3
  46. package/dist/providers/provider-templates.js.map +1 -1
  47. package/dist/scan/scan.d.ts +7 -0
  48. package/dist/scan/scan.d.ts.map +1 -1
  49. package/dist/scan/scan.js +106 -2
  50. package/dist/scan/scan.js.map +1 -1
  51. package/dist/schemas/config.d.ts +40 -8
  52. package/dist/schemas/config.d.ts.map +1 -1
  53. package/dist/schemas/config.js +15 -1
  54. package/dist/schemas/config.js.map +1 -1
  55. package/dist/secrets/secrets.d.ts +29 -0
  56. package/dist/secrets/secrets.d.ts.map +1 -1
  57. package/dist/secrets/secrets.js +81 -0
  58. package/dist/secrets/secrets.js.map +1 -1
  59. package/package.json +5 -5
@@ -1,11 +1,14 @@
1
1
  import { constants, readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
3
4
  import { basename, dirname, join, resolve } from "node:path";
4
5
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
6
+ import { planAuthorityStatus } from "../authority/authority-status.js";
5
7
  import { deepMerge, loadSwitchboardConfig } from "../config/load-config.js";
6
8
  import { resolveRepoConfigPaths } from "../config/paths.js";
7
9
  import { resolveProjectClientConfigPath } from "../install/client-config.js";
8
10
  import { normalizeNamespace } from "../namespaces/namespaces.js";
11
+ import { planRecommendedNextAction } from "../next-actions/next-actions.js";
9
12
  import { switchboardConfigSchema } from "../schemas/config.js";
10
13
  export const importPlanSchemaVersion = "switchboard.import-plan.v1";
11
14
  const providerEnvPrefixes = {
@@ -18,7 +21,11 @@ const providerEnvPrefixes = {
18
21
  export async function createSwitchboardImportPlan(options = {}) {
19
22
  const cwd = resolve(options.cwd ?? process.cwd());
20
23
  const repoName = basename(cwd);
21
- const loaded = loadSwitchboardConfig({ cwd });
24
+ const loaded = loadSwitchboardConfig({
25
+ cwd,
26
+ ...(options.env ? { env: options.env } : {}),
27
+ ...(options.homeDir ? { homeDir: options.homeDir } : {})
28
+ });
22
29
  const profileNames = new Set(Object.keys(loaded.config.profiles));
23
30
  const switchboardProfiles = Object.entries(loaded.config.profiles).map(([name, profile]) => ({
24
31
  name,
@@ -28,6 +35,23 @@ export async function createSwitchboardImportPlan(options = {}) {
28
35
  const clients = detectClientConfigs(cwd, repoName);
29
36
  const envFiles = detectEnvFiles(cwd);
30
37
  const detectedServers = clients.flatMap((client) => client.servers.filter((server) => server.name !== "switchboard"));
38
+ const acceptedDirect = collectAcceptedDirectRiskIds({
39
+ configured: loaded.config.acceptedRisks.directMcp.map((risk) => risk.id),
40
+ requested: options.acceptDirect ?? []
41
+ });
42
+ const bypassFindings = buildBypassFindings({
43
+ cwd,
44
+ ...(options.homeDir ? { homeDir: options.homeDir } : {}),
45
+ clients,
46
+ switchboardProfiles,
47
+ acceptedDirect
48
+ });
49
+ const riskFindings = buildRiskFindings({
50
+ envFiles,
51
+ clients,
52
+ bypassFindings
53
+ });
54
+ const cleanupPlan = buildClientCleanupPlan(clients, acceptedDirect);
31
55
  const createProfileActions = uniqueServersByProfile(detectedServers)
32
56
  .filter((server) => !profileNames.has(server.suggestedProfileName))
33
57
  .map((server) => createProfileAction(server));
@@ -75,18 +99,42 @@ export async function createSwitchboardImportPlan(options = {}) {
75
99
  ...installActions,
76
100
  ...reviewActions
77
101
  ];
78
- const warnings = buildWarnings({ clients, envFiles, detectedServers });
102
+ const warnings = buildWarnings({
103
+ clients,
104
+ envFiles,
105
+ detectedServers,
106
+ riskFindings,
107
+ bypassFindings
108
+ });
79
109
  const safetyNotes = [
80
110
  "Dry run only: this command does not write .switchboard.yaml or client config.",
81
111
  "Secret values are never read from env files or client config; only env variable names are reported.",
82
112
  "Imported MCP servers should be treated as setup candidates until doctor and preset checks pass.",
83
- "Run client install separately so existing Codex/Claude config remains backup-protected and reversible."
113
+ "Run client install separately so existing Codex/Claude config remains backup-protected and reversible.",
114
+ "Cleanup backups are exact rollback copies of original client config and may contain any raw values that were already there."
84
115
  ];
116
+ const cleanupPlanned = cleanupPlan.some((item) => item.status === "planned");
85
117
  const nextActions = [
86
- "switchboard import --dry-run",
118
+ ...(cleanupPlanned ? ["switchboard import --write --cleanup-client"] : []),
87
119
  ...secretCommands.map((command) => renderCommand(command)),
88
- ...installClients.map((command) => renderCommand(command))
120
+ ...installClients.map((command) => renderCommand(command)),
121
+ ...(cleanupPlanned ? [] : ["switchboard import --dry-run"])
89
122
  ];
123
+ const recommendedNextAction = planRecommendedNextAction(importNextActionCandidates({
124
+ secretCommands,
125
+ cleanupPlan,
126
+ installClients,
127
+ preferCleanup: cleanupPlanned
128
+ }));
129
+ const authorityStatus = planAuthorityStatus({
130
+ diagnostics: loaded.diagnostics,
131
+ invalidClientConfigs: clients.some((client) => client.status === "invalid"),
132
+ bypassFindings,
133
+ riskFindings,
134
+ switchboardConfigured: switchboardProfiles.length > 0,
135
+ switchboardInstalled: clients.some((client) => client.servers.some((server) => server.routesThroughSwitchboard)),
136
+ recommendedNextAction
137
+ });
90
138
  return {
91
139
  ok: true,
92
140
  schemaVersion: importPlanSchemaVersion,
@@ -97,18 +145,66 @@ export async function createSwitchboardImportPlan(options = {}) {
97
145
  switchboardProfiles,
98
146
  envFiles
99
147
  },
148
+ riskFindings,
149
+ bypassFindings,
150
+ authorityStatus,
151
+ cleanupPlan,
100
152
  actions,
101
153
  commands: {
102
154
  dryRun: { command: "switchboard", args: ["import", "--dry-run"] },
103
155
  writePreview: { command: "switchboard", args: ["import", "--write"] },
156
+ cleanupClient: {
157
+ command: "switchboard",
158
+ args: ["import", "--write", "--cleanup-client"]
159
+ },
104
160
  installClients,
105
161
  secretCommands
106
162
  },
107
163
  warnings,
108
164
  safetyNotes,
165
+ recommendedNextAction,
109
166
  nextActions: [...new Set(nextActions)]
110
167
  };
111
168
  }
169
+ function importNextActionCandidates(options) {
170
+ const candidates = [];
171
+ if (options.preferCleanup &&
172
+ options.cleanupPlan.some((item) => item.status === "planned")) {
173
+ candidates.push({
174
+ kind: "bypass-cleanup",
175
+ command: "switchboard import --write --cleanup-client",
176
+ reason: "Remove direct MCP bypass routes from active client config with backups."
177
+ });
178
+ }
179
+ for (const command of options.secretCommands) {
180
+ candidates.push({
181
+ kind: "missing-secret",
182
+ command: renderCommand(command),
183
+ reason: "Store the token behind a local secretRef before routing agents."
184
+ });
185
+ }
186
+ if (!options.preferCleanup &&
187
+ options.cleanupPlan.some((item) => item.status === "planned")) {
188
+ candidates.push({
189
+ kind: "bypass-cleanup",
190
+ command: "switchboard import --write --cleanup-client",
191
+ reason: "Remove direct MCP bypass routes from active client config with backups."
192
+ });
193
+ }
194
+ for (const command of options.installClients) {
195
+ candidates.push({
196
+ kind: "client-install",
197
+ command: renderCommand(command),
198
+ reason: "Route this project client through Switchboard MCP."
199
+ });
200
+ }
201
+ candidates.push({
202
+ kind: "info",
203
+ command: "switchboard import --dry-run",
204
+ reason: "Review the import plan without writing files."
205
+ });
206
+ return candidates;
207
+ }
112
208
  export async function writeSwitchboardImportPlan(options = {}) {
113
209
  const plan = await createSwitchboardImportPlan(options);
114
210
  const cwd = plan.repo.cwd;
@@ -116,34 +212,57 @@ export async function writeSwitchboardImportPlan(options = {}) {
116
212
  const importableServers = uniqueServersByProfile(plan.detected.clients.flatMap((client) => client.servers.filter((server) => !server.routesThroughSwitchboard &&
117
213
  server.command !== null &&
118
214
  !plan.detected.switchboardProfiles.some((profile) => profile.name === server.suggestedProfileName))));
215
+ const acceptedDirectRisks = plan.bypassFindings.filter((finding) => finding.status === "accepted" &&
216
+ (options.acceptDirect ?? []).includes(finding.id));
119
217
  if (importableServers.length === 0) {
218
+ const acceptedRiskWrite = acceptedDirectRisks.length > 0
219
+ ? await writeAcceptedDirectRisks(targetPath, acceptedDirectRisks, options.now)
220
+ : { backupPath: null, nextContent: null };
221
+ const clientCleanup = options.cleanupClient
222
+ ? await writeClientCleanupPlan(plan, options.now)
223
+ : [];
224
+ const refreshedPlan = await createSwitchboardImportPlan({
225
+ cwd,
226
+ ...(options.env ? { env: options.env } : {}),
227
+ ...(options.homeDir ? { homeDir: options.homeDir } : {})
228
+ });
120
229
  return {
121
230
  ok: true,
122
231
  schemaVersion: importPlanSchemaVersion,
123
- action: "noop",
232
+ action: acceptedRiskWrite.nextContent ? "updated" : "noop",
124
233
  targetPath,
125
- backupPath: null,
126
- plan,
234
+ backupPath: acceptedRiskWrite.backupPath,
235
+ plan: refreshedPlan,
127
236
  createdProfiles: [],
128
- nextContent: null
237
+ clientCleanup,
238
+ nextContent: acceptedRiskWrite.nextContent
129
239
  };
130
240
  }
131
241
  assertNoNamespaceCollisions(plan, importableServers);
132
242
  const existing = await readOptionalTextFileAsync(targetPath);
133
- const nextContent = renderMergedImportConfig(existing, importableServers);
243
+ const nextContent = renderMergedImportConfig(existing, importableServers, acceptedDirectRisks);
134
244
  await mkdir(dirname(targetPath), { recursive: true });
135
245
  const backupPath = existing
136
246
  ? await backupExistingFile(targetPath, options.now)
137
247
  : null;
138
248
  await writeFile(targetPath, nextContent, "utf8");
249
+ const clientCleanup = options.cleanupClient
250
+ ? await writeClientCleanupPlan(plan, options.now)
251
+ : [];
252
+ const refreshedPlan = await createSwitchboardImportPlan({
253
+ cwd,
254
+ ...(options.env ? { env: options.env } : {}),
255
+ ...(options.homeDir ? { homeDir: options.homeDir } : {})
256
+ });
139
257
  return {
140
258
  ok: true,
141
259
  schemaVersion: importPlanSchemaVersion,
142
260
  action: existing ? "updated" : "created",
143
261
  targetPath,
144
262
  backupPath,
145
- plan,
263
+ plan: refreshedPlan,
146
264
  createdProfiles: importableServers.map((server) => server.suggestedProfileName),
265
+ clientCleanup,
147
266
  nextContent
148
267
  };
149
268
  }
@@ -166,12 +285,14 @@ function assertNoNamespaceCollisions(plan, servers) {
166
285
  planned.set(namespace, server.suggestedProfileName);
167
286
  }
168
287
  }
169
- function renderMergedImportConfig(existingContent, servers) {
170
- const rendered = renderImportConfig(servers);
288
+ function renderMergedImportConfig(existingContent, servers, acceptedRisks = []) {
289
+ const existing = existingContent?.trim()
290
+ ? parseConfigYaml(existingContent, ".switchboard.yaml")
291
+ : {};
292
+ const rendered = renderImportConfig(servers, mergeAcceptedDirectRisks(existing, acceptedRisks));
171
293
  if (!existingContent?.trim()) {
172
294
  return `${stringifyYaml(rendered, { lineWidth: 0 })}`;
173
295
  }
174
- const existing = parseConfigYaml(existingContent, ".switchboard.yaml");
175
296
  const merged = deepMerge(existing, rendered);
176
297
  const parsed = switchboardConfigSchema.safeParse(merged);
177
298
  if (!parsed.success) {
@@ -181,7 +302,44 @@ function renderMergedImportConfig(existingContent, servers) {
181
302
  lineWidth: 0
182
303
  })}`;
183
304
  }
184
- function renderImportConfig(servers) {
305
+ function mergeAcceptedDirectRisks(existing, acceptedRisks) {
306
+ const byId = new Map();
307
+ const existingRisks = isRecord(existing.acceptedRisks)
308
+ ? existing.acceptedRisks
309
+ : {};
310
+ const directMcp = Array.isArray(existingRisks.directMcp)
311
+ ? existingRisks.directMcp
312
+ : [];
313
+ for (const risk of directMcp) {
314
+ if (isRecord(risk) &&
315
+ typeof risk.id === "string" &&
316
+ (risk.client === "codex" || risk.client === "claude") &&
317
+ typeof risk.serverName === "string") {
318
+ byId.set(risk.id, {
319
+ id: risk.id,
320
+ status: "accepted",
321
+ severity: "medium",
322
+ client: risk.client,
323
+ targetPath: "",
324
+ serverName: risk.serverName,
325
+ provider: "unknown",
326
+ command: null,
327
+ args: [],
328
+ envKeys: [],
329
+ suggestedProfileName: "",
330
+ riskTags: ["direct-mcp-server"],
331
+ reasons: [],
332
+ nextActions: [],
333
+ acceptedRiskGuidance: "This direct route is marked as accepted risk; it remains visible and prevents controlled authority status."
334
+ });
335
+ }
336
+ }
337
+ for (const risk of acceptedRisks) {
338
+ byId.set(risk.id, risk);
339
+ }
340
+ return [...byId.values()];
341
+ }
342
+ function renderImportConfig(servers, acceptedRisks = []) {
185
343
  const profiles = Object.fromEntries(servers.map((server) => [
186
344
  server.suggestedProfileName,
187
345
  {
@@ -198,18 +356,41 @@ function renderImportConfig(servers) {
198
356
  }
199
357
  }
200
358
  ]));
201
- return {
359
+ const config = {
202
360
  version: 1,
203
361
  defaults: {},
204
362
  profiles,
205
- workspaces: {
363
+ policies: {}
364
+ };
365
+ if (servers.length > 0) {
366
+ config.workspaces = {
206
367
  default: {
207
368
  paths: ["."],
208
369
  profiles: servers.map((server) => server.suggestedProfileName)
209
370
  }
210
- },
211
- policies: {}
212
- };
371
+ };
372
+ }
373
+ if (acceptedRisks.length > 0) {
374
+ config.acceptedRisks = {
375
+ directMcp: acceptedRisks.map((risk) => ({
376
+ id: risk.id,
377
+ client: risk.client,
378
+ serverName: risk.serverName
379
+ }))
380
+ };
381
+ }
382
+ return config;
383
+ }
384
+ async function writeAcceptedDirectRisks(targetPath, acceptedRisks, now) {
385
+ const existing = await readOptionalTextFileAsync(targetPath);
386
+ const nextContent = renderMergedImportConfig(existing, [], acceptedRisks);
387
+ if (existing === nextContent) {
388
+ return { backupPath: null, nextContent: null };
389
+ }
390
+ await mkdir(dirname(targetPath), { recursive: true });
391
+ const backupPath = existing ? await backupExistingFile(targetPath, now) : null;
392
+ await writeFile(targetPath, nextContent, "utf8");
393
+ return { backupPath, nextContent };
213
394
  }
214
395
  function renderImportedEnv(server) {
215
396
  const env = Object.fromEntries(server.suggestedSecretRefs.map((secret) => [
@@ -425,8 +606,408 @@ function buildWarnings(options) {
425
606
  if (options.detectedServers.some((server) => server.envKeys.some((key) => isSecretLikeName(key)))) {
426
607
  warnings.push("Existing MCP configs reference secret-looking env names; store values behind Switchboard local token aliases before routing agents.");
427
608
  }
609
+ if (options.bypassFindings && options.bypassFindings.length > 0) {
610
+ warnings.push("Direct MCP servers bypass Switchboard authority; review bypass findings before giving agents this repo.");
611
+ }
612
+ const criticalOrHighRisks = options.riskFindings.filter((finding) => finding.severity === "critical" || finding.severity === "high");
613
+ if (criticalOrHighRisks.length > 0) {
614
+ warnings.push(`${criticalOrHighRisks.length} high-risk provider/environment hint(s) were detected; review risk findings before creating mandates.`);
615
+ }
428
616
  return [...new Set(warnings)];
429
617
  }
618
+ export function buildRiskFindings(options) {
619
+ const findings = [];
620
+ const acceptedBypassIds = new Set(options.bypassFindings
621
+ .filter((finding) => finding.status === "accepted")
622
+ .map((finding) => finding.id));
623
+ for (const file of options.envFiles) {
624
+ for (const envKey of file.envKeys) {
625
+ const provider = providerFromEnvName(envKey);
626
+ if (looksProductionLikeEnvName(envKey)) {
627
+ findings.push({
628
+ id: `env:${file.path}:${envKey}:prod`,
629
+ kind: "prod_env_hint",
630
+ severity: "high",
631
+ provider,
632
+ source: {
633
+ kind: "env-file",
634
+ path: file.path
635
+ },
636
+ evidence: [envKey],
637
+ reason: "This env name looks production/live-scoped; agents should not receive it through a default non-prod mandate.",
638
+ nextActions: [
639
+ "Use a non-prod/test token alias for setup.",
640
+ "Create an explicit production mandate only if this access is intentional."
641
+ ]
642
+ });
643
+ }
644
+ if (isSecretLikeName(envKey)) {
645
+ findings.push({
646
+ id: `env:${file.path}:${envKey}:secret`,
647
+ kind: "secret_env_hint",
648
+ severity: "medium",
649
+ provider,
650
+ source: {
651
+ kind: "env-file",
652
+ path: file.path
653
+ },
654
+ evidence: [envKey],
655
+ reason: "This env name looks secret-like; agents should receive it only through an intentional profile secretRef.",
656
+ nextActions: [
657
+ "Review whether this value belongs in an agent profile.",
658
+ "Store agent-needed values with switchboard secrets set <ref> --value-stdin."
659
+ ]
660
+ });
661
+ }
662
+ if (provider === "stripe" && looksStripeLiveOrAmbiguousKey(envKey)) {
663
+ findings.push({
664
+ id: `env:${file.path}:${envKey}:stripe-live`,
665
+ kind: "live_payment_key_hint",
666
+ severity: looksProductionLikeEnvName(envKey) ? "critical" : "high",
667
+ provider,
668
+ source: {
669
+ kind: "env-file",
670
+ path: file.path
671
+ },
672
+ evidence: [envKey],
673
+ reason: "This Stripe env name is live-looking or mode-ambiguous; stripe-test should use an explicit test-mode secretRef.",
674
+ nextActions: [
675
+ "switchboard setup stripe-test",
676
+ "Store a test-mode Stripe key in the suggested local secretRef."
677
+ ]
678
+ });
679
+ }
680
+ if (provider === "supabase" && /SERVICE[_-]?ROLE|ADMIN|ROOT/i.test(envKey)) {
681
+ findings.push({
682
+ id: `env:${file.path}:${envKey}:database-write`,
683
+ kind: "database_write_surface",
684
+ severity: "critical",
685
+ provider,
686
+ source: {
687
+ kind: "env-file",
688
+ path: file.path
689
+ },
690
+ evidence: [envKey],
691
+ reason: "This Supabase env name looks admin/write-capable; future database mandates should not mount it by default.",
692
+ nextActions: [
693
+ "Prefer anon/read-only/dev credentials for agent setup.",
694
+ "Defer production database writes to an explicit approval-gated mandate."
695
+ ]
696
+ });
697
+ }
698
+ }
699
+ }
700
+ for (const client of options.clients) {
701
+ for (const server of client.servers) {
702
+ if (server.routesThroughSwitchboard) {
703
+ continue;
704
+ }
705
+ if (acceptedBypassIds.has(`${client.client}:${server.name}`)) {
706
+ continue;
707
+ }
708
+ const adminEvidence = [
709
+ ...server.envKeys.filter((key) => /ADMIN|ROOT|SERVICE[_-]?ROLE/i.test(key)),
710
+ ...server.args.filter((arg) => /--tools=all|admin|service[_-]?role/i.test(arg))
711
+ ];
712
+ if (adminEvidence.length === 0) {
713
+ continue;
714
+ }
715
+ findings.push({
716
+ id: `client:${client.client}:${server.name}:provider-admin`,
717
+ kind: "provider_admin_surface",
718
+ severity: "high",
719
+ provider: server.provider,
720
+ source: {
721
+ kind: "client-config",
722
+ path: client.targetPath,
723
+ detail: server.name
724
+ },
725
+ evidence: [...new Set(adminEvidence)],
726
+ reason: "This direct MCP server appears to expose broad provider/admin capability outside Switchboard policy.",
727
+ nextActions: [
728
+ "switchboard import --dry-run",
729
+ "switchboard import --write --cleanup-client"
730
+ ]
731
+ });
732
+ }
733
+ }
734
+ const providerRoutes = new Map();
735
+ for (const client of options.clients) {
736
+ for (const server of client.servers) {
737
+ if (server.routesThroughSwitchboard || server.provider === "unknown") {
738
+ continue;
739
+ }
740
+ const routes = providerRoutes.get(server.provider) ?? [];
741
+ routes.push({ client: client.client, server: server.name });
742
+ providerRoutes.set(server.provider, routes);
743
+ }
744
+ }
745
+ for (const [provider, routes] of providerRoutes) {
746
+ if (routes.length < 2) {
747
+ continue;
748
+ }
749
+ findings.push({
750
+ id: `client:${provider}:duplicate-direct-route`,
751
+ kind: "duplicate_provider_route",
752
+ severity: "medium",
753
+ provider: provider,
754
+ source: {
755
+ kind: "client-config",
756
+ path: routes.map((route) => `${route.client}:${route.server}`).join(", ")
757
+ },
758
+ evidence: routes.map((route) => `${route.client}:${route.server}`),
759
+ reason: "Multiple direct MCP routes expose the same provider outside one repo-scoped Switchboard authority path.",
760
+ nextActions: [
761
+ "switchboard import --dry-run",
762
+ "switchboard import --write --cleanup-client"
763
+ ]
764
+ });
765
+ }
766
+ return dedupeRiskFindings(findings);
767
+ }
768
+ function dedupeRiskFindings(findings) {
769
+ const seen = new Set();
770
+ return findings.filter((finding) => {
771
+ if (seen.has(finding.id)) {
772
+ return false;
773
+ }
774
+ seen.add(finding.id);
775
+ return true;
776
+ });
777
+ }
778
+ export function buildBypassFindings(options) {
779
+ const hasSwitchboardClientRoute = options.clients.some((client) => client.servers.some((server) => server.routesThroughSwitchboard));
780
+ const home = options.homeDir ?? homedir();
781
+ return options.clients.flatMap((client) => client.servers
782
+ .filter((server) => !server.routesThroughSwitchboard)
783
+ .map((server) => {
784
+ const riskTags = ["direct-mcp-server"];
785
+ const reasons = [
786
+ `${client.client} server "${server.name}" can give agents tools without Switchboard mandate policy, leases, approvals, or audit.`
787
+ ];
788
+ if (hasSwitchboardClientRoute) {
789
+ riskTags.push("switchboard-coexists");
790
+ reasons.push("This direct MCP route coexists with a Switchboard client route, so agents can bypass the intended control plane.");
791
+ }
792
+ if (providerOverlapsSwitchboard(server, options.switchboardProfiles)) {
793
+ riskTags.push("provider-overlap");
794
+ reasons.push(`This server appears to overlap a configured Switchboard ${server.provider} profile.`);
795
+ }
796
+ if (server.envKeys.some((key) => isSecretLikeName(key))) {
797
+ riskTags.push("secret-env-name");
798
+ reasons.push("The server references secret-looking env names; Switchboard should keep values behind local secretRefs.");
799
+ }
800
+ if (server.args.some((arg) => arg.includes("[redacted]"))) {
801
+ riskTags.push("token-like-arg");
802
+ reasons.push("The server command args include token-like material; values were redacted from output.");
803
+ }
804
+ if (looksLikeBroadFilesystemMount(server, options.cwd, home)) {
805
+ riskTags.push("broad-filesystem-mount");
806
+ reasons.push("The server appears to mount a broad filesystem path such as /, $HOME, or the repo parent.");
807
+ }
808
+ const severity = riskTags.includes("broad-filesystem-mount")
809
+ ? "critical"
810
+ : riskTags.some((tag) => ["provider-overlap", "secret-env-name", "token-like-arg"].includes(tag))
811
+ ? "high"
812
+ : "medium";
813
+ const id = `${client.client}:${server.name}`;
814
+ const accepted = options.acceptedDirect?.has(id) ?? false;
815
+ return {
816
+ id,
817
+ status: accepted ? "accepted" : "unaccepted",
818
+ severity,
819
+ client: client.client,
820
+ targetPath: client.targetPath,
821
+ serverName: server.name,
822
+ provider: server.provider,
823
+ command: server.command,
824
+ args: server.args,
825
+ envKeys: server.envKeys,
826
+ suggestedProfileName: server.suggestedProfileName,
827
+ riskTags: [...new Set(riskTags)],
828
+ reasons,
829
+ nextActions: [
830
+ "switchboard import --dry-run",
831
+ server.suggestedSecretRefs.length > 0
832
+ ? `switchboard secrets set ${server.suggestedSecretRefs[0]?.ref ?? "<ref>"} --value-stdin`
833
+ : `switchboard import --write`
834
+ ],
835
+ acceptedRiskGuidance: accepted
836
+ ? "This direct route is marked as accepted risk; it remains visible and prevents controlled authority status."
837
+ : `If this direct route is intentional, rerun with --accept-direct ${id} to preserve it as accepted risk.`
838
+ };
839
+ }));
840
+ }
841
+ function collectAcceptedDirectRiskIds(options) {
842
+ return new Set([...options.configured, ...options.requested]
843
+ .map((value) => value.trim())
844
+ .filter((value) => /^[a-z]+:[^:\s]+$/.test(value)));
845
+ }
846
+ function providerOverlapsSwitchboard(server, profiles) {
847
+ if (server.provider === "unknown") {
848
+ return false;
849
+ }
850
+ return profiles.some((profile) => {
851
+ const provider = profile.provider?.toLowerCase();
852
+ return (provider === server.provider ||
853
+ profile.name.toLowerCase().includes(server.provider) ||
854
+ (profile.namespace?.toLowerCase().includes(server.provider) ?? false));
855
+ });
856
+ }
857
+ function looksLikeBroadFilesystemMount(server, cwd, homeDir) {
858
+ const joined = [server.name, server.command ?? "", ...server.args].join(" ");
859
+ if (!/filesystem|file-system|fs|desktop-commander/i.test(joined)) {
860
+ return false;
861
+ }
862
+ const repoParent = dirname(resolve(cwd));
863
+ return server.args.some((arg) => {
864
+ const normalized = arg.replace(/^['"]|['"]$/g, "");
865
+ return (normalized === "/" ||
866
+ normalized === homeDir ||
867
+ normalized === "$HOME" ||
868
+ normalized === "~" ||
869
+ normalized === repoParent);
870
+ });
871
+ }
872
+ function buildClientCleanupPlan(clients, acceptedDirect) {
873
+ return clients.map((client) => {
874
+ const affectedServerNames = client.servers
875
+ .filter((server) => !server.routesThroughSwitchboard &&
876
+ !acceptedDirect.has(`${client.client}:${server.name}`))
877
+ .map((server) => server.name);
878
+ const acceptedServerNames = client.servers
879
+ .filter((server) => !server.routesThroughSwitchboard &&
880
+ acceptedDirect.has(`${client.client}:${server.name}`))
881
+ .map((server) => server.name);
882
+ const acceptedRiskGuidance = acceptedServerNames.length > 0
883
+ ? `Accepted direct route(s) preserved: ${acceptedServerNames.join(", ")}. They remain visible and keep authority status at partial-control.`
884
+ : affectedServerNames.length > 0
885
+ ? `If any direct route is intentional, rerun with --accept-direct <client:server> to preserve it as accepted risk.`
886
+ : "No direct MCP routes need accepted-risk handling.";
887
+ if (client.status === "missing") {
888
+ return {
889
+ client: client.client,
890
+ targetPath: client.targetPath,
891
+ status: "missing",
892
+ affectedServerNames: [],
893
+ rollbackCommand: null,
894
+ acceptedRiskGuidance
895
+ };
896
+ }
897
+ if (client.status === "invalid") {
898
+ return {
899
+ client: client.client,
900
+ targetPath: client.targetPath,
901
+ status: "invalid",
902
+ affectedServerNames: [],
903
+ rollbackCommand: null,
904
+ acceptedRiskGuidance
905
+ };
906
+ }
907
+ return {
908
+ client: client.client,
909
+ targetPath: client.targetPath,
910
+ status: affectedServerNames.length > 0 ? "planned" : "noop",
911
+ affectedServerNames,
912
+ rollbackCommand: affectedServerNames.length > 0
913
+ ? `cp <backupPath> ${shellQuotePath(client.targetPath)}`
914
+ : null,
915
+ acceptedRiskGuidance
916
+ };
917
+ });
918
+ }
919
+ async function writeClientCleanupPlan(plan, now) {
920
+ const results = [];
921
+ for (const item of plan.cleanupPlan) {
922
+ if (item.status !== "planned") {
923
+ results.push({
924
+ client: item.client,
925
+ targetPath: item.targetPath,
926
+ status: item.status,
927
+ backupPath: null,
928
+ affectedServerNames: item.affectedServerNames,
929
+ rollbackCommand: item.rollbackCommand,
930
+ acceptedRiskGuidance: item.acceptedRiskGuidance
931
+ });
932
+ continue;
933
+ }
934
+ const existing = await readOptionalTextFileAsync(item.targetPath);
935
+ if (existing === null) {
936
+ results.push({
937
+ client: item.client,
938
+ targetPath: item.targetPath,
939
+ status: "missing",
940
+ backupPath: null,
941
+ affectedServerNames: [],
942
+ rollbackCommand: null,
943
+ acceptedRiskGuidance: item.acceptedRiskGuidance
944
+ });
945
+ continue;
946
+ }
947
+ const nextContent = item.client === "claude"
948
+ ? removeClaudeMcpServers(existing, item.affectedServerNames)
949
+ : removeCodexMcpServers(existing, item.affectedServerNames);
950
+ if (nextContent === existing) {
951
+ results.push({
952
+ client: item.client,
953
+ targetPath: item.targetPath,
954
+ status: "noop",
955
+ backupPath: null,
956
+ affectedServerNames: [],
957
+ rollbackCommand: null,
958
+ acceptedRiskGuidance: item.acceptedRiskGuidance
959
+ });
960
+ continue;
961
+ }
962
+ const backupPath = await backupExistingFile(item.targetPath, now);
963
+ await writeFile(item.targetPath, nextContent, "utf8");
964
+ results.push({
965
+ client: item.client,
966
+ targetPath: item.targetPath,
967
+ status: "updated",
968
+ backupPath,
969
+ affectedServerNames: item.affectedServerNames,
970
+ rollbackCommand: `cp ${shellQuotePath(backupPath)} ${shellQuotePath(item.targetPath)}`,
971
+ acceptedRiskGuidance: item.acceptedRiskGuidance
972
+ });
973
+ }
974
+ return results;
975
+ }
976
+ function removeClaudeMcpServers(content, serverNames) {
977
+ const parsed = JSON.parse(content);
978
+ const mcpServers = isRecord(parsed.mcpServers) ? parsed.mcpServers : {};
979
+ for (const serverName of serverNames) {
980
+ delete mcpServers[serverName];
981
+ }
982
+ return `${JSON.stringify({
983
+ ...parsed,
984
+ mcpServers
985
+ }, null, 2)}\n`;
986
+ }
987
+ function removeCodexMcpServers(content, serverNames) {
988
+ const remove = new Set(serverNames);
989
+ const lines = content.split(/\r?\n/);
990
+ const kept = [];
991
+ let removing = false;
992
+ for (const line of lines) {
993
+ const serverHeader = line.match(/^\s*\[mcp_servers\.([^\].]+)\]\s*$/);
994
+ const envHeader = line.match(/^\s*\[mcp_servers\.([^\].]+)\.env\]\s*$/);
995
+ const headerName = serverHeader?.[1] ?? envHeader?.[1];
996
+ if (headerName) {
997
+ removing = remove.has(unquoteTomlKey(headerName));
998
+ }
999
+ else if (/^\s*\[/.test(line)) {
1000
+ removing = false;
1001
+ }
1002
+ if (!removing) {
1003
+ kept.push(line);
1004
+ }
1005
+ }
1006
+ return `${kept.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n*$/, "")}\n`;
1007
+ }
1008
+ function shellQuotePath(value) {
1009
+ return `'${value.replaceAll("'", "'\\''")}'`;
1010
+ }
430
1011
  function codexServerSections(content) {
431
1012
  const sections = [];
432
1013
  let current = null;
@@ -606,6 +1187,14 @@ function secretRefLeaf(envName) {
606
1187
  function isSecretLikeName(name) {
607
1188
  return /(SECRET|TOKEN|KEY|PASSWORD|PRIVATE)/i.test(name);
608
1189
  }
1190
+ function looksProductionLikeEnvName(name) {
1191
+ return /(^|[_\-.])(PROD|PRODUCTION|LIVE)([_\-.]|$)/i.test(name);
1192
+ }
1193
+ function looksStripeLiveOrAmbiguousKey(name) {
1194
+ return (/(^|[_\-.])(LIVE|PROD|PRODUCTION)([_\-.]|$)/i.test(name) ||
1195
+ /^STRIPE_(SECRET_)?KEY$/i.test(name) ||
1196
+ /^STRIPE_SECRET_KEY$/i.test(name));
1197
+ }
609
1198
  function redactSecretLikeArgs(args) {
610
1199
  return args.map((arg) => arg
611
1200
  .replace(/\b([A-Za-z_][A-Za-z0-9_]*(?:SECRET|TOKEN|KEY|PASSWORD|PRIVATE)[A-Za-z0-9_]*)=([^\s]+)/gi, "$1=[redacted]")