@sphyr/cli 2.0.0-beta.1
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 +167 -0
- package/index.js +89 -0
- package/package.json +31 -0
- package/src/commands/guard.js +145 -0
- package/src/commands/init.js +484 -0
- package/src/commands/login.js +91 -0
- package/src/commands/verify.js +34 -0
- package/src/lib/ide-clients.js +411 -0
- package/src/lib/url-guard.js +59 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Sphyr
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* IDE client definitions for sphyr init.
|
|
6
|
+
*
|
|
7
|
+
* Provides:
|
|
8
|
+
* IDE_CLIENTS — ordered array of 6 supported IDE clients
|
|
9
|
+
* detectInstalledClients() — returns Set of installed client IDs
|
|
10
|
+
* isSphyrEntry(...) — returns true if an mcpServers key/entry is sphyr-owned (D-04)
|
|
11
|
+
* mergeClientConfig(...) — read-merge-write a client's JSON config file
|
|
12
|
+
* buildServerEntry(...) — constructs the MCP server entry JSON
|
|
13
|
+
* readMcpServersForIde(...) — reads existing mcpServers for an IDE client
|
|
14
|
+
* replaceWithGuardEntry(...) — replaces wrapped entries with a single sphyr-guard entry
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
|
|
22
|
+
// D-17: Read the CLI version from package.json at module load time.
|
|
23
|
+
// Using createRequire because this file is ESM ("type": "module" in package.json),
|
|
24
|
+
// so require() is not available natively. createRequire gives us a CJS-compatible
|
|
25
|
+
// require() bound to this file's URL, making the relative path resolution correct
|
|
26
|
+
// regardless of the process cwd. The CLI_VERSION is read once and pinned into
|
|
27
|
+
// every generated npx command to prevent supply-chain attacks via future npm
|
|
28
|
+
// releases of @sphyr/sdk that the user has not vetted.
|
|
29
|
+
const _require = createRequire(import.meta.url);
|
|
30
|
+
const CLI_VERSION = _require('../../package.json').version;
|
|
31
|
+
|
|
32
|
+
// D-07: Read the SDK version from @sphyr/sdk/package.json at module load time.
|
|
33
|
+
// This is distinct from CLI_VERSION (packages/cli is 1.1.0-beta.1; @sphyr/sdk is
|
|
34
|
+
// 2.0.0-beta.1 — different packages). SDK_VERSION is used in buildServerEntry to
|
|
35
|
+
// pin the sphyr-guard binary to the exact SDK version installed in this workspace,
|
|
36
|
+
// satisfying the D-17 supply-chain pinning requirement for the new sphyr-guard entry.
|
|
37
|
+
export const SDK_VERSION = _require('@sphyr/sdk/package.json').version;
|
|
38
|
+
|
|
39
|
+
const HOME = os.homedir();
|
|
40
|
+
const PLATFORM = process.platform;
|
|
41
|
+
|
|
42
|
+
// Helper: resolve %APPDATA% on Windows, fall back to HOME-relative on others
|
|
43
|
+
function appdata(...segments) {
|
|
44
|
+
if (PLATFORM === 'win32') {
|
|
45
|
+
const base = process.env.APPDATA || path.join(HOME, 'AppData', 'Roaming');
|
|
46
|
+
return path.join(base, ...segments);
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* IDE_CLIENTS — 6 entries in priority order:
|
|
53
|
+
* Claude Desktop, Cursor, VS Code (Cline/Roo), Antigravity, Zed, Windsurf
|
|
54
|
+
*
|
|
55
|
+
* Each entry:
|
|
56
|
+
* id — string identifier
|
|
57
|
+
* name — display name
|
|
58
|
+
* configPath() — returns absolute path or null (e.g. Claude Desktop on Linux)
|
|
59
|
+
* topLevelKey — JSON key for the MCP servers block
|
|
60
|
+
* entryExtra — optional extra fields merged into the server entry
|
|
61
|
+
*/
|
|
62
|
+
export const IDE_CLIENTS = [
|
|
63
|
+
{
|
|
64
|
+
id: 'claude-desktop',
|
|
65
|
+
name: 'Claude Desktop',
|
|
66
|
+
configPath() {
|
|
67
|
+
if (PLATFORM === 'darwin') {
|
|
68
|
+
return path.join(HOME, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
|
|
69
|
+
}
|
|
70
|
+
if (PLATFORM === 'win32') {
|
|
71
|
+
return appdata('Claude', 'claude_desktop_config.json');
|
|
72
|
+
}
|
|
73
|
+
// Linux: not supported
|
|
74
|
+
return null;
|
|
75
|
+
},
|
|
76
|
+
topLevelKey: 'mcpServers',
|
|
77
|
+
entryExtra: undefined,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: 'cursor',
|
|
81
|
+
name: 'Cursor',
|
|
82
|
+
configPath() {
|
|
83
|
+
return path.join(HOME, '.cursor', 'mcp.json');
|
|
84
|
+
},
|
|
85
|
+
topLevelKey: 'mcpServers',
|
|
86
|
+
entryExtra: undefined,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: 'vscode',
|
|
90
|
+
name: 'VS Code (Cline/Roo)',
|
|
91
|
+
configPath() {
|
|
92
|
+
if (PLATFORM === 'win32') {
|
|
93
|
+
return appdata('Code', 'User', 'mcp.json');
|
|
94
|
+
}
|
|
95
|
+
return path.join(HOME, '.vscode', 'mcp.json');
|
|
96
|
+
},
|
|
97
|
+
topLevelKey: 'servers',
|
|
98
|
+
entryExtra: { type: 'stdio' },
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: 'antigravity',
|
|
102
|
+
name: 'Antigravity',
|
|
103
|
+
configPath() {
|
|
104
|
+
return path.join(HOME, '.gemini', 'antigravity', 'mcp_config.json');
|
|
105
|
+
},
|
|
106
|
+
topLevelKey: 'mcpServers',
|
|
107
|
+
entryExtra: undefined,
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: 'zed',
|
|
111
|
+
name: 'Zed',
|
|
112
|
+
configPath() {
|
|
113
|
+
if (PLATFORM === 'win32') {
|
|
114
|
+
return appdata('Zed', 'settings.json');
|
|
115
|
+
}
|
|
116
|
+
return path.join(HOME, '.config', 'zed', 'settings.json');
|
|
117
|
+
},
|
|
118
|
+
topLevelKey: 'context_servers',
|
|
119
|
+
entryExtra: undefined,
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: 'windsurf',
|
|
123
|
+
name: 'Windsurf',
|
|
124
|
+
configPath() {
|
|
125
|
+
return path.join(HOME, '.codeium', 'windsurf', 'mcp_config.json');
|
|
126
|
+
},
|
|
127
|
+
topLevelKey: 'mcpServers',
|
|
128
|
+
entryExtra: undefined,
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Returns a Set of client IDs whose configPath() is non-null AND whose config
|
|
134
|
+
* file (or its parent directory) exists on the filesystem.
|
|
135
|
+
*/
|
|
136
|
+
export function detectInstalledClients() {
|
|
137
|
+
const installed = new Set();
|
|
138
|
+
for (const client of IDE_CLIENTS) {
|
|
139
|
+
const cfgPath = client.configPath();
|
|
140
|
+
if (!cfgPath) continue;
|
|
141
|
+
// Check for the file itself, or the parent directory (e.g. app is installed
|
|
142
|
+
// but config hasn't been created yet)
|
|
143
|
+
if (fs.existsSync(cfgPath) || fs.existsSync(path.dirname(cfgPath))) {
|
|
144
|
+
installed.add(client.id);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return installed;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Read-merge-write a client's JSON config file.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} configPath — absolute path to the config file
|
|
154
|
+
* @param {string} topLevelKey — JSON key that holds the MCP servers map
|
|
155
|
+
* @param {string} serverEntryKey — the key to set within the servers map
|
|
156
|
+
* @param {object} serverEntry — the MCP server entry object to write
|
|
157
|
+
*/
|
|
158
|
+
export function mergeClientConfig(configPath, topLevelKey, serverEntryKey, serverEntry) {
|
|
159
|
+
let existing = {};
|
|
160
|
+
|
|
161
|
+
if (fs.existsSync(configPath)) {
|
|
162
|
+
try {
|
|
163
|
+
existing = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
164
|
+
if (typeof existing !== 'object' || existing === null || Array.isArray(existing)) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`Config file at ${configPath} must contain a JSON object at the top level. ` +
|
|
167
|
+
`Found ${Array.isArray(existing) ? 'array' : typeof existing}. ` +
|
|
168
|
+
`Fix the file manually, then re-run sphyr init.`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
} catch (parseErr) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`Config file at ${configPath} contains invalid JSON and cannot be merged safely.\n` +
|
|
174
|
+
`Fix the file manually, then re-run sphyr init.\n` +
|
|
175
|
+
`Parse error: ${parseErr.message}`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
// Ensure parent directory exists
|
|
180
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!existing[topLevelKey] || typeof existing[topLevelKey] !== 'object') {
|
|
184
|
+
existing[topLevelKey] = {};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
existing[topLevelKey][serverEntryKey] = serverEntry;
|
|
188
|
+
|
|
189
|
+
fs.writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
|
|
190
|
+
fs.chmodSync(configPath, 0o600);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Returns true if an mcpServers entry should be excluded from SPHYR_DOWNSTREAM
|
|
195
|
+
* (D-04 belt-and-suspenders circular-wrapping prevention).
|
|
196
|
+
*
|
|
197
|
+
* Exclusion rules:
|
|
198
|
+
* 1. Key prefix check: key.toLowerCase().startsWith('sphyr')
|
|
199
|
+
* 2. Command/args content check: 'sphyr-guard' or 'sphyr-proxy' appears in
|
|
200
|
+
* entry.command or entry.args (catches sphyr binaries keyed under non-sphyr names)
|
|
201
|
+
*
|
|
202
|
+
* Exported so init.js can use it for wrappedKeys computation and D-04 logging
|
|
203
|
+
* without duplicating the rule (kept in sync automatically).
|
|
204
|
+
*
|
|
205
|
+
* @param {string} key — the mcpServers entry key
|
|
206
|
+
* @param {object} entry — the mcpServers entry value
|
|
207
|
+
* @returns {boolean}
|
|
208
|
+
*/
|
|
209
|
+
export function isSphyrEntry(key, entry) {
|
|
210
|
+
if (key.toLowerCase().startsWith('sphyr')) return true;
|
|
211
|
+
const cmd = entry.command || '';
|
|
212
|
+
const argsStr = (entry.args || []).join(' ');
|
|
213
|
+
return cmd.includes('sphyr-guard') || cmd.includes('sphyr-proxy') || cmd.includes('sphyr-mcp') ||
|
|
214
|
+
argsStr.includes('sphyr-guard') || argsStr.includes('sphyr-proxy') || argsStr.includes('sphyr-mcp');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Reads existing mcpServers entries from an IDE client's config, applying D-04
|
|
219
|
+
* exclusion, D-08 mapping, and D-01 merge semantics with dual legacy source support.
|
|
220
|
+
*
|
|
221
|
+
* Returns warnings in the result object — callers (init.js) are responsible for
|
|
222
|
+
* surfacing them via p.log.warn(). This file has no @clack/prompts dependency.
|
|
223
|
+
*
|
|
224
|
+
* @param {object} client — an entry from IDE_CLIENTS (must have configPath() and topLevelKey)
|
|
225
|
+
* @returns {{ all: object[], existingWrapped: object[], newIndividual: object[], warnings: string[] }}
|
|
226
|
+
*/
|
|
227
|
+
// eslint-disable-next-line max-lines-per-function -- sequential read-merge phases with D-01/D-04/D-08 logic; splitting would obscure the merge invariant
|
|
228
|
+
export function readMcpServersForIde(client) {
|
|
229
|
+
const empty = { all: [], existingWrapped: [], newIndividual: [], warnings: [] };
|
|
230
|
+
|
|
231
|
+
const configPath = client.configPath();
|
|
232
|
+
if (!configPath || !fs.existsSync(configPath)) {
|
|
233
|
+
return empty;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let config;
|
|
237
|
+
try {
|
|
238
|
+
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
239
|
+
} catch (parseErr) {
|
|
240
|
+
// Non-fatal during preview phase (D-03) — return empty with a warning
|
|
241
|
+
return {
|
|
242
|
+
...empty,
|
|
243
|
+
warnings: [`Config file at ${configPath} could not be parsed: ${parseErr.message}`],
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Use client.topLevelKey (NOT hardcoded 'mcpServers') — Pitfall 6:
|
|
248
|
+
// Zed uses 'context_servers', VS Code uses 'servers'.
|
|
249
|
+
const servers = config[client.topLevelKey] || {};
|
|
250
|
+
const warnings = [];
|
|
251
|
+
|
|
252
|
+
// Read previously-wrapped servers from BOTH legacy sources (D-01 re-run correctness).
|
|
253
|
+
// 'sphyr' is the current canonical entry; it wins on name collision with any legacy
|
|
254
|
+
// 'sphyr-guard' entry (the pre-rename entry written by older versions of this CLI).
|
|
255
|
+
let canonicalWrapped = [];
|
|
256
|
+
let legacyGuardWrapped = [];
|
|
257
|
+
|
|
258
|
+
const canonicalEntry = servers['sphyr'];
|
|
259
|
+
const legacyGuardEntry = servers['sphyr-guard'];
|
|
260
|
+
|
|
261
|
+
if (canonicalEntry?.env?.SPHYR_DOWNSTREAM) {
|
|
262
|
+
try {
|
|
263
|
+
canonicalWrapped = JSON.parse(canonicalEntry.env.SPHYR_DOWNSTREAM);
|
|
264
|
+
if (!Array.isArray(canonicalWrapped)) canonicalWrapped = [];
|
|
265
|
+
} catch {
|
|
266
|
+
warnings.push(
|
|
267
|
+
`Existing SPHYR_DOWNSTREAM in 'sphyr' could not be parsed — ` +
|
|
268
|
+
`previously-wrapped servers from that entry will be lost. ` +
|
|
269
|
+
`Check the entry manually.`,
|
|
270
|
+
);
|
|
271
|
+
canonicalWrapped = [];
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (legacyGuardEntry?.env?.SPHYR_DOWNSTREAM) {
|
|
276
|
+
try {
|
|
277
|
+
legacyGuardWrapped = JSON.parse(legacyGuardEntry.env.SPHYR_DOWNSTREAM);
|
|
278
|
+
if (!Array.isArray(legacyGuardWrapped)) legacyGuardWrapped = [];
|
|
279
|
+
} catch {
|
|
280
|
+
warnings.push(
|
|
281
|
+
`Existing SPHYR_DOWNSTREAM in 'sphyr-guard' could not be parsed — ` +
|
|
282
|
+
`previously-wrapped servers from that entry will be lost. ` +
|
|
283
|
+
`Check the entry manually.`,
|
|
284
|
+
);
|
|
285
|
+
legacyGuardWrapped = [];
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Merge both sources. 'sphyr' wins on name collision (it is the current canonical
|
|
290
|
+
// entry written by this version of the CLI; 'sphyr-guard' is a legacy key from
|
|
291
|
+
// older installs that used @sphyr/agent-guard-cli).
|
|
292
|
+
const existingWrappedMap = new Map(legacyGuardWrapped.map(e => [e.name, e]));
|
|
293
|
+
for (const e of canonicalWrapped) existingWrappedMap.set(e.name, e);
|
|
294
|
+
const existingWrapped = [...existingWrappedMap.values()];
|
|
295
|
+
|
|
296
|
+
// Build newIndividual: iterate mcpServers, exclude sphyr entries (D-04),
|
|
297
|
+
// map to DownstreamServerConfig shape (D-08 — unknown fields dropped silently).
|
|
298
|
+
const newIndividual = Object.entries(servers)
|
|
299
|
+
.filter(([key, entry]) => !isSphyrEntry(key, entry))
|
|
300
|
+
.map(([key, entry]) => ({
|
|
301
|
+
name: key,
|
|
302
|
+
command: entry.command,
|
|
303
|
+
args: entry.args || [],
|
|
304
|
+
...(entry.env ? { env: entry.env } : {}),
|
|
305
|
+
}));
|
|
306
|
+
|
|
307
|
+
// Merge with D-01 semantics: existing first, new individual wins on name collision.
|
|
308
|
+
const merged = new Map(existingWrapped.map(e => [e.name, e]));
|
|
309
|
+
for (const e of newIndividual) merged.set(e.name, e);
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
all: [...merged.values()],
|
|
313
|
+
existingWrapped,
|
|
314
|
+
newIndividual,
|
|
315
|
+
warnings,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Constructs the MCP server entry for a given client.
|
|
321
|
+
* Generates a sphyr-guard entry with SPHYR_DOWNSTREAM embedded in env.
|
|
322
|
+
*
|
|
323
|
+
* The sphyr-guard binary fail-fasts at startup if required env vars are missing,
|
|
324
|
+
* so callers must always supply all required env vars.
|
|
325
|
+
*
|
|
326
|
+
* @param {object} client — an entry from IDE_CLIENTS
|
|
327
|
+
* @param {string} credential — the Sphyr single credential (`sphyr_v1_<keyId>.<signingSecret>`)
|
|
328
|
+
* @param {string} downstreamJson — JSON.stringify'd DownstreamServerConfig[] array
|
|
329
|
+
* @returns {object} the server entry object
|
|
330
|
+
*/
|
|
331
|
+
export function buildServerEntry(client, credential, downstreamJson) {
|
|
332
|
+
// D-07: Pin SDK_VERSION (not CLI_VERSION) in the npx command — the sphyr-mcp
|
|
333
|
+
// binary lives in @sphyr/sdk, not @sphyr/cli. Using SDK_VERSION ensures the
|
|
334
|
+
// generated config pins the correct package at the correct version.
|
|
335
|
+
// D-06: args use 'sphyr-mcp'; SPHYR_DOWNSTREAM added to env.
|
|
336
|
+
const entry = {
|
|
337
|
+
command: 'npx',
|
|
338
|
+
args: ['-y', '-p', `@sphyr/sdk@${SDK_VERSION}`, 'sphyr-mcp'],
|
|
339
|
+
env: {
|
|
340
|
+
SPHYR_CREDENTIAL: credential,
|
|
341
|
+
SPHYR_DOWNSTREAM: downstreamJson,
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
if (client.entryExtra) Object.assign(entry, client.entryExtra);
|
|
345
|
+
return entry;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Replaces existing wrapped + sphyr-prefixed entries in an IDE config with a
|
|
350
|
+
* single sphyr-guard entry (D-05 write operation).
|
|
351
|
+
*
|
|
352
|
+
* Steps:
|
|
353
|
+
* 1. Read existing config (replicates mergeClientConfig read-guard exactly)
|
|
354
|
+
* 2. Delete all keys in wrappedKeys from config[topLevelKey]
|
|
355
|
+
* 3. Delete any remaining keys whose lowercased prefix starts with 'sphyr' (upgrade path)
|
|
356
|
+
* 4. Write config[topLevelKey]['sphyr-guard'] = guardEntry
|
|
357
|
+
* 5. Write file with 0o600 permissions (belt-and-suspenders: mode in writeFileSync + chmodSync)
|
|
358
|
+
*
|
|
359
|
+
* @param {string} configPath — absolute path to the config file
|
|
360
|
+
* @param {string} topLevelKey — JSON key that holds the MCP servers map
|
|
361
|
+
* @param {object} guardEntry — the sphyr-guard entry to write
|
|
362
|
+
* @param {string[]} wrappedKeys — keys to remove from servers (the previously-individual entries)
|
|
363
|
+
*/
|
|
364
|
+
// eslint-disable-next-line max-lines-per-function -- sequential read-guard then delete-write phases; splitting obscures the file-integrity invariant (same rationale as mergeClientConfig)
|
|
365
|
+
export function replaceWithGuardEntry(configPath, topLevelKey, guardEntry, wrappedKeys) {
|
|
366
|
+
let existing = {};
|
|
367
|
+
|
|
368
|
+
if (fs.existsSync(configPath)) {
|
|
369
|
+
try {
|
|
370
|
+
existing = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
371
|
+
if (typeof existing !== 'object' || existing === null || Array.isArray(existing)) {
|
|
372
|
+
throw new Error(
|
|
373
|
+
`Config file at ${configPath} must contain a JSON object at the top level. ` +
|
|
374
|
+
`Found ${Array.isArray(existing) ? 'array' : typeof existing}. ` +
|
|
375
|
+
`Fix the file manually, then re-run sphyr init.`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
} catch (parseErr) {
|
|
379
|
+
throw new Error(
|
|
380
|
+
`Config file at ${configPath} contains invalid JSON and cannot be merged safely.\n` +
|
|
381
|
+
`Fix the file manually, then re-run sphyr init.\n` +
|
|
382
|
+
`Parse error: ${parseErr.message}`,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
} else {
|
|
386
|
+
// Ensure parent directory exists
|
|
387
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (!existing[topLevelKey] || typeof existing[topLevelKey] !== 'object') {
|
|
391
|
+
existing[topLevelKey] = {};
|
|
392
|
+
}
|
|
393
|
+
const servers = existing[topLevelKey];
|
|
394
|
+
|
|
395
|
+
// Remove the individual wrapped entries (the ones now encoded in SPHYR_DOWNSTREAM)
|
|
396
|
+
for (const key of wrappedKeys) delete servers[key];
|
|
397
|
+
|
|
398
|
+
// Remove any remaining sphyr-prefixed entries (upgrade path D-05):
|
|
399
|
+
// clears old 'sphyr', 'sphyr-proxy', 'sphyr-guard' regardless of wrappedKeys.
|
|
400
|
+
for (const key of Object.keys(servers)) {
|
|
401
|
+
if (key.toLowerCase().startsWith('sphyr')) delete servers[key];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Write the single new 'sphyr' entry (key is hardcoded — ROADMAP SC-2, Pitfall 2)
|
|
405
|
+
servers['sphyr'] = guardEntry;
|
|
406
|
+
|
|
407
|
+
// Belt-and-suspenders: set mode in writeFileSync AND follow up with chmodSync
|
|
408
|
+
// (same pattern as mergeClientConfig — both calls required per security requirement)
|
|
409
|
+
fs.writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
|
|
410
|
+
fs.chmodSync(configPath, 0o600);
|
|
411
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Sphyr Agent Guard Contributors
|
|
3
|
+
//
|
|
4
|
+
// packages/cli/src/lib/url-guard.js
|
|
5
|
+
//
|
|
6
|
+
// REVIEW.md W-10 (2026-06-09): SPHYR_API_URL / SPHYR_MCP_URL flow into the
|
|
7
|
+
// device flow and are persisted into ~/.sphyr/config.json, where the SDK reads
|
|
8
|
+
// them for credentialed traffic indefinitely. A poisoned env var at login time
|
|
9
|
+
// would silently redirect credentials to an attacker host. guard.js has always
|
|
10
|
+
// enforced HTTPS on its own env var; this helper ports the same rule (plus the
|
|
11
|
+
// SDK's local-host exception, matching sdk/ts/guard.ts) to the two commands
|
|
12
|
+
// that actually handle the credential.
|
|
13
|
+
|
|
14
|
+
const LOCAL_HOSTNAMES = ['localhost', '127.0.0.1', '[::1]'];
|
|
15
|
+
|
|
16
|
+
/** Strips userinfo so a URL can be printed in errors without leaking embedded credentials. */
|
|
17
|
+
export function safeUrl(raw) {
|
|
18
|
+
try {
|
|
19
|
+
const u = new URL(raw);
|
|
20
|
+
u.username = '';
|
|
21
|
+
u.password = '';
|
|
22
|
+
return u.toString();
|
|
23
|
+
} catch {
|
|
24
|
+
return '<invalid url>';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Validates an endpoint URL sourced from an env var. Exits the process on a
|
|
30
|
+
* non-HTTPS non-local URL; prints a loud notice when a non-default endpoint is
|
|
31
|
+
* in effect so an unexpected override is visible at the moment credentials flow.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} value the URL in effect (env override or default)
|
|
34
|
+
* @param {string} envName the env var name, for messages
|
|
35
|
+
* @param {string} defaultValue the built-in default endpoint
|
|
36
|
+
* @returns {string} value, unchanged, for assignment convenience
|
|
37
|
+
*/
|
|
38
|
+
export function requireTrustedEndpoint(value, envName, defaultValue) {
|
|
39
|
+
let parsed;
|
|
40
|
+
try {
|
|
41
|
+
parsed = new URL(value);
|
|
42
|
+
} catch {
|
|
43
|
+
process.stderr.write(`[sphyr] Fatal: ${envName} is not a valid URL: ${safeUrl(value)}\n`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
if (parsed.protocol !== 'https:' && !LOCAL_HOSTNAMES.includes(parsed.hostname)) {
|
|
47
|
+
process.stderr.write(
|
|
48
|
+
`[sphyr] Fatal: ${envName} must use https:// for non-local hosts (got: ${safeUrl(value)}). ` +
|
|
49
|
+
`Credentials will not be sent over an insecure or unexpected transport.\n`,
|
|
50
|
+
);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
if (value !== defaultValue) {
|
|
54
|
+
process.stderr.write(
|
|
55
|
+
`[sphyr] NOTICE: ${envName} is overridden — credentials will flow to ${safeUrl(value)} (default: ${defaultValue}).\n`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|