kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(mcp): harden install merge, prompts, and device code validation

- Pass config path into overwrite prompt; only swallow ENOENT on read
- Fail merge on invalid JSON; use null-prototype mcpServers map and reject reserved server names
- Validate device code response fields and normalize numeric interval/expires_in

Tin f1ef0689 d7b4cda2

+81 -17
+33 -1
apps/mcp/src/auth/device-flow.ts
··· 36 36 if (!("device_code" in body) || typeof body.device_code !== "string") { 37 37 throw new Error(`device/code: unexpected response ${JSON.stringify(body)}`); 38 38 } 39 - return body; 39 + if ( 40 + typeof body.user_code !== "string" || 41 + typeof body.verification_uri !== "string" 42 + ) { 43 + throw new Error( 44 + `device/code: missing user_code or verification_uri ${JSON.stringify(body)}`, 45 + ); 46 + } 47 + const interval = toFiniteNumber((body as DeviceCodeResponse).interval); 48 + const expiresIn = toFiniteNumber((body as DeviceCodeResponse).expires_in); 49 + if (interval === undefined || expiresIn === undefined) { 50 + throw new Error( 51 + `device/code: invalid interval or expires_in ${JSON.stringify(body)}`, 52 + ); 53 + } 54 + return { 55 + ...body, 56 + interval, 57 + expires_in: expiresIn, 58 + } as DeviceCodeResponse; 59 + } 60 + 61 + function toFiniteNumber(v: unknown): number | undefined { 62 + if (typeof v === "number" && Number.isFinite(v)) { 63 + return v; 64 + } 65 + if (typeof v === "string" && v.trim() !== "") { 66 + const n = Number(v); 67 + if (Number.isFinite(n)) { 68 + return n; 69 + } 70 + } 71 + return undefined; 40 72 } 41 73 42 74 /**
+14 -3
apps/mcp/src/install/index.ts
··· 237 237 let existingText: string | null = null; 238 238 try { 239 239 existingText = await readFile(configPath, "utf8"); 240 - } catch { 241 - existingText = null; 240 + } catch (err: unknown) { 241 + const code = 242 + err && 243 + typeof err === "object" && 244 + "code" in err && 245 + typeof (err as NodeJS.ErrnoException).code === "string" 246 + ? (err as NodeJS.ErrnoException).code 247 + : undefined; 248 + if (code === "ENOENT") { 249 + existingText = null; 250 + } else { 251 + throw err; 252 + } 242 253 } 243 254 244 255 const already = hasExistingServerEntry(existingText, parsed.name); 245 256 246 257 if (already && !parsed.yes) { 247 258 if (input.isTTY && output.isTTY) { 248 - const ok = await promptConfirmOverwrite(parsed.name); 259 + const ok = await promptConfirmOverwrite(parsed.name, configPath); 249 260 if (!ok) { 250 261 console.log(`Skipped:\n ${configPath}`); 251 262 continue;
+32 -12
apps/mcp/src/install/merge-config.ts
··· 4 4 env?: Record<string, string>; 5 5 }; 6 6 7 + const RESERVED_MCP_SERVER_KEYS = new Set([ 8 + "__proto__", 9 + "constructor", 10 + "prototype", 11 + ]); 12 + 13 + function isPlainObject(v: unknown): v is Record<string, unknown> { 14 + return typeof v === "object" && v !== null && !Array.isArray(v); 15 + } 16 + 7 17 /** 8 18 * Merges or replaces `mcpServers[serverName]` and returns formatted JSON. 9 19 * Preserves other top-level keys and other MCP server entries. ··· 13 23 serverName: string, 14 24 serverConfig: McpServerEntry, 15 25 ): string { 26 + if (RESERVED_MCP_SERVER_KEYS.has(serverName)) { 27 + throw new Error( 28 + `Refusing MCP server name "${serverName}" (reserved key; use a different --name).`, 29 + ); 30 + } 31 + 16 32 let root: Record<string, unknown> = {}; 17 33 if (existingJson) { 34 + let parsed: unknown; 18 35 try { 19 - const parsed: unknown = JSON.parse(existingJson); 20 - if ( 21 - typeof parsed === "object" && 22 - parsed !== null && 23 - !Array.isArray(parsed) 24 - ) { 25 - root = { ...(parsed as Record<string, unknown>) }; 26 - } 36 + parsed = JSON.parse(existingJson); 27 37 } catch { 28 - console.warn( 29 - "[kaneo-mcp] Existing MCP config is not valid JSON; overwriting with a fresh object.", 38 + throw new Error("Existing MCP config is not valid JSON."); 39 + } 40 + if (!isPlainObject(parsed)) { 41 + throw new Error( 42 + "Existing MCP config must be a JSON object (not an array or primitive).", 30 43 ); 31 44 } 45 + root = { ...parsed }; 32 46 } 33 47 34 48 const mcpServers = (() => { 49 + const map = Object.create(null) as Record<string, unknown>; 35 50 const raw = root.mcpServers; 36 51 if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) { 37 - return { ...(raw as Record<string, unknown>) }; 52 + for (const key of Object.keys(raw as object)) { 53 + if (RESERVED_MCP_SERVER_KEYS.has(key)) { 54 + continue; 55 + } 56 + map[key] = (raw as Record<string, unknown>)[key]; 57 + } 38 58 } 39 - return {}; 59 + return map; 40 60 })(); 41 61 42 62 mcpServers[serverName] = serverConfig;
+2 -1
apps/mcp/src/install/wizard.ts
··· 71 71 72 72 export async function promptConfirmOverwrite( 73 73 serverName: string, 74 + configPath: string, 74 75 ): Promise<boolean> { 75 76 const answer = await prompts( 76 77 { 77 78 type: "confirm", 78 79 name: "overwrite", 79 - message: `MCP server "${serverName}" is already in this file. Overwrite it?`, 80 + message: `MCP server "${serverName}" is already in this file (${configPath}). Overwrite it?`, 80 81 initial: false, 81 82 }, 82 83 { onCancel },