fix(infra): block Windows system path env vars from workspace .env injection (#74456)

* fix: address issue

* fix: address PR review feedback

* fix: address codex review feedback

* fix: address codex review feedback

* fix: address codex review feedback

* docs: add changelog entry for PR merge

* Update CHANGELOG.md
This commit is contained in:
Pavan Kumar Gondhi
2026-05-01 18:32:25 +05:30
committed by GitHub
parent 3b75898bee
commit cba0a348dc
8 changed files with 112 additions and 6 deletions

View File

@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
- fix(infra): block ambient Homebrew env vars from brew resolution. (#74463) Thanks @pgondhi987.
- Thinking/providers: resolve bundled provider thinking profiles through lightweight provider policy artifacts when startup-lazy providers are not active, so OpenAI Codex GPT-5.x keeps xhigh available in Gateway session validation. Fixes #74796. Thanks @maxschachere.
- Security/Windows: ignore workspace `.env` system-path variables and resolve stale-process `taskkill.exe` from the validated Windows install root, preventing repository-local env files from redirecting cleanup helpers. Thanks @pgondhi987.
- Plugins/TTS: keep bundled speech-provider discovery available on cold package Gateway paths and add bundled plugin matrix runtime probes for health, readiness, RPC, TTS discovery, and post-ready runtime-deps watchdog coverage. Refs #75283. Thanks @vincentkoc.
- Google Meet/Twilio: show delegated voice call ID, DTMF, and intro-greeting state in `googlemeet doctor`, and avoid claiming DTMF was sent when no Meet PIN sequence was configured. Refs #72478. Thanks @DougButdorf.
- Voice Call/Twilio: send notify-mode initial TwiML directly in the outbound create-call request while keeping conversation and pre-connect DTMF calls webhook-driven, so one-shot notify calls do not depend on a first-answer webhook fetch. Supersedes #72758. Thanks @tyshepps.

View File

@@ -1,9 +1,13 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
buildEmbeddedPiSettingsSnapshot,
DEFAULT_EMBEDDED_PI_PROJECT_SETTINGS_POLICY,
resolveEmbeddedPiProjectSettingsPolicy,
} from "./pi-project-settings-snapshot.js";
import { createPreparedEmbeddedPiSettingsManager } from "./pi-project-settings.js";
type EmbeddedPiSettingsArgs = Parameters<typeof buildEmbeddedPiSettingsSnapshot>[0];
@@ -126,3 +130,48 @@ describe("buildEmbeddedPiSettingsSnapshot", () => {
});
});
});
describe("createPreparedEmbeddedPiSettingsManager", () => {
it("keeps trusted file-backed settings runtime-scoped after preparation", async () => {
const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pi-settings-"));
try {
const cwd = path.join(baseDir, "workspace");
const agentDir = path.join(baseDir, "agent");
const projectSettingsDir = path.join(cwd, ".pi");
const agentSettingsPath = path.join(agentDir, "settings.json");
await fs.mkdir(projectSettingsDir, { recursive: true });
await fs.mkdir(agentDir, { recursive: true });
await fs.writeFile(
agentSettingsPath,
JSON.stringify({ retry: { enabled: true } }, null, 2),
"utf8",
);
await fs.writeFile(
path.join(projectSettingsDir, "settings.json"),
JSON.stringify({ shellCommandPrefix: "echo trusted &&" }, null, 2),
"utf8",
);
const settingsManager = createPreparedEmbeddedPiSettingsManager({
cwd,
agentDir,
cfg: {
agents: { defaults: { embeddedPi: { projectSettingsPolicy: "trusted" } } },
},
});
expect(settingsManager.getShellCommandPrefix()).toBe("echo trusted &&");
expect(settingsManager.getRetryEnabled()).toBe(true);
settingsManager.setRetryEnabled(false);
await settingsManager.flush();
const diskSettings = JSON.parse(await fs.readFile(agentSettingsPath, "utf8")) as {
retry?: { enabled?: boolean };
};
expect(diskSettings.retry?.enabled).toBe(true);
} finally {
await fs.rm(baseDir, { recursive: true, force: true });
}
});
});

View File

@@ -37,6 +37,17 @@ export function createEmbeddedPiSettingsManager(params: {
return SettingsManager.inMemory(settings);
}
function createRuntimeEmbeddedPiSettingsManager(settingsManager: SettingsManager): SettingsManager {
return SettingsManager.inMemory(
buildEmbeddedPiSettingsSnapshot({
globalSettings: settingsManager.getGlobalSettings(),
pluginSettings: {},
projectSettings: settingsManager.getProjectSettings(),
policy: "trusted",
}),
);
}
export function createPreparedEmbeddedPiSettingsManager(params: {
cwd: string;
agentDir: string;
@@ -44,7 +55,9 @@ export function createPreparedEmbeddedPiSettingsManager(params: {
/** Resolved context window budget so reserve-token floor can be capped for small models. */
contextTokenBudget?: number;
}): SettingsManager {
const settingsManager = createEmbeddedPiSettingsManager(params);
const settingsManager = createRuntimeEmbeddedPiSettingsManager(
createEmbeddedPiSettingsManager(params),
);
applyPiCompactionSettingsFromConfig({
settingsManager,
cfg: params.cfg,

View File

@@ -139,6 +139,18 @@ describe("brew helpers", () => {
});
});
it("does not resolve brew from PATH entries", async () => {
await withTempDir({ prefix: "openclaw-brew-" }, async (tmp) => {
const pathBin = path.join(tmp, "path-bin");
const pathBrew = path.join(pathBin, "brew");
await writeExecutable(pathBrew);
const env: NodeJS.ProcessEnv = { PATH: pathBin };
expect(resolveBrewExecutable({ homeDir: path.join(tmp, "home"), env })).not.toBe(pathBrew);
});
});
it("always includes the standard macOS brew dirs after linuxbrew candidates", () => {
const dirs = resolveBrewPathDirs({ homeDir: "/home/test" });

View File

@@ -680,6 +680,14 @@ describe("workspace .env blocklist completeness", () => {
"OPENCLAW_NODE_EXEC_HOST",
"OPENCLAW_NODE_EXEC_FALLBACK",
"OPENCLAW_ALLOW_PROJECT_LOCAL_BIN",
"PATH",
"HOMEBREW_BREW_FILE",
"HOMEBREW_PREFIX",
"SystemRoot",
"WINDIR",
"ProgramFiles",
"ProgramFiles(x86)",
"ProgramW6432",
"SYNOLOGY_CHAT_INCOMING_URL",
"SYNOLOGY_NAS_HOST",
];

View File

@@ -74,10 +74,16 @@ const BLOCKED_WORKSPACE_DOTENV_KEYS = new Set([
"OPENCLAW_STATE_DIR",
"OPENCLAW_TEST_TAILSCALE_BINARY",
"PI_CODING_AGENT_DIR",
"PATH",
"PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH",
"PROGRAMFILES",
"PROGRAMFILES(X86)",
"PROGRAMW6432",
"SYNOLOGY_CHAT_INCOMING_URL",
"SYNOLOGY_NAS_HOST",
"SYSTEMROOT",
"UV_PYTHON",
"WINDIR",
]);
// Block endpoint redirection for any service without overfitting per-provider names.

View File

@@ -90,6 +90,15 @@ vi.mock("./windows-port-pids.js", () => ({
mockReadWindowsProcessArgsResult(pid, timeoutMs),
}));
vi.mock("./windows-install-roots.js", () => ({
getWindowsInstallRoots: () => ({
systemRoot: "C:\\Windows",
programFiles: "C:\\Program Files",
programFilesX86: "C:\\Program Files (x86)",
programW6432: null,
}),
}));
import { resolveLsofCommandSync } from "./ports-lsof.js";
let __testing: typeof import("./restart-stale-pids.js").__testing;
let cleanStaleGatewayProcessesSync: typeof import("./restart-stale-pids.js").cleanStaleGatewayProcessesSync;
@@ -980,8 +989,10 @@ describe.skipIf(isWindows)("restart-stale-pids", () => {
it("does not report Windows pids as killed when taskkill fails", () => {
const origDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
const originalSystemRoot = process.env.SystemRoot;
const stalePid = process.pid + 911;
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
process.env.SystemRoot = "C:\\PoisonedWindows";
try {
let fakeNow = 0;
__testing.setDateNowOverride(() => fakeNow);
@@ -1012,12 +1023,17 @@ describe.skipIf(isWindows)("restart-stale-pids", () => {
expect(cleanStaleGatewayProcessesSync()).toEqual([]);
expect(mockSpawnSync).toHaveBeenCalledWith(
expect.stringContaining("taskkill.exe"),
"C:\\Windows\\System32\\taskkill.exe",
["/T", "/PID", String(stalePid)],
expect.objectContaining({ timeout: 5000 }),
);
} finally {
__testing.setDateNowOverride(null);
if (originalSystemRoot === undefined) {
delete process.env.SystemRoot;
} else {
process.env.SystemRoot = originalSystemRoot;
}
if (origDescriptor) {
Object.defineProperty(process, "platform", origDescriptor);
}
@@ -1063,13 +1079,13 @@ describe.skipIf(isWindows)("restart-stale-pids", () => {
expect(cleanStaleGatewayProcessesSync()).toEqual([]);
expect(mockSpawnSync).toHaveBeenNthCalledWith(
1,
expect.stringContaining("taskkill.exe"),
"C:\\Windows\\System32\\taskkill.exe",
["/T", "/PID", String(stalePid)],
expect.objectContaining({ timeout: 5000 }),
);
expect(mockSpawnSync).toHaveBeenNthCalledWith(
2,
expect.stringContaining("taskkill.exe"),
"C:\\Windows\\System32\\taskkill.exe",
["/F", "/T", "/PID", String(stalePid)],
expect.objectContaining({ timeout: 5000 }),
);

View File

@@ -6,6 +6,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { isGatewayArgv } from "./gateway-process-argv.js";
import { resolveLsofCommandSync } from "./ports-lsof.js";
import { getWindowsInstallRoots } from "./windows-install-roots.js";
import {
readWindowsListeningPidsOnPortSync,
readWindowsListeningPidsResultSync,
@@ -424,8 +425,8 @@ function terminateStaleProcessesSync(pids: number[]): number[] {
* Sends a graceful taskkill first (/T for tree), waits, then escalates to /F.
*/
function terminateStaleProcessesWindows(pids: number[]): number[] {
const taskkillPath = path.join(
process.env.SystemRoot ?? "C:\\Windows",
const taskkillPath = path.win32.join(
getWindowsInstallRoots().systemRoot,
"System32",
"taskkill.exe",
);