Skip to content

Commit b77f36f

Browse files
mednsodysseus0
andauthored
fix(exec): protect pathPrepend against posix login-shell RC overrides (#81403)
Merged via squash. Prepared head SHA: 874fa90 Co-authored-by: medns <1575008+medns@users.noreply.github.com> Co-authored-by: odysseus0 <8635094+odysseus0@users.noreply.github.com> Reviewed-by: @odysseus0
1 parent 9b7e431 commit b77f36f

9 files changed

Lines changed: 241 additions & 8 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ Docs: https://docs.openclaw.ai
5353
- Auth/OAuth: skip the refresh adapter when a stored OAuth credential has no refresh token so agent turns fail fast on missing-key instead of waiting on the 120s refresh timeout. Thanks @romneyda.
5454
- Auth/Codex: load legacy OAuth sidecar credentials in the embedded runner's secrets-runtime auth loaders so Telegram replies, cron-triggered turns, and other isolated sub-agent lanes can reach the existing #83312 refresh-and-rewrite migration instead of failing with `No API key found for provider "openai-codex"` until the user runs `openclaw doctor`. Thanks @Totalsolutionsync and @romneyda.
5555
- Codex/failover: classify `deactivated_workspace` as a permanent auth failure so configured fallback models can advance when a Codex workspace is deactivated. (#55893) Thanks @litang9.
56+
- Exec: keep configured `tools.exec.pathPrepend` entries ahead of user shell startup PATH changes on POSIX gateway runs. (#81403) Thanks @medns.
5657

5758
## 2026.5.20
5859

docs/tools/exec.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ Example:
133133
rejected for host execution. The daemon itself still runs with a minimal `PATH`:
134134
- macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin`
135135
- Linux: `/usr/local/bin`, `/usr/bin`, `/bin`
136+
- To prevent user shell configuration (like `~/.zshenv` or `/etc/zshenv`) from overriding priority paths during startup, `tools.exec.pathPrepend` entries are securely prepended to the final `PATH` inside the shell command right before execution.
136137
- `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`.
137138
OpenClaw prepends `env.PATH` after profile sourcing via an internal env var (no shell interpolation);
138139
`tools.exec.pathPrepend` applies here too.

src/agents/bash-tools.exec-host-gateway.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export type ProcessGatewayAllowlistParams = {
5252
command: string;
5353
workdir: string;
5454
env: Record<string, string>;
55+
pathPrepend?: string[];
5556
requestedEnv?: Record<string, string>;
5657
pty: boolean;
5758
timeoutSec?: number;
@@ -663,6 +664,7 @@ export async function processGatewayAllowlist(
663664
execCommand: enforcedCommand,
664665
workdir: params.workdir,
665666
env: params.env,
667+
pathPrepend: params.pathPrepend,
666668
sandbox: undefined,
667669
containerWorkdir: null,
668670
usePty: params.pty,

src/agents/bash-tools.exec-runtime.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,3 +687,88 @@ describe("buildExecExitOutcome", () => {
687687
expect(outcome.reason).toContain("Do not rely on shell backgrounding");
688688
});
689689
});
690+
691+
describe("runExecProcess POSIX command wrapper", () => {
692+
it("wraps command with PATH export if OPENCLAW_PREPEND_PATH is present", async () => {
693+
if (process.platform === "win32") {
694+
return;
695+
}
696+
697+
supervisorMock.spawn.mockResolvedValueOnce({
698+
runId: "mock-run",
699+
startedAtMs: Date.now(),
700+
wait: async () => ({
701+
reason: "exit",
702+
exitCode: 0,
703+
exitSignal: null,
704+
durationMs: 0,
705+
stdout: "",
706+
stderr: "",
707+
timedOut: false,
708+
noOutputTimedOut: false,
709+
}),
710+
cancel: vi.fn(),
711+
});
712+
713+
const run = await runExecProcess({
714+
command: "echo test",
715+
workdir: "/tmp",
716+
env: { PATH: "/usr/bin" },
717+
pathPrepend: ["/custom/bin", "/opt/bin"],
718+
usePty: false,
719+
warnings: [],
720+
maxOutput: 1000,
721+
pendingMaxOutput: 1000,
722+
notifyOnExit: false,
723+
timeoutSec: null,
724+
});
725+
726+
expect(supervisorMock.spawn).toHaveBeenCalledTimes(1);
727+
const spawnCall = supervisorMock.spawn.mock.calls[0][0];
728+
729+
const commandStr = spawnCall.argv.join(" ");
730+
expect(commandStr).toContain('export PATH="${OPENCLAW_PREPEND_PATH}${PATH:+:$PATH}"; unset OPENCLAW_PREPEND_PATH; echo test');
731+
});
732+
733+
it("does not wrap command on Windows", async () => {
734+
if (process.platform !== "win32") {
735+
return;
736+
}
737+
738+
supervisorMock.spawn.mockResolvedValueOnce({
739+
runId: "mock-run",
740+
startedAtMs: Date.now(),
741+
wait: async () => ({
742+
reason: "exit",
743+
exitCode: 0,
744+
exitSignal: null,
745+
durationMs: 0,
746+
stdout: "",
747+
stderr: "",
748+
timedOut: false,
749+
noOutputTimedOut: false,
750+
}),
751+
cancel: vi.fn(),
752+
});
753+
754+
const run = await runExecProcess({
755+
command: "echo test",
756+
workdir: "C:\\tmp",
757+
env: { Path: "C:\\Windows\\System32" },
758+
pathPrepend: ["C:\\custom\\bin"],
759+
usePty: false,
760+
warnings: [],
761+
maxOutput: 1000,
762+
pendingMaxOutput: 1000,
763+
notifyOnExit: false,
764+
timeoutSec: null,
765+
});
766+
767+
expect(supervisorMock.spawn).toHaveBeenCalledTimes(1);
768+
const spawnCall = supervisorMock.spawn.mock.calls[0][0];
769+
770+
const commandStr = spawnCall.argv.join(" ");
771+
expect(commandStr).not.toContain("export PATH=");
772+
expect(commandStr).toContain("echo test");
773+
});
774+
});

src/agents/bash-tools.exec-runtime.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
} from "../infra/exec-approvals.js";
1111
import { requestHeartbeat } from "../infra/heartbeat-wake.js";
1212
import { isDangerousHostInheritedEnvVarName } from "../infra/host-env-security.js";
13-
import { findPathKey, mergePathPrepend } from "../infra/path-prepend.js";
13+
import { findPathKey, mergePathPrepend, removePathPrepend } from "../infra/path-prepend.js";
1414
import { enqueueSystemEvent } from "../infra/system-events.js";
1515
import { resolveEventSessionKey, scopedHeartbeatWakeOptions } from "../routing/session-key.js";
1616
import { isSubagentSessionKey } from "../sessions/session-key-utils.js";
@@ -578,13 +578,45 @@ export function buildExecRuntimeErrorOutcome(params: {
578578
};
579579
}
580580

581+
/**
582+
* Apply PATH prepends inside the shell command.
583+
* This ensures our paths take precedence even if user RC files (e.g. ~/.zshenv)
584+
* prepend their own entries to PATH during shell startup.
585+
*/
586+
function wrapPosixCommandWithPathPrepend(command: string, env: Record<string, string>, pathPrepend?: string[]): string {
587+
if (process.platform === "win32") {
588+
return command;
589+
}
590+
591+
if (!pathPrepend || pathPrepend.length === 0) {
592+
return command;
593+
}
594+
595+
// Strip prepended entries from the base env.PATH to avoid duplicate segments.
596+
// The wrapper will re-apply them after shell startup.
597+
const pathKey = findPathKey(env);
598+
const currentPath = env[pathKey];
599+
if (currentPath) {
600+
const newPath = removePathPrepend(currentPath, pathPrepend);
601+
if (newPath !== undefined) {
602+
env[pathKey] = newPath;
603+
}
604+
}
605+
606+
// Pass the prepend string safely via a temporary environment variable.
607+
env.OPENCLAW_PREPEND_PATH = pathPrepend.join(path.delimiter);
608+
609+
return `export PATH="\${OPENCLAW_PREPEND_PATH}\${PATH:+:$PATH}"; unset OPENCLAW_PREPEND_PATH; ${command}`;
610+
}
611+
581612
export async function runExecProcess(opts: {
582613
command: string;
583614
// Execute this instead of `command` (which is kept for display/session/logging).
584615
// Used to sanitize safeBins execution while preserving the original user input.
585616
execCommand?: string;
586617
workdir: string;
587618
env: Record<string, string>;
619+
pathPrepend?: string[];
588620
sandbox?: BashSandboxConfig;
589621
containerWorkdir?: string | null;
590622
usePty: boolean;
@@ -762,11 +794,15 @@ export async function runExecProcess(opts: {
762794
};
763795
}
764796
const { shell, args: shellArgs } = getShellConfig();
765-
const childArgv = [shell, ...shellArgs, execCommand];
797+
798+
// Wrap the command to enforce PATH prepend precedence over shell RC overrides.
799+
const commandWithPathPrepend = wrapPosixCommandWithPathPrepend(execCommand, shellRuntimeEnv, opts.pathPrepend);
800+
801+
const childArgv = [shell, ...shellArgs, commandWithPathPrepend];
766802
if (opts.usePty) {
767803
return {
768804
mode: "pty" as const,
769-
ptyCommand: execCommand,
805+
ptyCommand: commandWithPathPrepend,
770806
childFallbackArgv: childArgv,
771807
env: shellRuntimeEnv,
772808
stdinMode: "pipe-open" as const,

src/agents/bash-tools.exec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1560,6 +1560,7 @@ export function createExecTool(
15601560
command: params.command,
15611561
workdir,
15621562
env,
1563+
pathPrepend: defaultPathPrepend,
15631564
requestedEnv: params.env,
15641565
pty: params.pty === true && !sandbox,
15651566
timeoutSec: params.timeout,
@@ -1614,6 +1615,7 @@ export function createExecTool(
16141615
execCommand: execCommandOverride,
16151616
workdir,
16161617
env,
1618+
pathPrepend: defaultPathPrepend,
16171619
sandbox,
16181620
containerWorkdir,
16191621
usePty,

src/agents/bash-tools.test.ts

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,12 @@ vi.mock("../process/supervisor/index.js", () => {
104104
};
105105

106106
const immediate = () => new Promise<void>((resolve) => setImmediate(resolve));
107-
const readEnvPath = (env?: NodeJS.ProcessEnv) => env?.PATH ?? env?.Path ?? "";
107+
const readPathKey = (env?: NodeJS.ProcessEnv) =>
108+
env && "Path" in env && !("PATH" in env) ? "Path" : "PATH";
109+
const readEnvPath = (env?: NodeJS.ProcessEnv) => env?.[readPathKey(env)] ?? "";
110+
const writeEnvPath = (env: NodeJS.ProcessEnv, value: string) => {
111+
env[readPathKey(env)] = value;
112+
};
108113
const extractCommand = (input: SpawnInput) => input.ptyCommand ?? input.argv?.at(-1) ?? "";
109114
const splitCommands = (command: string) => {
110115
const commands: string[] = [];
@@ -116,7 +121,18 @@ vi.mock("../process/supervisor/index.js", () => {
116121
}
117122
return commands;
118123
};
119-
const stdoutForSegment = (segment: string, env?: NodeJS.ProcessEnv) => {
124+
const applySegmentShellEffects = (segment: string, env: NodeJS.ProcessEnv) => {
125+
if (segment === 'export PATH="${OPENCLAW_PREPEND_PATH}${PATH:+:$PATH}"') {
126+
const prepend = env.OPENCLAW_PREPEND_PATH ?? "";
127+
const current = readEnvPath(env);
128+
writeEnvPath(env, `${prepend}${current ? `:${current}` : ""}`);
129+
return;
130+
}
131+
if (segment === "unset OPENCLAW_PREPEND_PATH") {
132+
delete env.OPENCLAW_PREPEND_PATH;
133+
}
134+
};
135+
const stdoutForSegment = (segment: string, env: NodeJS.ProcessEnv) => {
120136
if (segment === "echo $PATH" || segment === "Write-Output $env:PATH") {
121137
return `${readEnvPath(env)}\n`;
122138
}
@@ -129,10 +145,15 @@ vi.mock("../process/supervisor/index.js", () => {
129145
return "";
130146
};
131147

132-
const commandOutput = (command: string, env?: NodeJS.ProcessEnv) =>
133-
splitCommands(command)
134-
.map((segment) => stdoutForSegment(segment, env))
148+
const commandOutput = (command: string, env?: NodeJS.ProcessEnv) => {
149+
const shellEnv = { ...env };
150+
return splitCommands(command)
151+
.map((segment) => {
152+
applySegmentShellEffects(segment, shellEnv);
153+
return stdoutForSegment(segment, shellEnv);
154+
})
135155
.join("");
156+
};
136157

137158
return {
138159
getProcessSupervisor: () => ({
@@ -845,6 +866,23 @@ describe("exec PATH handling", () => {
845866
expect(index).toBeLessThan(baseIndex);
846867
}
847868
});
869+
870+
it("protects POSIX prepended paths from shell startup overrides", async () => {
871+
if (isWin) {
872+
return;
873+
}
874+
process.env.PATH = "/evil/bin:/usr/bin";
875+
const tool = createTestExecTool({ pathPrepend: ["/custom/bin"] });
876+
877+
const result = await executeExecCommand(tool, COMMAND_PRINT_PATH);
878+
879+
const text = readNormalizedTextContent(result.content);
880+
const entries = text.split(path.delimiter);
881+
882+
// Simulate a shell startup file prepending /evil/bin before the command runs.
883+
// The exec wrapper must still restore configured pathPrepend entries to the front.
884+
expect(entries).toEqual(["/custom/bin", "/evil/bin", "/usr/bin"]);
885+
});
848886
});
849887

850888
describe("findPathKey", () => {

src/infra/path-prepend.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
findPathKey,
66
mergePathPrepend,
77
normalizePathPrepend,
8+
removePathPrepend,
89
} from "./path-prepend.js";
910

1011
const env = (value: Record<string, string>) => value;
@@ -106,4 +107,50 @@ describe("path prepend helpers", () => {
106107
applyPathPrepend(env, prepend, opts);
107108
expect(env).toEqual(expected);
108109
});
110+
111+
describe("removePathPrepend", () => {
112+
it("returns the existing path if prepend is empty", () => {
113+
expect(removePathPrepend("/usr/bin:/bin", [])).toBe("/usr/bin:/bin");
114+
});
115+
116+
it("returns undefined if existing is undefined", () => {
117+
expect(removePathPrepend(undefined, ["/custom/bin"])).toBeUndefined();
118+
});
119+
120+
it("removes prepended entries globally from the existing path", () => {
121+
// Normal case
122+
expect(
123+
removePathPrepend(
124+
pathLine("/custom/bin", "/opt/bin", "/usr/bin", "/bin"),
125+
["/custom/bin", "/opt/bin"]
126+
)
127+
).toBe(pathLine("/usr/bin", "/bin"));
128+
129+
// Tampered case (entries exist later in the path)
130+
expect(
131+
removePathPrepend(
132+
pathLine("/plugin/bin", "/custom/bin", "/opt/bin", "/usr/bin", "/bin"),
133+
["/custom/bin", "/opt/bin"]
134+
)
135+
).toBe(pathLine("/plugin/bin", "/usr/bin", "/bin"));
136+
137+
// Duplicate case (natural path contains duplicate of prepended entry)
138+
// Since removePathPrepend now uses global filtering, it will remove all instances.
139+
expect(
140+
removePathPrepend(
141+
pathLine("/custom/bin", "/opt/bin", "/usr/bin", "/custom/bin", "/bin"),
142+
["/custom/bin", "/opt/bin"]
143+
)
144+
).toBe(pathLine("/usr/bin", "/bin"));
145+
});
146+
147+
it("handles whitespace and blank entries safely", () => {
148+
expect(
149+
removePathPrepend(
150+
pathLine(" /custom/bin ", " ", "/usr/bin"),
151+
[" /custom/bin ", ""]
152+
)
153+
).toBe("/usr/bin");
154+
});
155+
});
109156
});

src/infra/path-prepend.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,27 @@ export function mergePathPrepend(existing: string | undefined, prepend: string[]
5757
return merged.join(path.delimiter);
5858
}
5959

60+
export function removePathPrepend(
61+
existing: string | undefined,
62+
prepend: string[],
63+
): string | undefined {
64+
if (!existing || prepend.length === 0) {
65+
return existing;
66+
}
67+
68+
const prependEntries = new Set<string>(
69+
prepend.map((part) => part.trim()).filter(Boolean),
70+
);
71+
72+
const remaining: string[] = (existing ?? "")
73+
.split(path.delimiter)
74+
.map((part) => part.trim())
75+
.filter(Boolean)
76+
.filter((part) => !prependEntries.has(part));
77+
78+
return remaining.join(path.delimiter);
79+
}
80+
6081
export function applyPathPrepend(
6182
env: Record<string, string>,
6283
prepend: string[] | undefined,

0 commit comments

Comments
 (0)