fix: hand user-facing children the inherited NoDefaultCurrentDirectoryInExePath state

libuv reads the opt-out from the spawning process, so Cline's own protection
does not depend on children carrying it. But cmd.exe, Go, Bun 1.4 and
libuv-based children honor it as well, so letting them inherit Cline's
setting would silently change how a user's own bare program names resolve
(npm scripts running a cwd-local .bat through cmd.exe, MCP servers named
relative to cwd, hooks that spawn helpers).

disableCurrentDirectoryExecutableSearch() now latches the value the process
inherited, and withInheritedExecutableSearch() restores that state on the
child env at the spawn sites that run user-authored programs: the shell
executor, MCP stdio servers, hook subprocesses, the plugin subprocess
sandbox, and the VS Code host's HookProcess.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
This commit is contained in:
Saoud Rizwan
2026-09-16 07:50:23 +00:00
co-authored by Saoud Rizwan
parent 3986ad8337
commit b643517257
10 changed files with 110 additions and 26 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
### Fixed
- On Windows, opening a repository that contains a file named `rg.exe`, `git.exe`, or `powershell.exe` no longer runs that file in place of the real program. Bare program names were resolved through the workspace directory before PATH, so a planted executable ran with your privileges as soon as the workspace was indexed. Cline now sets Windows' `NoDefaultCurrentDirectoryInExePath` opt-out at startup, in both the VS Code extension and the JetBrains core. Processes Cline launches inherit it, so inside a Command Prompt shell a program in the current directory now needs `.\` as it already does in PowerShell.
- On Windows, opening a repository that contains a file named `rg.exe`, `git.exe`, or `powershell.exe` no longer runs that file in place of the real program. Bare program names were resolved through the workspace directory before PATH, so a planted executable ran with your privileges as soon as the workspace was indexed. Cline now sets Windows' `NoDefaultCurrentDirectoryInExePath` opt-out at startup, in both the VS Code extension and the JetBrains core. Commands, hooks, and MCP servers Cline launches receive the variable exactly as your own environment had it, so nothing changes for them.
- A model turn that fails mid-stream with a transient provider error is now retried up to three times with backoff instead of ending the task. A single rate-limit response forwarded by a gateway previously surfaced as a failed task. A turn that has already streamed output is never retried, so nothing is duplicated.
- Terminal commands that succeed without printing anything (`git add -A` on a clean tree, for example) are now reported as empty output. They were treated as a shell-integration failure, which fed the model a snapshot of unrelated terminal scrollback prefixed with a warning that the output could not be captured, so silent commands intermittently looked like failures.
- Checkpoints no longer re-hash every untracked file on each message. In workspaces holding large untracked directories this delayed every message by seconds to minutes; a persistent per-task index now lets git skip files it has already seen.
@@ -1,3 +1,4 @@
import { withInheritedExecutableSearch } from "@cline/shared"
import { ChildProcess, spawn } from "child_process"
import { EventEmitter } from "events"
import { existsSync } from "fs"
@@ -191,6 +192,7 @@ export class HookProcess extends EventEmitter {
shell: launchConfig.shell,
detached: launchConfig.detached,
cwd: this.cwd, // Execute from the determined workspace root (validated above)
env: withInheritedExecutableSearch(process.env),
windowsHide: true,
})
+1 -1
View File
@@ -2,7 +2,7 @@
## 0.0.83
- On Windows, a program planted in the workspace can no longer run in place of the real one. libuv resolves a bare program name (`rg`, `git`, `powershell`, anything the model names) by searching the child's working directory before PATH, and Cline spawns those with the user's repo as cwd, so opening a repo containing an `rg.exe` executed it during file indexing, before any approval. New `disableCurrentDirectoryExecutableSearch()` in `@cline/shared` sets Windows' documented `NoDefaultCurrentDirectoryInExePath` opt-out, which libuv, Bun 1.4+, cmd.exe and the C runtime all honor, so one call at process startup covers every spawn and every child. Embedders hosting `@cline/core` in their own Windows process should call it before anything spawns; the Cline CLI, desktop sidecar, VS Code extension and JetBrains core do
- On Windows, a program planted in the workspace can no longer run in place of the real one. libuv resolves a bare program name (`rg`, `git`, `powershell`, anything the model names) by searching the child's working directory before PATH, and Cline spawns those with the user's repo as cwd, so opening a repo containing an `rg.exe` executed it during file indexing, before any approval. New `disableCurrentDirectoryExecutableSearch()` in `@cline/shared` sets Windows' documented `NoDefaultCurrentDirectoryInExePath` opt-out, which libuv reads from the spawning process, so one call at process startup covers every spawn site. Embedders hosting `@cline/core` in their own Windows process should call it before anything spawns; the Cline CLI, desktop sidecar, VS Code extension and JetBrains core do. Because cmd.exe, Go, Bun 1.4+ and libuv-based children honor the variable too, user-facing children — shell commands, hooks, MCP servers, plugin sandboxes — are spawned through `withInheritedExecutableSearch()`, which hands them the variable exactly as the process inherited it, so their own bare program names resolve as before
- Hub-managed Agent Plugins. Packages under `~/.agents/plugins/*` on the hub host are discovered and validated from their root `plugin.json`; valid skills under `skills/` are exposed through the skills tool as `plugin-name:skill-name`, and stdio, Streamable HTTP, and legacy SSE servers from `mcp.json` are started without touching `cline_mcp_settings.json`. Workspace `.agents/plugins` directories are deliberately not scanned, so opening a repo cannot implicitly start repo-controlled MCP servers; extra roots require an explicit `agentPluginPaths`. Enablement lives in hub settings keyed by manifest name and publishes `settings.changed`, so clients no longer need their own loader or enablement store. Two bugs fixed along the way: `settings.toggle({type: "skills"})` wrote a `disabled` key into a plugin skill's SKILL.md frontmatter, which the strict Agent Skills parser then rejected so the skill silently vanished until hand-edited; and `InMemoryMcpManager.dispose()` aborted on the first `disconnect()` rejection, leaking every remaining server's process
- A model turn that dies mid-stream with a transient provider error is now retried up to 3 times with exponential backoff instead of failing the whole run — a single forwarded 429 previously aborted the run outright. Retryability is read from the AI SDK's typed signals, and a turn is never retried once it has streamed any text, reasoning, media, or tool call, so nothing is duplicated. Model calls also now allow 5 SDK-level retries for request-start 429/5xx/network failures, up from 2
- Streaming is no longer throttled by hook forwarding. The hub proxied every runtime event to client-contributed `onEvent` hooks as a capability round trip carrying the full session snapshot, with the agent loop awaiting it — so each streamed token cost a few hundred KB of serialization, a persisted row, and a blocking IPC hop. Per-chunk text, reasoning, and tool-update deltas are no longer forwarded to remote `onEvent` hooks; every other event still reaches hooks unchanged. The hub event log also moved to `synchronous = NORMAL`, dropping one fsync per appended delta
@@ -5,6 +5,7 @@ import {
type AgentToolContext,
formatMcpTimeoutErrorMessage,
isMcpTimeoutConfigured,
withInheritedExecutableSearch,
} from "@cline/shared";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js";
@@ -415,10 +416,10 @@ class StdioMcpClient implements McpServerClient {
: {};
const child = spawn(transport.command, transport.args ?? [], {
cwd: transport.cwd,
env: {
env: withInheritedExecutableSearch({
...process.env,
...(transport.env ?? {}),
},
}),
stdio: ["pipe", "pipe", "pipe"],
...platformOptions,
});
@@ -22,6 +22,7 @@ import {
type AgentToolContext,
getDefaultShell,
getShellInvocation,
withInheritedExecutableSearch,
} from "@cline/shared";
import {
type ProcessStartTokenProbeResult,
@@ -678,7 +679,7 @@ function spawnAndCollect(
const child = spawn(config.executable, config.args, {
cwd: config.cwd,
env: { ...process.env, ...config.env },
env: withInheritedExecutableSearch({ ...process.env, ...config.env }),
stdio: ["pipe", "pipe", "pipe"],
detached: !isWindows,
// Prevent a console window from flashing on Windows when the
@@ -1,6 +1,7 @@
import { spawn } from "node:child_process";
import {
augmentNodeCommandForDebug,
withInheritedExecutableSearch,
withResolvedClineBuildEnv,
} from "@cline/shared";
@@ -138,7 +139,7 @@ export async function runSubprocessEvent(
const detached = !!options.detached;
const child = spawn(command[0], command.slice(1), {
cwd: options.cwd,
env: withResolvedClineBuildEnv(options.env),
env: withInheritedExecutableSearch(withResolvedClineBuildEnv(options.env)),
stdio: detached ? ["pipe", "ignore", "ignore"] : ["pipe", "pipe", "pipe"],
detached,
// Prevent a console window from flashing on Windows (especially when
@@ -2,6 +2,7 @@ import { type ChildProcess, spawn } from "node:child_process";
import { basename } from "node:path";
import {
augmentNodeCommandForDebug,
withInheritedExecutableSearch,
withResolvedClineBuildEnv,
} from "@cline/shared";
import {
@@ -267,7 +268,9 @@ export class SubprocessSandbox {
command.slice(1),
{
stdio: ["ignore", "ignore", "pipe", "ipc"],
env: withResolvedClineBuildEnv(process.env),
env: withInheritedExecutableSearch(
withResolvedClineBuildEnv(process.env),
),
// Prevent a console window from flashing on Windows.
windowsHide: true,
},
+1
View File
@@ -518,6 +518,7 @@ export {
export {
disableCurrentDirectoryExecutableSearch,
NO_DEFAULT_CURRENT_DIRECTORY_IN_EXE_PATH_ENV,
withInheritedExecutableSearch,
} from "./runtime/windows-exe-path";
export type {
CaptureAgentUnexpectedReasoningTokensInput,
@@ -2,20 +2,27 @@ import { spawn } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
disableCurrentDirectoryExecutableSearch,
NO_DEFAULT_CURRENT_DIRECTORY_IN_EXE_PATH_ENV,
} from "./windows-exe-path";
import { describe, expect, it, vi } from "vitest";
const NAME = "NoDefaultCurrentDirectoryInExePath";
// The module latches the inherited value on first use; a fresh copy per test
// keeps the cases independent.
async function loadFresh() {
vi.resetModules();
return import("./windows-exe-path");
}
describe("disableCurrentDirectoryExecutableSearch", () => {
it("defines the Windows opt-out variable", () => {
it("defines the Windows opt-out variable", async () => {
const { disableCurrentDirectoryExecutableSearch } = await loadFresh();
const env: Record<string, string | undefined> = {};
disableCurrentDirectoryExecutableSearch({ env, platform: "win32" });
expect(env[NO_DEFAULT_CURRENT_DIRECTORY_IN_EXE_PATH_ENV]).toBe("1");
expect(env[NAME]).toBe("1");
});
it("leaves other platforms alone", () => {
it("leaves other platforms alone", async () => {
const { disableCurrentDirectoryExecutableSearch } = await loadFresh();
for (const platform of ["darwin", "linux"] as const) {
const env: Record<string, string | undefined> = {};
disableCurrentDirectoryExecutableSearch({ env, platform });
@@ -29,21 +36,21 @@ describe("disableCurrentDirectoryExecutableSearch", () => {
it.runIf(process.platform === "win32")(
"keeps a cmd.exe planted in the working directory from shadowing the real one",
async () => {
const { disableCurrentDirectoryExecutableSearch } = await loadFresh();
const cwd = await mkdtemp(join(tmpdir(), "cline-planted-exe-"));
const previous =
process.env[NO_DEFAULT_CURRENT_DIRECTORY_IN_EXE_PATH_ENV];
const previous = process.env[NAME];
try {
await writeFile(join(cwd, "cmd.exe"), "");
delete process.env[NO_DEFAULT_CURRENT_DIRECTORY_IN_EXE_PATH_ENV];
delete process.env[NAME];
expect(await runsRealCmd(cwd)).toBe(false);
disableCurrentDirectoryExecutableSearch();
expect(await runsRealCmd(cwd)).toBe(true);
} finally {
if (previous === undefined) {
delete process.env[NO_DEFAULT_CURRENT_DIRECTORY_IN_EXE_PATH_ENV];
delete process.env[NAME];
} else {
process.env[NO_DEFAULT_CURRENT_DIRECTORY_IN_EXE_PATH_ENV] = previous;
process.env[NAME] = previous;
}
await rm(cwd, { recursive: true, force: true });
}
@@ -51,6 +58,42 @@ describe("disableCurrentDirectoryExecutableSearch", () => {
);
});
describe("withInheritedExecutableSearch", () => {
it("returns the environment untouched when the process was never hardened", async () => {
const { withInheritedExecutableSearch } = await loadFresh();
const env = { [NAME]: "user", PATH: "C:\\bin" };
expect(withInheritedExecutableSearch(env)).toBe(env);
});
it("drops the variable from children when this process did not inherit it", async () => {
const {
disableCurrentDirectoryExecutableSearch,
withInheritedExecutableSearch,
} = await loadFresh();
const env: Record<string, string | undefined> = { PATH: "C:\\bin" };
disableCurrentDirectoryExecutableSearch({ env, platform: "win32" });
const child = withInheritedExecutableSearch({ ...env, EXTRA: "x" });
expect(child).toEqual({ PATH: "C:\\bin", EXTRA: "x" });
// The hardened parent environment is left alone.
expect(env[NAME]).toBe("1");
});
it("keeps the user's own value when the variable was already set", async () => {
const {
disableCurrentDirectoryExecutableSearch,
withInheritedExecutableSearch,
} = await loadFresh();
const env: Record<string, string | undefined> = { [NAME]: "user" };
disableCurrentDirectoryExecutableSearch({ env, platform: "win32" });
// A second call, as when the daemon entry runs after the CLI entry,
// must not mistake our own "1" for the inherited value.
disableCurrentDirectoryExecutableSearch({ env, platform: "win32" });
expect(withInheritedExecutableSearch(env)).toEqual({ [NAME]: "user" });
});
});
function runsRealCmd(cwd: string): Promise<boolean> {
return new Promise((resolve) => {
let child: ReturnType<typeof spawn>;
@@ -5,18 +5,25 @@
export const NO_DEFAULT_CURRENT_DIRECTORY_IN_EXE_PATH_ENV =
"NoDefaultCurrentDirectoryInExePath";
/**
* The variable's value in the environment this process inherited, latched by
* {@link disableCurrentDirectoryExecutableSearch} so children can be handed
* the state they would have seen had Cline not hardened itself.
*/
let inherited: { value: string | undefined } | undefined;
/**
* Stop Windows from resolving bare program names through the child's working
* directory.
*
* When `child_process.spawn("rg", ...)` runs on Windows, libuv resolves the
* bare name by looking in the child's cwd before walking PATH (it gates that
* bare name by looking in the child's cwd before walking PATH. It gates that
* step on `NeedCurrentDirectoryForExePathW`, which reads this variable from
* the spawning process). Cline spawns `rg`, `git`, `powershell` and
* model-named programs with the user's workspace as cwd, so a repo that ships
* an `rg.exe` would get it executed, with the user's privileges, the moment
* the workspace opened. Bun's spawn (1.4+) honors the same variable, as do
* cmd.exe and the C runtime, so children inherit the protection too.
* the spawning process's own environment, not the child's. Cline spawns `rg`,
* `git`, `powershell` and model-named programs with the user's workspace as
* cwd, so a repo that ships an `rg.exe` would get it executed, with the user's
* privileges, the moment the workspace opened. Bun's spawn (1.4+) reads the
* same variable.
*
* Call once at process startup, from the main thread (a worker thread's
* `process.env` is a copy that native code never sees), before anything can
@@ -30,5 +37,30 @@ export function disableCurrentDirectoryExecutableSearch(
): void {
const { env = process.env, platform = process.platform } = options;
if (platform !== "win32") return;
inherited ??= { value: env[NO_DEFAULT_CURRENT_DIRECTORY_IN_EXE_PATH_ENV] };
env[NO_DEFAULT_CURRENT_DIRECTORY_IN_EXE_PATH_ENV] = "1";
}
/**
* Build a child environment that carries the variable exactly as this process
* inherited it: absent if it was absent, the user's own value if they set it.
*
* The protection only needs the variable in the spawning process, but it is
* also honored by cmd.exe, libuv-based children, Go and Bun 1.4+, so letting
* user-facing children (shell commands, hooks, MCP servers, plugin sandboxes)
* inherit Cline's setting would silently change how their own bare program
* names resolve. Returns `env` untouched when
* {@link disableCurrentDirectoryExecutableSearch} has not run in this process.
*/
export function withInheritedExecutableSearch(
env: Record<string, string | undefined>,
): Record<string, string | undefined> {
if (!inherited) return env;
const child = { ...env };
if (inherited.value === undefined) {
delete child[NO_DEFAULT_CURRENT_DIRECTORY_IN_EXE_PATH_ENV];
} else {
child[NO_DEFAULT_CURRENT_DIRECTORY_IN_EXE_PATH_ENV] = inherited.value;
}
return child;
}