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
5 changes: 5 additions & 0 deletions .changeset/windows-bare-command-binary-planting.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions apps/kimi-code/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 16 additions & 11 deletions apps/kimi-code/src/cli/run-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
32 changes: 30 additions & 2 deletions apps/kimi-code/src/cli/update/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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`;
Expand Down Expand Up @@ -493,12 +509,16 @@ export async function installUpdate(
platform: NodeJS.Platform,
): Promise<void> {
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<void>((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,
});
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 13 additions & 1 deletion apps/kimi-code/src/cli/update/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string> {
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<string> {
return new Promise((resolveOutput, reject) => {
execFile(command, [...args], { encoding: 'utf-8' }, (error, stdout) => {
Expand Down Expand Up @@ -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,
};
Expand Down
28 changes: 22 additions & 6 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,10 @@ export class KimiTUI {
private pluginCommands: readonly KimiSlashCommand[] = [];
readonly pluginCommandMap = new Map<string, string>();
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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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');
Expand Down Expand Up @@ -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;
Expand Down
79 changes: 79 additions & 0 deletions apps/kimi-code/src/utils/process/resolve-command.ts
Original file line number Diff line number Diff line change
@@ -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;
}
20 changes: 19 additions & 1 deletion apps/kimi-code/test/cli/run-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading