Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/hardening-supply-chain.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 6 additions & 2 deletions apps/kimi-code/src/cli/update/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand All @@ -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;
}
Expand Down
50 changes: 50 additions & 0 deletions apps/kimi-code/src/utils/plugin-marketplace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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://')) {
Expand Down
23 changes: 8 additions & 15 deletions apps/kimi-code/test/cli/update/preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down
4 changes: 4 additions & 0 deletions apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
}
});
Expand Down
49 changes: 49 additions & 0 deletions apps/kimi-code/test/utils/plugin-marketplace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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: [] });
});
});

28 changes: 27 additions & 1 deletion packages/agent-core-v2/src/app/plugin/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
24 changes: 23 additions & 1 deletion packages/agent-core/src/plugin/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
17 changes: 11 additions & 6 deletions packages/agent-core/test/plugin/source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading