From 958f23e8681d76e5c76e2537b08ca44305ba10b5 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 6 Aug 2026 18:33:55 +0800 Subject: [PATCH] fix(kimi-code): close pre-trust-gate bare command resolution on Windows On Windows, cmd.exe / CreateProcess resolve a bare command name from the current directory before PATH. Several startup-path child processes ran before the workspace trust prompt, so a binary planted in an untrusted workspace (stty.exe, npm.cmd, fd.exe) could execute before the user confirmed trust. - skip the POSIX-only stty save/restore entirely on win32 - defer fd detection from the KimiTUI field initializer to startBackgroundFdAutocomplete(), which runs after the trust gate - add resolveCommandPath(): resolve commands through PATH (PATHEXT-aware on win32) to an absolute path and refuse hits inside the cwd - route update-preflight package-manager spawns and the npm global-prefix probe through it - run the workspace trust prompt before the migration branch as well, closing the blind spot where a pending ~/.kimi migration skipped it - document the no-bare-command-before-trust-gate rule in apps/kimi-code/AGENTS.md --- .../windows-bare-command-binary-planting.md | 5 + apps/kimi-code/AGENTS.md | 1 + apps/kimi-code/src/cli/run-shell.ts | 27 ++-- apps/kimi-code/src/cli/update/preflight.ts | 32 +++- apps/kimi-code/src/cli/update/source.ts | 14 +- apps/kimi-code/src/tui/kimi-tui.ts | 28 +++- .../src/utils/process/resolve-command.ts | 79 ++++++++++ apps/kimi-code/test/cli/run-shell.test.ts | 20 ++- .../test/cli/update/preflight.test.ts | 74 ++++++++- apps/kimi-code/test/cli/update/source.test.ts | 22 ++- .../test/tui/kimi-tui-startup.test.ts | 73 +++++++++ .../utils/process/resolve-command.test.ts | 147 ++++++++++++++++++ 12 files changed, 498 insertions(+), 24 deletions(-) create mode 100644 .changeset/windows-bare-command-binary-planting.md create mode 100644 apps/kimi-code/src/utils/process/resolve-command.ts create mode 100644 apps/kimi-code/test/utils/process/resolve-command.test.ts diff --git a/.changeset/windows-bare-command-binary-planting.md b/.changeset/windows-bare-command-binary-planting.md new file mode 100644 index 0000000000..6c29f6026f --- /dev/null +++ b/.changeset/windows-bare-command-binary-planting.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix a Windows binary-planting risk: child processes spawned by bare command name before the workspace trust prompt (stty, fd detection, package-manager update installs) could resolve to a malicious executable placed in the current directory. These commands are now skipped on Windows, deferred until after the trust prompt, or resolved to an absolute PATH location with hits inside the current directory refused. diff --git a/apps/kimi-code/AGENTS.md b/apps/kimi-code/AGENTS.md index 11184d9588..857dc09e93 100644 --- a/apps/kimi-code/AGENTS.md +++ b/apps/kimi-code/AGENTS.md @@ -65,6 +65,7 @@ The theme apply/switch mechanics live in the `write-tui` skill. The following ru ## General Coding Requirements +- The startup path before the workspace trust gate (`KimiTUI.start()` -> `maybeRunWorkspaceTrustPrompt()`) must not spawn child processes by bare command name — on Windows, cmd.exe / CreateProcess resolve them from the current directory first, so a binary planted in an untrusted workspace would run before the user confirms trust. When an external command is unavoidable, resolve it with `resolveCommandPath` from `src/utils/process/resolve-command.ts`, which returns an absolute PATH hit and refuses matches inside the cwd. - For optional object properties, pass `undefined` directly — do not use conditional spread. - Optional object properties do not need to additionally allow `undefined` in the type. - Internal methods with only a single parameter should not be turned into options objects just for stylistic uniformity. diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 3d6c741ceb..d7a13cb756 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -155,17 +155,22 @@ export async function runShell( }; let savedStty: string | undefined; - try { - // stty operates on the terminal behind stdin, so stdin must be the TTY — - // piping /dev/null (ignore) makes stty fail with "not a tty". - const saved = execSync('stty -g', { - encoding: 'utf8', - stdio: ['inherit', 'pipe', 'ignore'], - }); - savedStty = typeof saved === 'string' ? saved.trim() : undefined; - execSync('stty -ixon', { stdio: ['inherit', 'ignore', 'ignore'] }); - } catch { - /* ignore */ + // stty is a POSIX command and never works on Windows; skip it there instead + // of relying on the catch — a bare command name would resolve a planted + // `stty.exe` from the current directory before the workspace trust gate. + if (process.platform !== 'win32') { + try { + // stty operates on the terminal behind stdin, so stdin must be the TTY — + // piping /dev/null (ignore) makes stty fail with "not a tty". + const saved = execSync('stty -g', { + encoding: 'utf8', + stdio: ['inherit', 'pipe', 'ignore'], + }); + savedStty = typeof saved === 'string' ? saved.trim() : undefined; + execSync('stty -ixon', { stdio: ['inherit', 'ignore', 'ignore'] }); + } catch { + /* ignore */ + } } const restoreStty = (): void => { if (savedStty === undefined) return; diff --git a/apps/kimi-code/src/cli/update/preflight.ts b/apps/kimi-code/src/cli/update/preflight.ts index 098899035c..5bcad7c9bf 100644 --- a/apps/kimi-code/src/cli/update/preflight.ts +++ b/apps/kimi-code/src/cli/update/preflight.ts @@ -9,6 +9,7 @@ import { NATIVE_INSTALL_COMMAND_WIN, } from '#/constant/app'; import { loadTuiConfig } from '#/tui/config'; +import { resolveCommandPath } from '#/utils/process/resolve-command'; import { readUpdateCache } from './cache'; import { tryAcquireUpdateInstallLock } from './install-lock'; @@ -142,6 +143,21 @@ function formatErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +/** + * Resolve a spawn target from `spawnForSource` to an absolute executable path + * via PATH, refusing hits inside the current working directory: the update + * preflight runs before the workspace trust gate, so a package-manager binary + * planted in an untrusted workspace must never be executed. On win32 the + * resolved path is quoted because the spawn goes through cmd.exe (shell: + * true) and paths like `C:\Program Files\...` would otherwise split. Returns + * undefined when the command cannot be safely resolved. + */ +function resolveSpawnCommand(cmd: string, platform: NodeJS.Platform): string | undefined { + const resolved = resolveCommandPath(cmd); + if (resolved === undefined) return undefined; + return platform === 'win32' ? `"${resolved}"` : resolved; +} + const THIRD_PARTY_SOURCE_NOTE = '\nNote: Third-party sources may lag behind the official release.\n' + `For the latest updates, use the official installer: ${KIMI_CODE_OFFICIAL_INSTALL_URL}\n`; @@ -493,12 +509,16 @@ export async function installUpdate( platform: NodeJS.Platform, ): Promise { const { cmd, args } = spawnForSource(source, version, platform); + const resolvedCmd = resolveSpawnCommand(cmd, platform); + if (resolvedCmd === undefined) { + throw new Error(`${cmd} was not found in PATH; cannot install the update`); + } await new Promise((resolve, reject) => { // Windows package managers (npm/pnpm/yarn) are .cmd shims. Since the // CVE-2024-27980 fix, Node throws EINVAL when spawning a .cmd/.bat without // a shell, so run through the shell on win32. The version is a validated // semver and the package name is a constant, so args are shell-safe. - const child = spawn(cmd, [...args], { + const child = spawn(resolvedCmd, [...args], { stdio: 'inherit', shell: platform === 'win32' ? true : undefined, }); @@ -609,7 +629,15 @@ async function startBackgroundInstall( }); }; - const child = spawn(cmd, [...args], { + const resolvedCmd = resolveSpawnCommand(cmd, platform); + if (resolvedCmd === undefined) { + // The package manager cannot be resolved to an absolute path outside + // the cwd — record a normal install failure instead of spawning a bare + // command name that Windows would resolve into the untrusted workspace. + finish(false); + return; + } + const child = spawn(resolvedCmd, [...args], { detached: true, stdio: 'ignore', shell: platform === 'win32' ? true : undefined, diff --git a/apps/kimi-code/src/cli/update/source.ts b/apps/kimi-code/src/cli/update/source.ts index 7d6904b673..464e323186 100644 --- a/apps/kimi-code/src/cli/update/source.ts +++ b/apps/kimi-code/src/cli/update/source.ts @@ -4,6 +4,7 @@ import { createRequire } from 'node:module'; import { join, resolve } from 'node:path'; import { getHostPackageRoot } from '#/cli/version'; +import { resolveCommandPath } from '#/utils/process/resolve-command'; import { NPM_PACKAGE_NAME, type InstallSource } from './types'; @@ -76,6 +77,17 @@ function npmCommand(platform: NodeJS.Platform): string { return platform === 'win32' ? 'npm.cmd' : 'npm'; } +// The install-source detection runs before the workspace trust gate, so the +// npm binary must be resolved through PATH to an absolute path — a bare name +// would let cmd.exe pick up an `npm.cmd` planted in the current directory. +function npmGlobalPrefix(platform: NodeJS.Platform): Promise { + const resolved = resolveCommandPath(npmCommand(platform)); + if (resolved === undefined) { + return Promise.reject(new Error('npm was not found in PATH')); + } + return execFileText(resolved, ['prefix', '-g']).then((text) => text.trim()); +} + function execFileText(command: string, args: readonly string[]): Promise { return new Promise((resolveOutput, reject) => { execFile(command, [...args], { encoding: 'utf-8' }, (error, stdout) => { @@ -140,7 +152,7 @@ export async function detectInstallSource( getPackageRoot: deps.getPackageRoot ?? getHostPackageRoot, getGlobalPrefix: deps.getGlobalPrefix ?? - (() => execFileText(npmCommand(platform), ['prefix', '-g']).then((text) => text.trim())), + (() => npmGlobalPrefix(platform)), detectNative: deps.detectNative ?? detectNativeInstall, platform, }; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 7c118e57a3..c1254bce89 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -327,7 +327,10 @@ export class KimiTUI { private pluginCommands: readonly KimiSlashCommand[] = []; readonly pluginCommandMap = new Map(); private readonly imageStore = new ImageAttachmentStore(); - private fdPath: string | null = detectFdPath(); + // Detected lazily in startBackgroundFdAutocomplete() — detection spawns + // `fd --version`, which must not happen before the workspace trust gate: + // on Windows a bare command name resolves into the (untrusted) cwd first. + private fdPath: string | null = null; private fdDownloadStarted = false; sessionEventUnsubscribe: (() => void) | undefined; cancelInFlight: (() => void) | undefined; @@ -586,9 +589,19 @@ export class KimiTUI { this.registerSignalHandlers(); // Outer try rolls back signal listeners on startup failure. try { + // The workspace trust gate must run before anything else in startup — + // including the migration branch: a workspace that needs migration is + // not implicitly trusted, and later startup steps spawn child processes. + startupTrace('trustPrompt:begin'); + const trustPromptStartedLoop = await this.maybeRunWorkspaceTrustPrompt(); + startupTrace('trustPrompt:end'); + if (this.migrationPlan !== null) { // Migration needs the event loop running first (pi-tui component). - this.startEventLoop(); + // When the trust prompt already started it, starting it again would + // re-run pi-tui's terminal.start() — stacking a second Kitty + // keyboard-protocol push and duplicate stdin listeners. + if (!trustPromptStartedLoop) this.startEventLoop(); try { const migrationResult = await this.runMigrationScreen(this.migrationPlan); if (this.migrateOnly) { @@ -609,9 +622,6 @@ export class KimiTUI { return; } - startupTrace('trustPrompt:begin'); - const trustPromptStartedLoop = await this.maybeRunWorkspaceTrustPrompt(); - startupTrace('trustPrompt:end'); startupTrace('initMainTui:begin'); const shouldReplayHistory = await this.initMainTui(); startupTrace('initMainTui:end'); @@ -724,9 +734,15 @@ export class KimiTUI { } private startBackgroundFdAutocomplete(): void { - if (this.fdPath !== null || this.fdDownloadStarted) return; + if (this.fdDownloadStarted) return; this.fdDownloadStarted = true; + this.fdPath = detectFdPath(); + if (this.fdPath !== null) { + this.setupAutocomplete(); + return; + } + void ensureFdPath() .then((fdPath) => { if (fdPath === null) return; diff --git a/apps/kimi-code/src/utils/process/resolve-command.ts b/apps/kimi-code/src/utils/process/resolve-command.ts new file mode 100644 index 0000000000..721342e601 --- /dev/null +++ b/apps/kimi-code/src/utils/process/resolve-command.ts @@ -0,0 +1,79 @@ +import { accessSync, constants, statSync } from 'node:fs'; +import { isAbsolute, join, relative, resolve } from 'node:path'; + +// cmd.exe / CreateProcess search the current directory before PATH, so on +// Windows a bare command name can execute a binary planted in the workspace +// the user just opened (binary planting). Resolving through PATH ourselves — +// and refusing any hit inside the cwd — keeps that from happening before the +// workspace trust gate has run. + +const DEFAULT_WIN32_PATHEXT = ['.COM', '.EXE', '.BAT', '.CMD']; + +function pathExtensions(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): readonly string[] { + if (platform !== 'win32') return ['']; + const raw = env['PATHEXT']; + if (raw === undefined || raw.trim().length === 0) return DEFAULT_WIN32_PATHEXT; + return raw + .split(';') + .map((ext) => ext.trim()) + .filter((ext) => ext.length > 0); +} + +function candidateNames(command: string, extensions: readonly string[]): readonly string[] { + if (extensions.length === 1 && extensions[0] === '') return [command]; + const lower = command.toLowerCase(); + // An explicitly suffixed name (npm.cmd) is tried as-is first, like cmd.exe. + if (extensions.some((ext) => lower.endsWith(ext.toLowerCase()))) { + return [command, ...extensions.map((ext) => command + ext)]; + } + return extensions.map((ext) => command + ext); +} + +function isExecutableFile(candidate: string, platform: NodeJS.Platform): boolean { + try { + if (!statSync(candidate).isFile()) return false; + // Windows has no executable bit; file existence is enough there. + if (platform !== 'win32') accessSync(candidate, constants.X_OK); + return true; + } catch { + return false; + } +} + +function isInsideCwd(candidate: string, cwd: string, platform: NodeJS.Platform): boolean { + let resolvedCandidate = resolve(candidate); + let resolvedCwd = resolve(cwd); + if (platform === 'win32') { + resolvedCandidate = resolvedCandidate.toLowerCase(); + resolvedCwd = resolvedCwd.toLowerCase(); + } + const rel = relative(resolvedCwd, resolvedCandidate); + return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel); +} + +/** + * Resolve a bare command name to an absolute executable path by searching + * PATH (PATHEXT-aware on Windows). Returns undefined when the command is not + * found — or when the only hit lives inside `cwd`, since executing that would + * run whatever a malicious workspace planted there. + */ +export function resolveCommandPath(command: string, cwd: string = process.cwd()): string | undefined { + const platform = process.platform; + const env = process.env; + const extensions = pathExtensions(platform, env); + const names = candidateNames(command, extensions); + const pathValue = env['PATH'] ?? ''; + const separator = platform === 'win32' ? ';' : ':'; + for (const dir of pathValue.split(separator)) { + // An empty PATH entry means the current directory on POSIX — anything it + // could produce would be rejected by the cwd check anyway, so skip it. + if (dir === '') continue; + for (const name of names) { + const candidate = join(dir, name); + if (!isExecutableFile(candidate, platform)) continue; + if (isInsideCwd(candidate, cwd, platform)) return undefined; + return resolve(candidate); + } + } + return undefined; +} diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index c4e95c1d1b..35a0966592 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -297,7 +297,13 @@ describe('runShell', () => { expect(mocks.harnessEnsureConfigFile.mock.invocationCallOrder[0]).toBeLessThan( mocks.harnessGetConfig.mock.invocationCallOrder[0]!, ); - expect(execSync).toHaveBeenCalledWith('stty -ixon', { stdio: ['inherit', 'ignore', 'ignore'] }); + // stty is POSIX-only; on Windows the save/restore block is skipped + // entirely (a bare `stty` name would resolve into the untrusted cwd). + if (process.platform !== 'win32') { + expect(execSync).toHaveBeenCalledWith('stty -ixon', { stdio: ['inherit', 'ignore', 'ignore'] }); + } else { + expect(execSync).not.toHaveBeenCalled(); + } expect(mocks.kimiTuiConstructor).toHaveBeenCalledTimes(1); expect(mocks.createKimiDeviceId).toHaveBeenCalledWith( '/tmp/kimi-code-test-home', @@ -339,6 +345,18 @@ describe('runShell', () => { }); }); + it('never runs stty on Windows, where it would resolve into the untrusted cwd', async () => { + stubTuiStartup(); + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + await runShell(minimalCliOptions, '1.2.3-test'); + expect(execSync).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + } + }); + it('resolves the --agent profile into the TUI startup input', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', diff --git a/apps/kimi-code/test/cli/update/preflight.test.ts b/apps/kimi-code/test/cli/update/preflight.test.ts index 2f7439f51b..3382d622eb 100644 --- a/apps/kimi-code/test/cli/update/preflight.test.ts +++ b/apps/kimi-code/test/cli/update/preflight.test.ts @@ -37,6 +37,13 @@ const mocks = vi.hoisted(() => ({ resolveUpdateDeviceId: vi.fn(), appendRolloutDecisionLog: vi.fn(), spawn: vi.fn(), + // Identity by default: resolution is covered by resolve-command.test.ts; + // here we only care which command string reaches spawn(). + resolveCommandPath: vi.fn((cmd: string) => cmd as string | undefined), +})); + +vi.mock('#/utils/process/resolve-command', () => ({ + resolveCommandPath: mocks.resolveCommandPath, })); vi.mock('../../../src/cli/update/cache', () => ({ @@ -240,6 +247,7 @@ describe('runUpdatePreflight', () => { filePath: '/tmp/kimi-update-install.lock', release: vi.fn().mockResolvedValue(undefined), }); + mocks.resolveCommandPath.mockImplementation((cmd: string) => cmd); }); afterEach(() => { vi.clearAllMocks(); vi.unstubAllEnvs(); }); @@ -437,7 +445,8 @@ describe('runUpdatePreflight', () => { const { options } = captureOutput(); await runUpdatePreflight('0.4.0', options); expect(mocks.spawn).toHaveBeenCalledWith( - 'pnpm.cmd', + // Resolved to an absolute path and quoted for the cmd.exe shell. + '"pnpm.cmd"', ['add', '-g', '@moonshot-ai/kimi-code@0.5.0'], { stdio: 'inherit', shell: true }, ); @@ -570,6 +579,66 @@ describe('runUpdatePreflight', () => { expect(stdout.join('')).not.toContain('Updated @moonshot-ai/kimi-code'); }); + it('spawns the resolved absolute path instead of the bare command name', async () => { + disableAutoInstall(); + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallChoice.mockResolvedValue('install'); + mocks.resolveCommandPath.mockReturnValue('/usr/local/bin/npm'); + mockSpawnExit(0); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('exit'); + + expect(mocks.resolveCommandPath).toHaveBeenCalledWith('npm'); + expect(mocks.spawn).toHaveBeenCalledWith( + '/usr/local/bin/npm', + ['install', '-g', '@moonshot-ai/kimi-code@0.5.0'], + { stdio: 'inherit' }, + ); + }); + + it('warns and continues without spawning when the package manager cannot be resolved', async () => { + disableAutoInstall(); + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallChoice.mockResolvedValue('install'); + // Only resolvable inside the cwd (or missing entirely): refuse to run it. + mocks.resolveCommandPath.mockReturnValue(undefined); + const { stdout, stderr, options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + + expect(mocks.spawn).not.toHaveBeenCalled(); + expect(stderr.join('')).toContain('warning: failed to install'); + expect(stdout.join('')).not.toContain('Updated @moonshot-ai/kimi-code'); + }); + + it('records a background install failure without spawning when the package manager cannot be resolved', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.resolveCommandPath.mockReturnValue(undefined); + const { stderr, options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(mocks.spawn).not.toHaveBeenCalled(); + expect(stderr.join('')).toBe(''); + expect(writeUpdateInstallState).toHaveBeenLastCalledWith(expect.objectContaining({ + active: null, + lastFailure: expect.objectContaining({ + version: '0.5.0', + attempts: 1, + }), + lastSuccess: null, + })); + }); + it('starts an automatic update in the background by default', async () => { mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.readUpdateInstallState.mockResolvedValue(installState()); @@ -619,7 +688,8 @@ describe('runUpdatePreflight', () => { const { options } = captureOutput(); await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); expect(mocks.spawn).toHaveBeenCalledWith( - 'npm.cmd', + // Resolved to an absolute path and quoted for the cmd.exe shell. + '"npm.cmd"', ['install', '-g', '@moonshot-ai/kimi-code@0.5.0'], { detached: true, stdio: 'ignore', shell: true, windowsHide: true }, ); diff --git a/apps/kimi-code/test/cli/update/source.test.ts b/apps/kimi-code/test/cli/update/source.test.ts index dd88d32c3c..babe509a20 100644 --- a/apps/kimi-code/test/cli/update/source.test.ts +++ b/apps/kimi-code/test/cli/update/source.test.ts @@ -1,10 +1,15 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { classifyByPathHeuristic, classifyInstallSource, detectInstallSource, } from '#/cli/update/source'; +import { resolveCommandPath } from '#/utils/process/resolve-command'; + +vi.mock('#/utils/process/resolve-command', () => ({ + resolveCommandPath: vi.fn(), +})); describe('classifyByPathHeuristic', () => { it('returns null for an npm-style global path (handled by classifyInstallSource)', () => { @@ -176,4 +181,19 @@ describe('detectInstallSource', () => { }), ).resolves.toBe('unsupported'); }); + + it('returns unsupported when npm cannot be resolved outside the cwd', async () => { + // The default prefix lookup spawns npm; when it can only be found inside + // the current directory (or not at all), detection must degrade to + // 'unsupported' rather than run a planted binary. + vi.mocked(resolveCommandPath).mockReturnValue(undefined); + await expect( + detectInstallSource({ + getPackageRoot: () => '/Users/me/dev/@moonshot-ai/kimi-code', + detectNative: () => false, + platform: 'darwin', + }), + ).resolves.toBe('unsupported'); + expect(resolveCommandPath).toHaveBeenCalledWith('npm'); + }); }); diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index fe816442b6..2e76c04402 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -1941,6 +1941,79 @@ describe('KimiTUI startup', () => { expect(driver.terminalFocusTrackingDispose).toBeUndefined(); }); + it('checks workspace trust before entering the migration screen', async () => { + // The migration branch used to skip the trust gate entirely: a workspace + // with legacy ~/.kimi data went straight to the migration screen, and + // later startup steps spawned child processes in an untrusted directory. + const getWorkspaceTrustInfo = vi.fn(async () => ({ + trusted: true, + gatedMcpServers: [] as string[], + })); + const harness = makeHarness(makeSession(), { getWorkspaceTrustInfo }); + const driver = makeDriver(harness, { + ...makeStartupInput(), + migrationPlan: MIGRATION_PLAN, + migrateOnly: true, + engineV2: true, + }) as unknown as MigrateExitDriver; + vi.spyOn(driver.state.ui, 'start').mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'stop').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); + const migrationSpy = vi + .spyOn(driver, 'runMigrationScreen') + .mockResolvedValue({ decision: 'later' }); + const onExit = vi.fn(async () => {}); + driver.onExit = onExit; + + await driver.start(); + + expect(getWorkspaceTrustInfo).toHaveBeenCalledWith('/tmp/proj-a'); + expect(getWorkspaceTrustInfo.mock.invocationCallOrder[0]!).toBeLessThan( + migrationSpy.mock.invocationCallOrder[0]!, + ); + expect(onExit).toHaveBeenCalledWith(0); + }); + + it('prompts for workspace trust before migrating an untrusted workspace', async () => { + const getWorkspaceTrustInfo = vi.fn(async () => ({ + trusted: false, + gatedMcpServers: [] as string[], + })); + const trustWorkspace = vi.fn(async () => {}); + const harness = makeHarness(makeSession(), { getWorkspaceTrustInfo, trustWorkspace }); + const driver = makeDriver(harness, { + ...makeStartupInput(), + migrationPlan: MIGRATION_PLAN, + migrateOnly: true, + engineV2: true, + }) as unknown as MigrateExitDriver & { + mountEditorReplacement(panel: { handleInput(data: string): void }): void; + }; + vi.spyOn(driver.state.ui, 'start').mockImplementation(() => {}); + vi.spyOn(driver.state.ui, 'stop').mockImplementation(() => {}); + vi.spyOn(driver.state.terminal, 'write').mockImplementation(() => {}); + const migrationSpy = vi + .spyOn(driver, 'runMigrationScreen') + .mockResolvedValue({ decision: 'later' }); + const mountSpy = vi.spyOn(driver, 'mountEditorReplacement'); + const onExit = vi.fn(async () => {}); + driver.onExit = onExit; + + const startPromise = driver.start(); + await vi.waitFor(() => { + expect(mountSpy).toHaveBeenCalled(); + }); + // Choose the default "Trust this folder" option with Enter. + mountSpy.mock.calls[0]![0].handleInput('\r'); + await startPromise; + + expect(trustWorkspace).toHaveBeenCalledWith('/tmp/proj-a'); + expect(getWorkspaceTrustInfo.mock.invocationCallOrder[0]!).toBeLessThan( + migrationSpy.mock.invocationCallOrder[0]!, + ); + expect(onExit).toHaveBeenCalledWith(0); + }); + it('keeps non-login startup session errors fatal', async () => { const harness = makeHarness(makeSession(), { createSession: vi.fn(async () => { diff --git a/apps/kimi-code/test/utils/process/resolve-command.test.ts b/apps/kimi-code/test/utils/process/resolve-command.test.ts new file mode 100644 index 0000000000..8c836b45ff --- /dev/null +++ b/apps/kimi-code/test/utils/process/resolve-command.test.ts @@ -0,0 +1,147 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { resolveCommandPath } from '#/utils/process/resolve-command'; + +const originalEnv = { ...process.env }; +const originalPlatform = process.platform; +let tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }); + } + tempDirs = []; + process.env = { ...originalEnv }; + Object.defineProperty(process, 'platform', { value: originalPlatform }); +}); + +function makeTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function mockPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform }); +} + +describe('resolveCommandPath (posix)', () => { + // Executable-bit checks only work on a posix host. + it.skipIf(process.platform === 'win32')('resolves an executable from PATH to an absolute path', () => { + const bin = makeTempDir('kimi-resolve-bin-'); + const cwd = makeTempDir('kimi-resolve-cwd-'); + const tool = join(bin, 'mytool'); + writeFileSync(tool, '#!/bin/sh\nexit 0\n'); + chmodSync(tool, 0o755); + process.env['PATH'] = bin; + + expect(resolveCommandPath('mytool', cwd)).toBe(tool); + }); + + it.skipIf(process.platform === 'win32')('ignores PATH files without the executable bit', () => { + const bin = makeTempDir('kimi-resolve-bin-'); + const cwd = makeTempDir('kimi-resolve-cwd-'); + writeFileSync(join(bin, 'mytool'), '#!/bin/sh\nexit 0\n'); + chmodSync(join(bin, 'mytool'), 0o644); + process.env['PATH'] = bin; + + expect(resolveCommandPath('mytool', cwd)).toBeUndefined(); + }); + + it.skipIf(process.platform === 'win32')('refuses a hit inside the current working directory', () => { + const cwd = makeTempDir('kimi-resolve-cwd-'); + const tool = join(cwd, 'mytool'); + writeFileSync(tool, '#!/bin/sh\nexit 0\n'); + chmodSync(tool, 0o755); + // The cwd itself sits on PATH (e.g. a `.` entry) — the planted binary + // must be rejected, not executed. + process.env['PATH'] = cwd; + + expect(resolveCommandPath('mytool', cwd)).toBeUndefined(); + }); + + it.skipIf(process.platform === 'win32')('refuses a hit from a relative PATH entry landing in the cwd', () => { + const cwd = makeTempDir('kimi-resolve-cwd-'); + const tool = join(cwd, 'mytool'); + writeFileSync(tool, '#!/bin/sh\nexit 0\n'); + chmodSync(tool, 0o755); + process.env['PATH'] = '.'; + + expect(resolveCommandPath('mytool', cwd)).toBeUndefined(); + }); + + it.skipIf(process.platform === 'win32')('refuses a hit in a subdirectory of the cwd', () => { + const cwd = makeTempDir('kimi-resolve-cwd-'); + const nested = join(cwd, 'bin'); + mkdirSync(nested); + const tool = join(nested, 'mytool'); + writeFileSync(tool, '#!/bin/sh\nexit 0\n'); + chmodSync(tool, 0o755); + process.env['PATH'] = nested; + + expect(resolveCommandPath('mytool', cwd)).toBeUndefined(); + }); + + it('returns undefined when the command is not on PATH', () => { + const bin = makeTempDir('kimi-resolve-bin-'); + const cwd = makeTempDir('kimi-resolve-cwd-'); + process.env['PATH'] = bin; + + expect(resolveCommandPath('definitely-not-a-real-command', cwd)).toBeUndefined(); + }); +}); + +describe('resolveCommandPath (win32)', () => { + it('resolves a bare name through PATHEXT', () => { + mockPlatform('win32'); + const bin = makeTempDir('kimi-resolve-bin-'); + const cwd = makeTempDir('kimi-resolve-cwd-'); + // Windows is case-insensitive, so the resolved name carries the PATHEXT + // casing; match it here so the test also passes on case-insensitive + // posix filesystems. + const shim = join(bin, 'npm.CMD'); + writeFileSync(shim, '@echo off\r\n'); + process.env['PATH'] = bin; + process.env['PATHEXT'] = '.COM;.EXE;.BAT;.CMD'; + + expect(resolveCommandPath('npm', cwd)).toBe(shim); + }); + + it('tries an explicitly suffixed name as-is', () => { + mockPlatform('win32'); + const bin = makeTempDir('kimi-resolve-bin-'); + const cwd = makeTempDir('kimi-resolve-cwd-'); + const shim = join(bin, 'npm.cmd'); + writeFileSync(shim, '@echo off\r\n'); + process.env['PATH'] = bin; + process.env['PATHEXT'] = '.COM;.EXE;.BAT;.CMD'; + + expect(resolveCommandPath('npm.cmd', cwd)).toBe(shim); + }); + + it('falls back to the default PATHEXT when the variable is unset', () => { + mockPlatform('win32'); + const bin = makeTempDir('kimi-resolve-bin-'); + const cwd = makeTempDir('kimi-resolve-cwd-'); + const shim = join(bin, 'bun.EXE'); + writeFileSync(shim, 'MZ'); + process.env['PATH'] = bin; + delete process.env['PATHEXT']; + + expect(resolveCommandPath('bun', cwd)).toBe(shim); + }); + + it('refuses a hit inside the current working directory', () => { + mockPlatform('win32'); + const cwd = makeTempDir('kimi-resolve-cwd-'); + writeFileSync(join(cwd, 'npm.cmd'), '@echo off\r\n'); + process.env['PATH'] = cwd; + process.env['PATHEXT'] = '.COM;.EXE;.BAT;.CMD'; + + expect(resolveCommandPath('npm', cwd)).toBeUndefined(); + }); +});