diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..7c536be --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,209 @@ +import { promises as fs } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import type { PluginInput } from '@opencode-ai/plugin'; +import type { Config } from '@opencode-ai/sdk'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@opencode-ai/plugin', () => { + const createSchemaChain = (): Record unknown> => { + const chain = { + describe: () => chain, + optional: () => chain, + }; + return chain; + }; + return { + tool: Object.assign((definition: T): T => definition, { + schema: { + enum: () => createSchemaChain(), + string: () => createSchemaChain(), + boolean: () => createSchemaChain(), + array: () => createSchemaChain(), + }, + }), + }; +}); + +import { opencodeConfigSync } from './index.js'; +import { syncLocalToRepo } from './sync/apply.js'; +import { normalizeSyncConfig, parseJsonc } from './sync/config.js'; +import { buildSyncPlan, resolveSyncLocations } from './sync/paths.js'; + +const ENV_KEYS = [ + 'HOME', + 'XDG_CONFIG_HOME', + 'XDG_DATA_HOME', + 'XDG_STATE_HOME', + 'PR47_GITHUB_PAT', + 'MISSING_PAT', +] as const; + +function createPluginInput(logs: unknown[]): PluginInput { + return { + $: (() => { + throw new Error('Shell execution is not expected in config-hook tests.'); + }) as unknown as PluginInput['$'], + client: { + app: { log: async (entry: unknown) => logs.push(entry) }, + config: { get: async () => ({ data: {} }) }, + session: { + create: async () => ({ data: null }), + delete: async () => ({}), + prompt: async () => ({ data: null }), + status: async () => ({ data: {} }), + }, + tui: { showToast: async () => ({}) }, + } as unknown as PluginInput['client'], + } as PluginInput; +} + +async function createPluginHooks(logs: unknown[]): ReturnType { + const originalSetTimeout = globalThis.setTimeout; + globalThis.setTimeout = (() => 0) as unknown as typeof setTimeout; + try { + return await opencodeConfigSync(createPluginInput(logs)); + } finally { + globalThis.setTimeout = originalSetTimeout; + } +} + +async function withIsolatedPluginHome(run: (homeDir: string) => Promise): Promise { + const original = new Map(ENV_KEYS.map((key) => [key, process.env[key]])); + const homeDir = await fs.mkdtemp(path.join(tmpdir(), 'opencode-sync-plugin-')); + process.env.HOME = homeDir; + process.env.XDG_CONFIG_HOME = path.join(homeDir, 'config'); + process.env.XDG_DATA_HOME = path.join(homeDir, 'data'); + process.env.XDG_STATE_HOME = path.join(homeDir, 'state'); + + try { + await run(homeDir); + } finally { + for (const [key, value] of original) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + await fs.rm(homeDir, { recursive: true, force: true }); + } +} + +describe('opencode plugin config hook', () => { + it('resolves a local placeholder at runtime without exposing it to the synced repo or logs', async () => { + await withIsolatedPluginHome(async (homeDir) => { + const locations = resolveSyncLocations(); + const repoRoot = path.join(homeDir, 'sync-repo'); + const localConfigPath = path.join(locations.configRoot, 'opencode.json'); + const repoConfigPath = path.join(repoRoot, 'config', 'opencode.json'); + const secret = 'runtime-only-secret-sentinel'; + await fs.mkdir(locations.configRoot, { recursive: true }); + await fs.writeFile( + localConfigPath, + JSON.stringify({ + mcp: { + github: { + type: 'remote', + url: 'https://example.test/mcp', + enabled: true, + headers: { Authorization: `Bearer ${secret}` }, + }, + }, + }), + 'utf8' + ); + const syncConfig = normalizeSyncConfig({ + repo: { owner: 'acme', name: 'config' }, + includeOpencodeSkills: false, + includeAgentsDir: false, + includeModelFavorites: false, + }); + const plan = buildSyncPlan(syncConfig, locations, repoRoot, 'linux'); + await syncLocalToRepo(plan, null, { overridesPath: locations.overridesPath }); + + await fs.writeFile( + locations.overridesPath, + `{ + // Parsed after OpenCode's file-level environment substitution. + "mcp": { + "github": { + "headers": { "Authorization": "Bearer {env:PR47_GITHUB_PAT}" }, + }, + }, + }\n`, + 'utf8' + ); + process.env.PR47_GITHUB_PAT = secret; + const logs: unknown[] = []; + const hooks = await createPluginHooks(logs); + const unrelated = { keep: true }; + const runtime = parseJsonc( + await fs.readFile(repoConfigPath, 'utf8') + ); + runtime.unrelated = unrelated; + + await hooks.config?.(runtime); + + expect(runtime.unrelated).toBe(unrelated); + expect(runtime.mcp).toEqual({ + github: { + type: 'remote', + url: 'https://example.test/mcp', + enabled: true, + headers: { Authorization: `Bearer ${secret}` }, + }, + }); + expect(await fs.readFile(repoConfigPath, 'utf8')).not.toContain(secret); + expect(await fs.readFile(locations.overridesPath, 'utf8')).not.toContain(secret); + expect(JSON.stringify(logs)).not.toContain(secret); + }); + }); + + it('rejects a missing placeholder with field context and without exposing other env values', async () => { + await withIsolatedPluginHome(async () => { + const locations = resolveSyncLocations(); + await fs.mkdir(locations.configRoot, { recursive: true }); + await fs.writeFile( + locations.overridesPath, + '{"mcp":{"github":{"headers":{"Authorization":"{env:MISSING_PAT}"}}}}\n', + 'utf8' + ); + delete process.env.MISSING_PAT; + const logs: unknown[] = []; + const hooks = await createPluginHooks(logs); + const runtime: Config & { keep: boolean } = { + keep: true, + mcp: { + github: { + type: 'remote', + url: 'https://example.test/mcp', + enabled: true, + headers: { Authorization: '' }, + }, + }, + }; + + try { + await hooks.config?.(runtime); + } catch { + // OpenCode isolates plugin-hook failures and continues with the mutated runtime config. + } + expect(runtime).toEqual({ + keep: true, + command: expect.any(Object), + mcp: { + github: { + type: 'remote', + url: 'https://example.test/mcp', + enabled: false, + headers: { Authorization: '' }, + }, + }, + }); + expect(JSON.stringify(logs)).toContain( + 'Missing environment variable \\"MISSING_PAT\\" required by local override ' + + '\\"overrides.mcp.github.headers.Authorization\\".' + ); + expect(JSON.stringify(logs)).not.toContain('runtime-only-secret-sentinel'); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index 21c2c89..8ff8034 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,13 @@ import { fileURLToPath } from 'node:url'; import type { Plugin } from '@opencode-ai/plugin'; import { tool } from '@opencode-ai/plugin'; -import { applyOverridesToRuntimeConfig, loadOverrides } from './sync/config.js'; +import { + applyOverridesToRuntimeConfig, + EnvPlaceholderResolutionError, + hasOwn, + isPlainObject, + loadOverrides, +} from './sync/config.js'; import { SyncCommandError, SyncConfigMissingError } from './sync/errors.js'; import { resolveSyncLocations } from './sync/paths.js'; import { createSyncService } from './sync/service.js'; @@ -303,13 +309,26 @@ export const opencodeConfigSync: Plugin = async (ctx) => { }; } - try { - const overrides = await loadOverrides(resolveSyncLocations()); - if (overrides) { + const overrides = await loadOverrides(resolveSyncLocations()); + if (overrides) { + try { applyOverridesToRuntimeConfig(config as Record, overrides); + } catch (error) { + if (error instanceof EnvPlaceholderResolutionError) { + disableMcpServerForResolutionFailure( + config as Record, + error.fieldPath + ); + await ctx.client.app.log({ + body: { + service: 'opencode-synced', + level: 'error', + message: error.message, + }, + }); + } + throw error; } - } catch { - return; } }, }; @@ -318,6 +337,25 @@ export const opencodeConfigSync: Plugin = async (ctx) => { export const opencodeSynced = opencodeConfigSync; export default opencodeConfigSync; +function disableMcpServerForResolutionFailure( + config: Record, + fieldPath: readonly string[] +): void { + if (fieldPath[0] !== 'overrides' || fieldPath[1] !== 'mcp') return; + const serverName = fieldPath[2]; + if (!serverName) return; + + const mcp = isPlainObject(config.mcp) ? config.mcp : null; + if (!mcp || !hasOwn(mcp, serverName) || !isPlainObject(mcp[serverName])) return; + + Object.defineProperty(mcp[serverName], 'enabled', { + value: false, + enumerable: true, + configurable: true, + writable: true, + }); +} + function formatError(error: unknown): string { if (error instanceof Error) return error.message; return String(error); diff --git a/src/sync/apply.test.ts b/src/sync/apply.test.ts index 650d1ea..4e8f2e4 100644 --- a/src/sync/apply.test.ts +++ b/src/sync/apply.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { syncLocalToRepo, syncRepoToLocal } from './apply.js'; -import { normalizeSyncConfig } from './config.js'; +import { loadOverrides, normalizeSyncConfig, parseJsonc } from './config.js'; import type { ExtraPathPlan, SyncItem, SyncPlan } from './paths.js'; import { buildSyncPlan, resolveSyncLocations } from './paths.js'; @@ -320,3 +320,97 @@ describe('syncing plural OpenCode config directories', () => { }); }); }); + +describe('MCP secret scrub round trip', () => { + it('keeps the secret local, writes a placeholder to the repo, and protects overrides', async () => { + await withTempDir(async (root) => { + const homeDir = path.join(root, 'home'); + const repoRoot = path.join(root, 'repo'); + const locations = resolveSyncLocations({ HOME: homeDir }, 'linux'); + const localConfigPath = path.join(locations.configRoot, 'opencode.jsonc'); + const repoConfigPath = path.join(repoRoot, 'config', 'opencode.jsonc'); + const secret = 'focused-runtime-secret'; + await fs.mkdir(locations.configRoot, { recursive: true }); + await fs.writeFile( + localConfigPath, + `{ + // The secret must never reach Git. + "mcp": { + "github": { + "headers": { "Authorization": "Bearer ${secret}" }, + }, + }, + }\n`, + 'utf8' + ); + + const config = normalizeSyncConfig({ + repo: { owner: 'acme', name: 'config' }, + includeOpencodeSkills: false, + includeAgentsDir: false, + includeModelFavorites: false, + }); + const plan = buildSyncPlan(config, locations, repoRoot, 'linux'); + await syncLocalToRepo(plan, null, { overridesPath: locations.overridesPath }); + + const repoContent = await fs.readFile(repoConfigPath, 'utf8'); + const overridesContent = await fs.readFile(locations.overridesPath, 'utf8'); + const overrideMode = (await fs.stat(locations.overridesPath)).mode & 0o777; + expect(repoContent).toContain('Bearer {env:opencode_mcp_GITHUB_AUTHORIZATION}'); + expect(repoContent).not.toContain(secret); + expect(overridesContent).toContain(secret); + expect(overrideMode).toBe(0o600); + + const overrides = await loadOverrides(locations); + expect(overrides).not.toBeNull(); + await fs.writeFile(localConfigPath, '{}\n', 'utf8'); + await syncRepoToLocal(plan, overrides); + + const restored = parseJsonc>( + await fs.readFile(localConfigPath, 'utf8') + ); + expect(restored).toEqual({ + mcp: { + github: { + headers: { Authorization: `Bearer ${secret}` }, + }, + }, + }); + }); + }); + + it('rejects malformed credential values before mutating the synced repository', async () => { + await withTempDir(async (root) => { + const homeDir = path.join(root, 'home'); + const repoRoot = path.join(root, 'repo'); + const locations = resolveSyncLocations({ HOME: homeDir }, 'linux'); + const localConfigPath = path.join(locations.configRoot, 'opencode.json'); + const repoConfigPath = path.join(repoRoot, 'config', 'opencode.json'); + await fs.mkdir(path.dirname(localConfigPath), { recursive: true }); + await fs.mkdir(path.dirname(repoConfigPath), { recursive: true }); + await fs.writeFile( + localConfigPath, + '{"mcp":{"github":{"headers":{"Authorization":false}}}}\n', + 'utf8' + ); + const originalRepoContent = '{"existing":"must-remain"}\n'; + await fs.writeFile(repoConfigPath, originalRepoContent, 'utf8'); + const config = normalizeSyncConfig({ + repo: { owner: 'acme', name: 'config' }, + includeOpencodeSkills: false, + includeAgentsDir: false, + includeModelFavorites: false, + }); + const plan = buildSyncPlan(config, locations, repoRoot, 'linux'); + + await expect( + syncLocalToRepo(plan, null, { overridesPath: locations.overridesPath }) + ).rejects.toThrow( + 'MCP credential field "mcp.github.headers.Authorization" must be a string before it can ' + + 'be synchronized.' + ); + expect(await fs.readFile(repoConfigPath, 'utf8')).toBe(originalRepoContent); + await expect(fs.stat(locations.overridesPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + }); +}); diff --git a/src/sync/apply.ts b/src/sync/apply.ts index 90b75b4..2d8f4ee 100644 --- a/src/sync/apply.ts +++ b/src/sync/apply.ts @@ -88,7 +88,10 @@ export async function syncLocalToRepo( const baseOverrides = overrides ?? {}; const mergedOverrides = mergeOverrides(baseOverrides, secretOverrides); if (options.overridesPath && !isDeepEqual(baseOverrides, mergedOverrides)) { - await writeJsonFile(options.overridesPath, mergedOverrides, { jsonc: true }); + await writeJsonFile(options.overridesPath, mergedOverrides, { + jsonc: true, + mode: 0o600, + }); } } overridesForStrip = overrides ? stripOverrideKeys(overrides, secretOverrides) : overrides; diff --git a/src/sync/config.test.ts b/src/sync/config.test.ts index d1e695d..7e4ff1c 100644 --- a/src/sync/config.test.ts +++ b/src/sync/config.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -9,6 +9,7 @@ import { chmodIfExists, deepMerge, isTursoSessionBackend, + loadOverrides, normalizeSecretsBackend, normalizeSessionBackend, normalizeSyncConfig, @@ -16,6 +17,7 @@ import { sanitizeRepoUrl, stripOverrides, } from './config.js'; +import { resolveSyncLocations } from './paths.js'; describe('deepMerge', () => { it('merges nested objects and replaces arrays', () => { @@ -31,6 +33,17 @@ describe('deepMerge', () => { list: [2], }); }); + + it('defines __proto__ as data without mutating the result prototype', () => { + const override = JSON.parse('{"__proto__":{"polluted":"no"}}') as Record; + + const merged = deepMerge({}, override) as Record; + + expect(Object.getPrototypeOf(merged)).toBe(Object.prototype); + expect(Object.hasOwn(merged, '__proto__')).toBe(true); + expect(merged.__proto__).toEqual({ polluted: 'no' }); + expect(({} as Record).polluted).toBeUndefined(); + }); }); describe('stripOverrides', () => { @@ -349,3 +362,57 @@ describe('chmodIfExists', () => { } }); }); + +describe('loadOverrides', () => { + it('loads JSONC and repairs a legacy overrides file to mode 0600', async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'opencode-sync-overrides-')); + try { + const locations = resolveSyncLocations({ HOME: tempDir }, 'linux'); + await mkdir(locations.configRoot, { recursive: true }); + await writeFile( + locations.overridesPath, + `{ + // A URL containing // is data, not a comment. + "mcp": { + "remote": { + "url": "https://example.test/mcp", + "headers": ["{env:MCP_TOKEN}", 2, false,], + }, + }, + }\n`, + 'utf8' + ); + await chmod(locations.overridesPath, 0o644); + + const overrides = await loadOverrides(locations); + + expect(overrides).toEqual({ + mcp: { + remote: { + url: 'https://example.test/mcp', + headers: ['{env:MCP_TOKEN}', 2, false], + }, + }, + }); + expect((await stat(locations.overridesPath)).mode & 0o777).toBe(0o600); + expect(await readFile(locations.overridesPath, 'utf8')).toContain('{env:MCP_TOKEN}'); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it('rejects a non-object overrides document', async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'opencode-sync-overrides-')); + try { + const locations = resolveSyncLocations({ HOME: tempDir }, 'linux'); + await mkdir(locations.configRoot, { recursive: true }); + await writeFile(locations.overridesPath, '["not-an-object"]\n', 'utf8'); + + await expect(loadOverrides(locations)).rejects.toThrow( + `Local overrides file must contain a JSON object: ${locations.overridesPath}` + ); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/sync/config.ts b/src/sync/config.ts index a0c2241..1149ae7 100644 --- a/src/sync/config.ts +++ b/src/sync/config.ts @@ -296,8 +296,12 @@ export async function loadOverrides( return null; } + await chmodIfExists(locations.overridesPath, 0o600); const content = await fs.readFile(locations.overridesPath, 'utf8'); - const parsed = parseJsonc>(content); + const parsed = parseJsonc(content); + if (!isPlainObject(parsed)) { + throw new Error(`Local overrides file must contain a JSON object: ${locations.overridesPath}`); + } return parsed; } @@ -323,15 +327,75 @@ export async function updateState( await writeState(locations, { ...existing, ...update }); } +export class EnvPlaceholderResolutionError extends Error { + constructor( + message: string, + readonly fieldPath: readonly string[] = [] + ) { + super(message); + this.name = 'EnvPlaceholderResolutionError'; + } +} + export function applyOverridesToRuntimeConfig( config: Record, - overrides: Record + overrides: Record, + env: NodeJS.ProcessEnv = process.env ): void { - const merged = deepMerge(config, overrides) as Record; - for (const key of Object.keys(config)) { - delete config[key]; + const resolvedOverrides = resolveEnvPlaceholders(overrides, env); + const merged = deepMerge(config, resolvedOverrides) as Record; + for (const [key, value] of Object.entries(merged)) { + defineOwnValue(config, key, value); + } +} + +export function resolveEnvPlaceholders( + value: unknown, + env: NodeJS.ProcessEnv = process.env, + fieldPath: readonly string[] = ['overrides'] +): unknown { + if (typeof value === 'string') { + return value.replace(/\{env:([^}]+)\}/g, (_match, envVar: string) => { + const resolved = env[envVar]; + const displayPath = formatFieldPath(fieldPath); + if (resolved === undefined) { + throw new EnvPlaceholderResolutionError( + `Missing environment variable "${envVar}" required by local override "${displayPath}".`, + fieldPath + ); + } + if (resolved.length === 0) { + throw new EnvPlaceholderResolutionError( + `Environment variable "${envVar}" required by local override "${displayPath}" is empty.`, + fieldPath + ); + } + return resolved; + }); + } + + if (Array.isArray(value)) { + return value.map((item, index) => + resolveEnvPlaceholders(item, env, [...fieldPath, `${index}`]) + ); + } + + if (isPlainObject(value)) { + const result: Record = {}; + for (const [key, nestedValue] of Object.entries(value)) { + const nestedPath = [...fieldPath, key]; + if (key === '__proto__') { + throw new EnvPlaceholderResolutionError( + `Unsafe local override field "${formatFieldPath(nestedPath)}" is not allowed.`, + nestedPath + ); + } + defineOwnValue(result, key, resolveEnvPlaceholders(nestedValue, env, nestedPath)); + } + return result; } - Object.assign(config, merged); + + return value; } export function deepMerge(base: T, override: unknown): T { @@ -341,15 +405,35 @@ export function deepMerge(base: T, override: unknown): T { const result: Record = { ...(base as Record) }; for (const [key, value] of Object.entries(override as Record)) { - if (isPlainObject(value) && isPlainObject(result[key])) { - result[key] = deepMerge(result[key], value); - } else { - result[key] = value; - } + const currentValue = hasOwn(result, key) ? result[key] : undefined; + const mergedValue = + isPlainObject(value) && isPlainObject(currentValue) ? deepMerge(currentValue, value) : value; + defineOwnValue(result, key, mergedValue); } return result as T; } +function formatFieldPath(fieldPath: readonly string[]): string { + let result = fieldPath[0] ?? ''; + for (const key of fieldPath.slice(1)) { + if (/^(0|[1-9][0-9]*)$/u.test(key)) { + result += `[${key}]`; + continue; + } + result += /^[a-zA-Z_$][a-zA-Z0-9_$]*$/u.test(key) ? `.${key}` : `[${JSON.stringify(key)}]`; + } + return result; +} + +function defineOwnValue(target: Record, key: string, value: unknown): void { + Object.defineProperty(target, key, { + value, + enumerable: true, + configurable: true, + writable: true, + }); +} + export function stripOverrides( localConfig: Record, overrides: Record, @@ -474,7 +558,11 @@ export async function writeJsonFile( ): Promise { const json = JSON.stringify(data, null, 2); const content = options.jsonc ? `// Generated by opencode-synced\n${json}\n` : `${json}\n`; - await fs.writeFile(filePath, content, 'utf8'); + if (options.mode === undefined) { + await fs.writeFile(filePath, content, 'utf8'); + } else { + await fs.writeFile(filePath, content, { encoding: 'utf8', mode: options.mode }); + } if (options.mode !== undefined) { await chmodIfExists(filePath, options.mode); } diff --git a/src/sync/mcp-secrets.test.ts b/src/sync/mcp-secrets.test.ts index 72215d0..38cd705 100644 --- a/src/sync/mcp-secrets.test.ts +++ b/src/sync/mcp-secrets.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { extractMcpSecrets } from './mcp-secrets.js'; +import { extractMcpSecrets, McpSecretExtractionError } from './mcp-secrets.js'; describe('extractMcpSecrets', () => { it('moves MCP header secrets into overrides and adds env placeholders', () => { @@ -126,4 +126,75 @@ describe('extractMcpSecrets', () => { }, }); }); + + it('rejects an own __proto__ server name before creating scrubbed output', () => { + const input = JSON.parse( + '{"mcp":{"__proto__":{"headers":{"Authorization":"Bearer secret"}}}}' + ) as Record; + + expect(() => extractMcpSecrets(input)).toThrow( + 'Unsafe MCP server field "mcp.__proto__" is not allowed during secret scrubbing.' + ); + expect(({} as Record).headers).toBeUndefined(); + }); + + it.each([ + [0], + [false], + [null], + [[]], + [{}], + ])('rejects a non-string header credential %j with field context', (invalidValue) => { + const input = { + mcp: { + github: { + headers: { Authorization: invalidValue }, + }, + }, + }; + + expect(() => extractMcpSecrets(input)).toThrow( + new McpSecretExtractionError( + 'MCP credential field "mcp.github.headers.Authorization" must be a string before it ' + + 'can be synchronized.' + ) + ); + }); + + it('rejects an empty header credential with field context', () => { + const input = { + mcp: { github: { headers: { Authorization: '' } } }, + }; + + expect(() => extractMcpSecrets(input)).toThrow( + 'MCP credential field "mcp.github.headers.Authorization" must not be empty before it can be ' + + 'synchronized.' + ); + }); + + it('rejects a non-string OAuth client secret with field context', () => { + const input = { + mcp: { + github: { + oauth: { clientSecret: ['not', 'a', 'string'] }, + }, + }, + }; + + expect(() => extractMcpSecrets(input)).toThrow( + 'MCP credential field "mcp.github.oauth.clientSecret" must be a string before it can be ' + + 'synchronized.' + ); + }); + + it('rejects an empty OAuth client secret with field context', () => { + const input = { + mcp: { github: { oauth: { clientSecret: '' } } }, + }; + + expect(() => extractMcpSecrets(input)).toThrow( + 'MCP credential field "mcp.github.oauth.clientSecret" must not be empty before it can be ' + + 'synchronized.' + ); + }); }); diff --git a/src/sync/mcp-secrets.ts b/src/sync/mcp-secrets.ts index 28bdf74..ec0f39b 100644 --- a/src/sync/mcp-secrets.ts +++ b/src/sync/mcp-secrets.ts @@ -7,6 +7,13 @@ export interface McpSecretExtraction { const ENV_PLACEHOLDER_PATTERN = /\{env:[^}]+\}/i; +export class McpSecretExtractionError extends Error { + constructor(message: string) { + super(message); + this.name = 'McpSecretExtractionError'; + } +} + export function extractMcpSecrets(config: Record): McpSecretExtraction { const sanitizedConfig = cloneConfig(config); const secretOverrides: Record = {}; @@ -17,12 +24,37 @@ export function extractMcpSecrets(config: Record): McpSecretExt } for (const [serverName, serverConfigValue] of Object.entries(mcp)) { + if (serverName === '__proto__') { + throw new McpSecretExtractionError( + 'Unsafe MCP server field "mcp.__proto__" is not allowed during secret scrubbing.' + ); + } const serverConfig = getPlainObject(serverConfigValue); if (!serverConfig) continue; const headers = getPlainObject(serverConfig.headers); if (headers) { for (const [headerName, headerValue] of Object.entries(headers)) { + if (typeof headerValue !== 'string') { + throw new McpSecretExtractionError( + `MCP credential field ${formatFieldPath([ + 'mcp', + serverName, + 'headers', + headerName, + ])} must be a string before it can be synchronized.` + ); + } + if (headerValue.length === 0) { + throw new McpSecretExtractionError( + `MCP credential field ${formatFieldPath([ + 'mcp', + serverName, + 'headers', + headerName, + ])} must not be empty before it can be synchronized.` + ); + } if (!isSecretString(headerValue)) continue; const envVar = buildHeaderEnvVar(serverName, headerName); const placeholder = buildHeaderPlaceholder(String(headerValue), envVar, headerName); @@ -34,6 +66,26 @@ export function extractMcpSecrets(config: Record): McpSecretExt const oauth = getPlainObject(serverConfig.oauth); if (oauth) { const clientSecret = oauth.clientSecret; + if (hasOwn(oauth, 'clientSecret') && typeof clientSecret !== 'string') { + throw new McpSecretExtractionError( + `MCP credential field ${formatFieldPath([ + 'mcp', + serverName, + 'oauth', + 'clientSecret', + ])} must be a string before it can be synchronized.` + ); + } + if (clientSecret === '') { + throw new McpSecretExtractionError( + `MCP credential field ${formatFieldPath([ + 'mcp', + serverName, + 'oauth', + 'clientSecret', + ])} must not be empty before it can be synchronized.` + ); + } if (isSecretString(clientSecret)) { const envVar = buildEnvVar(serverName, 'OAUTH_CLIENT_SECRET'); oauth.clientSecret = `{env:${envVar}}`; @@ -89,17 +141,34 @@ function isAuthorizationHeader(headerName?: string): boolean { return normalized === 'authorization' || normalized === 'proxy-authorization'; } +function formatFieldPath(path: string[]): string { + let result = path[0] ?? ''; + for (const key of path.slice(1)) { + result += /^[a-zA-Z_$][a-zA-Z0-9_$]*$/u.test(key) ? `.${key}` : `[${JSON.stringify(key)}]`; + } + return `"${result}"`; +} + function setNestedValue(target: Record, path: string[], value: unknown): void { let current = target; for (let i = 0; i < path.length - 1; i += 1) { const key = path[i]; - const next = current[key]; + const next = hasOwn(current, key) ? current[key] : undefined; if (!isPlainObject(next)) { - current[key] = {}; + defineOwnValue(current, key, {}); } current = current[key] as Record; } - current[path[path.length - 1]] = value; + defineOwnValue(current, path[path.length - 1], value); +} + +function defineOwnValue(target: Record, key: string, value: unknown): void { + Object.defineProperty(target, key, { + value, + enumerable: true, + configurable: true, + writable: true, + }); } function getPlainObject(value: unknown): Record | null { diff --git a/src/sync/resolve.test.ts b/src/sync/resolve.test.ts new file mode 100644 index 0000000..0802966 --- /dev/null +++ b/src/sync/resolve.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; + +import { applyOverridesToRuntimeConfig, parseJsonc } from './config.js'; + +describe('applyOverridesToRuntimeConfig environment resolution', () => { + it('resolves only local override values and preserves unrelated runtime identity', () => { + const unrelated = { + keep: true, + rawPlaceholder: '{env:DO_NOT_RESOLVE}', + }; + const config: Record = { + unrelated, + mcp: { + github: { + enabled: true, + headers: { Authorization: 'old-value' }, + }, + }, + }; + const overrides = { + mcp: { + github: { + headers: { Authorization: 'Bearer {env:GITHUB_PAT}' }, + values: ['{env:GITHUB_PAT}', 42, false, null], + }, + }, + }; + + applyOverridesToRuntimeConfig(config, overrides, { GITHUB_PAT: 'secret-value' }); + + expect(config.unrelated).toBe(unrelated); + expect(config.unrelated).toEqual({ + keep: true, + rawPlaceholder: '{env:DO_NOT_RESOLVE}', + }); + expect(config.mcp).toEqual({ + github: { + enabled: true, + headers: { Authorization: 'Bearer secret-value' }, + values: ['secret-value', 42, false, null], + }, + }); + }); + + it('parses JSONC overrides before resolving placeholders', () => { + const overrides = parseJsonc>(` + { + // Local MCP credential + "mcp": { + "github": { + "headers": ["Bearer {env:GITHUB_PAT}", 7, true,], + }, + }, + } + `); + const config: Record = {}; + + applyOverridesToRuntimeConfig(config, overrides, { GITHUB_PAT: 'jsonc-secret' }); + + expect(config).toEqual({ + mcp: { + github: { + headers: ['Bearer jsonc-secret', 7, true], + }, + }, + }); + }); + + it('rejects a missing environment variable with its override field path', () => { + const config = { untouched: true }; + const overrides = { + mcp: { + github: { + headers: { Authorization: 'Bearer {env:MISSING_PAT}' }, + }, + }, + }; + + expect(() => applyOverridesToRuntimeConfig(config, overrides, {})).toThrow( + 'Missing environment variable "MISSING_PAT" required by local override ' + + '"overrides.mcp.github.headers.Authorization".' + ); + expect(config).toEqual({ untouched: true }); + }); + + it('rejects an empty environment variable with its array field path', () => { + const config: Record = {}; + const overrides = { headers: ['{env:EMPTY_TOKEN}'] }; + + expect(() => applyOverridesToRuntimeConfig(config, overrides, { EMPTY_TOKEN: '' })).toThrow( + 'Environment variable "EMPTY_TOKEN" required by local override ' + + '"overrides.headers[0]" is empty.' + ); + }); + + it('rejects __proto__ override keys without mutating object prototypes', () => { + const config: Record = {}; + const overrides = JSON.parse('{"__proto__":{"polluted":"yes"}}') as Record; + + expect(() => applyOverridesToRuntimeConfig(config, overrides, {})).toThrow( + 'Unsafe local override field "overrides.__proto__" is not allowed.' + ); + expect(({} as Record).polluted).toBeUndefined(); + expect(Object.getPrototypeOf(config)).toBe(Object.prototype); + }); +});