@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.
- package/LICENSE +21 -0
- package/README.md +1 -1
- package/dist/audit/audit-log.d.ts +8 -1
- package/dist/audit/audit-log.d.ts.map +1 -1
- package/dist/audit/audit-log.js +13 -4
- package/dist/audit/audit-log.js.map +1 -1
- package/dist/audit/repo-audit.d.ts +37 -0
- package/dist/audit/repo-audit.d.ts.map +1 -0
- package/dist/audit/repo-audit.js +261 -0
- package/dist/audit/repo-audit.js.map +1 -0
- package/dist/authority/authority-map.d.ts +112 -0
- package/dist/authority/authority-map.d.ts.map +1 -0
- package/dist/authority/authority-map.js +296 -0
- package/dist/authority/authority-map.js.map +1 -0
- package/dist/authority/authority-status.d.ts +24 -0
- package/dist/authority/authority-status.d.ts.map +1 -0
- package/dist/authority/authority-status.js +76 -0
- package/dist/authority/authority-status.js.map +1 -0
- package/dist/config/load-config.d.ts.map +1 -1
- package/dist/config/load-config.js +2 -1
- package/dist/config/load-config.js.map +1 -1
- package/dist/import/import-plan.d.ts +78 -3
- package/dist/import/import-plan.d.ts.map +1 -1
- package/dist/import/import-plan.js +609 -20
- package/dist/import/import-plan.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/mandates/mandates.d.ts +91 -5
- package/dist/mandates/mandates.d.ts.map +1 -1
- package/dist/mandates/mandates.js +78 -3
- package/dist/mandates/mandates.js.map +1 -1
- package/dist/manifest/route-diff.d.ts +44 -0
- package/dist/manifest/route-diff.d.ts.map +1 -0
- package/dist/manifest/route-diff.js +95 -0
- package/dist/manifest/route-diff.js.map +1 -0
- package/dist/next-actions/next-actions.d.ts +12 -0
- package/dist/next-actions/next-actions.d.ts.map +1 -0
- package/dist/next-actions/next-actions.js +40 -0
- package/dist/next-actions/next-actions.js.map +1 -0
- package/dist/providers/provider-add.js +1 -1
- package/dist/providers/provider-add.js.map +1 -1
- package/dist/providers/provider-templates.d.ts.map +1 -1
- package/dist/providers/provider-templates.js +125 -3
- package/dist/providers/provider-templates.js.map +1 -1
- package/dist/scan/scan.d.ts +7 -0
- package/dist/scan/scan.d.ts.map +1 -1
- package/dist/scan/scan.js +106 -2
- package/dist/scan/scan.js.map +1 -1
- package/dist/schemas/config.d.ts +40 -8
- package/dist/schemas/config.d.ts.map +1 -1
- package/dist/schemas/config.js +15 -1
- package/dist/schemas/config.js.map +1 -1
- package/dist/secrets/secrets.d.ts +29 -0
- package/dist/secrets/secrets.d.ts.map +1 -1
- package/dist/secrets/secrets.js +81 -0
- package/dist/secrets/secrets.js.map +1 -1
- 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({
|
|
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({
|
|
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 --
|
|
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:
|
|
126
|
-
plan,
|
|
234
|
+
backupPath: acceptedRiskWrite.backupPath,
|
|
235
|
+
plan: refreshedPlan,
|
|
127
236
|
createdProfiles: [],
|
|
128
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
359
|
+
const config = {
|
|
202
360
|
version: 1,
|
|
203
361
|
defaults: {},
|
|
204
362
|
profiles,
|
|
205
|
-
|
|
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
|
-
|
|
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]")
|