diff --git a/.changeset/hardening-supply-chain.md b/.changeset/hardening-supply-chain.md new file mode 100644 index 00000000..1eea4a3d --- /dev/null +++ b/.changeset/hardening-supply-chain.md @@ -0,0 +1,22 @@ +--- +'@moonshot-ai/kimi-code': minor +'@moonshot-ai/agent-core-v2': minor +'@moonshot-ai/agent-core': minor +--- + +Tighten how code arrives on the machine. + +The plugin catalog now has to be served over https from an allowed host +(`code.kimi.com` / `cdn.kimi.com` by default). The catalog picks which plugins +are offered and where their archives come from, and an installed plugin can +declare a spawnable `mcpServers` command, so whoever serves it effectively picks +code that runs locally. A self-hosted catalog is still possible by naming its +host in `KIMI_CODE_PLUGIN_MARKETPLACE_ALLOWED_HOSTS`. + +Remote plugin archives must likewise use https. Plaintext to loopback stays +allowed for local dev and test servers, where there is no network path to +tamper with. + +The native (`curl … install.sh | bash`) updater is no longer run unattended. +Nothing verifies what the CDN returns, so the command is now surfaced for the +user to run deliberately, matching what Windows already did. diff --git a/apps/kimi-code/src/cli/update/preflight.ts b/apps/kimi-code/src/cli/update/preflight.ts index 09889903..1e0b6c1e 100644 --- a/apps/kimi-code/src/cli/update/preflight.ts +++ b/apps/kimi-code/src/cli/update/preflight.ts @@ -87,7 +87,7 @@ export function installCommandFor( } } -export function canAutoInstall(source: InstallSource, platform: NodeJS.Platform): boolean { +export function canAutoInstall(source: InstallSource, _platform: NodeJS.Platform): boolean { switch (source) { case 'npm-global': case 'pnpm-global': @@ -99,7 +99,11 @@ export function canAutoInstall(source: InstallSource, platform: NodeJS.Platform) // behind the CDN release — prompt the user to run `brew upgrade` manually. return false; case 'native': - return platform !== 'win32'; + // The native updater is `curl … install.sh | bash` against the CDN, + // with nothing verifying what comes back. Running that unattended in + // the background turns a bad day at the CDN into local code execution, + // so surface the command and let the user run it deliberately. + return false; case 'unsupported': return false; } diff --git a/apps/kimi-code/src/utils/plugin-marketplace.ts b/apps/kimi-code/src/utils/plugin-marketplace.ts index 2ad4caa8..81dfe89a 100644 --- a/apps/kimi-code/src/utils/plugin-marketplace.ts +++ b/apps/kimi-code/src/utils/plugin-marketplace.ts @@ -182,12 +182,62 @@ export function parsePluginMarketplace(raw: string, location: MarketplaceLocatio }; } +/** + * Hosts the catalog may be fetched from over the network. + * + * The catalog decides which plugins are offered and where their archives come + * from, and an installed plugin can declare an `mcpServers` command that gets + * spawned — so whoever serves this file chooses code that runs on the machine. + * Anything beyond these hosts has to be named deliberately through + * `KIMI_CODE_PLUGIN_MARKETPLACE_ALLOWED_HOSTS` (comma-separated), which keeps a + * self-hosted internal catalog possible without leaving the default open. + */ +const DEFAULT_MARKETPLACE_HOSTS = ['code.kimi.com', 'cdn.kimi.com']; +const MARKETPLACE_ALLOWED_HOSTS_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_ALLOWED_HOSTS'; + +function allowedMarketplaceHosts(env: NodeJS.ProcessEnv = process.env): readonly string[] { + const extra = (env[MARKETPLACE_ALLOWED_HOSTS_ENV] ?? '') + .split(',') + .map((host) => host.trim().toLowerCase()) + .filter((host) => host.length > 0); + return [...DEFAULT_MARKETPLACE_HOSTS, ...extra]; +} + +const LOOPBACK_MARKETPLACE_HOSTS = new Set(['localhost', '127.0.0.1', '::1']); + +function assertAllowedMarketplaceUrl(raw: string): void { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new Error(`Plugin marketplace URL is not a valid URL: ${raw}`); + } + // A catalog served from this machine has no network path to tamper with. + if (LOOPBACK_MARKETPLACE_HOSTS.has(url.hostname.toLowerCase())) return; + if (url.protocol !== 'https:') { + throw new Error( + `Plugin marketplace must be served over https (got "${url.protocol}//"). ` + + `The catalog selects code that will run locally, so it is not fetched over plaintext.`, + ); + } + const host = url.hostname.toLowerCase(); + const allowed = allowedMarketplaceHosts(); + if (!allowed.includes(host)) { + throw new Error( + `Plugin marketplace host "${host}" is not allowed. ` + + `Allowed: ${allowed.join(', ')}. ` + + `Add it to ${MARKETPLACE_ALLOWED_HOSTS_ENV} to use a self-hosted catalog.`, + ); + } +} + function resolveMarketplaceLocation(source: string, workDir: string): MarketplaceLocation { const trimmed = source.trim(); if (trimmed.length === 0) { throw new Error(`${KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV} cannot be empty.`); } if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + assertAllowedMarketplaceUrl(trimmed); return { raw: trimmed, kind: 'remote', resolved: trimmed }; } if (trimmed.startsWith('file://')) { diff --git a/apps/kimi-code/test/cli/update/preflight.test.ts b/apps/kimi-code/test/cli/update/preflight.test.ts index 2f7439f5..4dcae7ca 100644 --- a/apps/kimi-code/test/cli/update/preflight.test.ts +++ b/apps/kimi-code/test/cli/update/preflight.test.ts @@ -491,28 +491,21 @@ describe('runUpdatePreflight', () => { expect(mocks.spawn).not.toHaveBeenCalled(); }); - it('native on darwin: spawns bash -c with pipefail-guarded curl|bash', async () => { + it('native on darwin: prints the manual install command, does not spawn', async () => { + // The native updater is an unverified `curl … | bash` against the CDN, so + // it is never run unattended: the command is surfaced for the user to run. disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('native'); - mocks.promptForInstallChoice.mockResolvedValue('install'); - mockSpawnExit(0); const originalPlatform = process.platform; Object.defineProperty(process, 'platform', { value: 'darwin' }); try { - const { options } = captureOutput(); - await runUpdatePreflight('0.4.0', options); - const call = mocks.spawn.mock.calls[0]; - expect(call?.[0]).toBe('bash'); - expect(call?.[2]).toEqual({ stdio: 'inherit' }); - const [flag, script] = call?.[1] as string[]; - expect(flag).toBe('-c'); - // pipefail must come before the pipeline so a failed `curl` is not masked - // by the trailing `bash` exiting 0 (see "surfaces a failed curl" below). - expect(script).toContain('set -o pipefail'); - expect(script).toContain('curl -fsSL https://code.kimi.com/kimi-code/install.sh'); - expect(script).toContain('| bash'); + const { stdout, options } = captureOutput(); + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + expect(stdout.join('')).toContain('curl -fsSL https://code.kimi.com/kimi-code/install.sh'); + expect(promptForInstallChoice).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); } finally { Object.defineProperty(process, 'platform', { value: originalPlatform }); } diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index acd25f34..4a178f8a 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -5638,6 +5638,9 @@ command = "vim" it('shows an inline Official error when the marketplace is unreachable, keeping the panel open', async () => { const originalFetch = globalThis.fetch; process.env['KIMI_CODE_PLUGIN_MARKETPLACE_URL'] = 'https://example.test/marketplace.json'; + // Allow the host so this stays a test about an unreachable catalog rather + // than one rejected by the host allowlist. + process.env['KIMI_CODE_PLUGIN_MARKETPLACE_ALLOWED_HOSTS'] = 'example.test'; vi.stubGlobal( 'fetch', vi.fn(async () => { @@ -5665,6 +5668,7 @@ command = "vim" // The panel stays mounted; the failure does not close /plugins. expect(driver.state.editorContainer.children[0]).toBe(panel); } finally { + delete process.env['KIMI_CODE_PLUGIN_MARKETPLACE_ALLOWED_HOSTS']; vi.stubGlobal('fetch', originalFetch); } }); diff --git a/apps/kimi-code/test/utils/plugin-marketplace.test.ts b/apps/kimi-code/test/utils/plugin-marketplace.test.ts index 5bc803ec..413357fa 100644 --- a/apps/kimi-code/test/utils/plugin-marketplace.test.ts +++ b/apps/kimi-code/test/utils/plugin-marketplace.test.ts @@ -305,6 +305,9 @@ describe('loadPluginMarketplace', () => { }); it('keeps the built-in entries when the catalog is unreachable', async () => { + // Not a default catalog host; name it explicitly the way an operator + // would for a self-hosted catalog. + vi.stubEnv('KIMI_CODE_PLUGIN_MARKETPLACE_ALLOWED_HOSTS', 'example.test,example.com'); const fetchImpl = vi.fn(async () => { throw new Error('fetch failed'); }) as unknown as typeof fetch; @@ -524,6 +527,7 @@ describe('loadPluginMarketplace', () => { }); it('loads an explicit remote marketplace with injectable fetch', async () => { + vi.stubEnv('KIMI_CODE_PLUGIN_MARKETPLACE_ALLOWED_HOSTS', 'example.com'); const source = 'https://example.com/plugins/marketplace.json'; const fetchImpl = vi.fn(async () => ({ ok: true, @@ -588,4 +592,49 @@ describe('loadPluginMarketplace', () => { ); }); + }); + +describe('marketplace host policy', () => { + it('rejects a plaintext http catalog', async () => { + await expect( + loadPluginMarketplace({ workDir: '/tmp', source: 'http://evil.example/marketplace.json' }), + ).rejects.toThrow(/must be served over https/); + }); + + it('rejects an https catalog on a host that is not allowed', async () => { + await expect( + loadPluginMarketplace({ workDir: '/tmp', source: 'https://evil.example/marketplace.json' }), + ).rejects.toThrow(/is not allowed/); + }); + + it('allows a self-hosted catalog named in the allowlist env', async () => { + vi.stubEnv('KIMI_CODE_PLUGIN_MARKETPLACE_ALLOWED_HOSTS', 'internal.example'); + const fetchImpl = vi.fn(async () => + new Response(JSON.stringify({ plugins: [] }), { status: 200 }), + ) as unknown as typeof fetch; + + await expect( + loadPluginMarketplace({ + workDir: '/tmp', + source: 'https://internal.example/marketplace.json', + fetchImpl, + }), + ).resolves.toMatchObject({ plugins: [] }); + }); + + it('allows a loopback catalog over http (local dev server)', async () => { + const fetchImpl = vi.fn(async () => + new Response(JSON.stringify({ plugins: [] }), { status: 200 }), + ) as unknown as typeof fetch; + + await expect( + loadPluginMarketplace({ + workDir: '/tmp', + source: 'http://127.0.0.1:8787/marketplace.json', + fetchImpl, + }), + ).resolves.toMatchObject({ plugins: [] }); + }); +}); + diff --git a/packages/agent-core-v2/src/app/plugin/source.ts b/packages/agent-core-v2/src/app/plugin/source.ts index 7909e81a..e8cdd4d7 100644 --- a/packages/agent-core-v2/src/app/plugin/source.ts +++ b/packages/agent-core-v2/src/app/plugin/source.ts @@ -16,13 +16,39 @@ export type InstallSource = ResolvedSource; const SHA_RE = /^[0-9a-f]{7,40}$/; +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); + +/** + * Plaintext to the local machine has no network path to tamper with, so it + * stays allowed (local test servers, `pnpm dev:plugin-marketplace`). Plaintext + * to anything else does not. + */ +function isLoopbackUrl(raw: string): boolean { + try { + return LOOPBACK_HOSTS.has(new URL(raw).hostname.toLowerCase()); + } catch { + return false; + } +} + + export function resolveInstallSource(source: string): ResolvedSource { const trimmed = source.trim(); const github = parseGithubUrl(trimmed); if (github !== undefined) return github; - if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + if (trimmed.startsWith('http://') && !isLoopbackUrl(trimmed)) { + // A plugin archive is executable content: it can ship an mcpServers + // command that gets spawned. Over plaintext there is nothing binding the + // bytes to the publisher, so refuse rather than trust the network. + throw new Error2( + ErrorCodes.VALIDATION_FAILED, + `Plugin source must use https (got "${trimmed}")`, + { details: { source } }, + ); + } + if (trimmed.startsWith('https://') || trimmed.startsWith('http://')) { return { kind: 'zip-url', path: trimmed }; } if (!path.isAbsolute(trimmed)) { diff --git a/packages/agent-core/src/plugin/source.ts b/packages/agent-core/src/plugin/source.ts index 38a02ece..5f8e76d0 100644 --- a/packages/agent-core/src/plugin/source.ts +++ b/packages/agent-core/src/plugin/source.ts @@ -15,13 +15,35 @@ export type InstallSource = ResolvedSource; const SHA_RE = /^[0-9a-f]{7,40}$/; +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); + +/** + * Plaintext to the local machine has no network path to tamper with, so it + * stays allowed (local test servers, `pnpm dev:plugin-marketplace`). Plaintext + * to anything else does not. + */ +function isLoopbackUrl(raw: string): boolean { + try { + return LOOPBACK_HOSTS.has(new URL(raw).hostname.toLowerCase()); + } catch { + return false; + } +} + + export function resolveInstallSource(source: string): ResolvedSource { const trimmed = source.trim(); const github = parseGithubUrl(trimmed); if (github !== undefined) return github; - if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + if (trimmed.startsWith('http://') && !isLoopbackUrl(trimmed)) { + // A plugin archive is executable content: it can ship an mcpServers + // command that gets spawned. Over plaintext there is nothing binding the + // bytes to the publisher, so refuse rather than trust the network. + throw new Error(`Plugin source must use https (got "${source}")`); + } + if (trimmed.startsWith('https://') || trimmed.startsWith('http://')) { return { kind: 'zip-url', path: trimmed }; } if (!path.isAbsolute(trimmed)) { diff --git a/packages/agent-core/test/plugin/source.test.ts b/packages/agent-core/test/plugin/source.test.ts index 12b76bc4..f3981fa1 100644 --- a/packages/agent-core/test/plugin/source.test.ts +++ b/packages/agent-core/test/plugin/source.test.ts @@ -8,9 +8,15 @@ describe('resolveInstallSource', () => { expect(result).toEqual({ kind: 'zip-url', path: 'https://example.com/plugin.zip' }); }); - it('recognizes http:// as zip-url', () => { - const result = resolveInstallSource('http://example.com/plugin.zip'); - expect(result).toEqual({ kind: 'zip-url', path: 'http://example.com/plugin.zip' }); + it('rejects plaintext http:// for a remote plugin archive', () => { + // A plugin archive can ship a spawnable mcpServers command, so plaintext + // delivery from a remote host is refused. + expect(() => resolveInstallSource('http://example.com/plugin.zip')).toThrow(/must use https/); + }); + + it('still allows http:// to loopback (local test/dev servers)', () => { + const url = 'http://127.0.0.1:8080/plugin.zip'; + expect(resolveInstallSource(url)).toEqual({ kind: 'zip-url', path: url }); }); it('recognizes absolute path as local-path', () => { @@ -169,10 +175,9 @@ describe('resolveInstallSource', () => { expect(result).toEqual({ kind: 'zip-url', path: url }); }); - it('treats http:// (non-https) github URL as plain zip-url', () => { + it('rejects a http:// github URL rather than treating it as a zip-url', () => { const url = 'http://github.com/wbxl2000/superpowers'; - const result = resolveInstallSource(url); - expect(result).toEqual({ kind: 'zip-url', path: url }); + expect(() => resolveInstallSource(url)).toThrow(/must use https/); }); it('percent-decodes %23 in /releases/tag/ so storage is human-readable', () => {