import fs from
"node:fs" ;
import os from
"node:os" ;
import path from
"node:path" ;
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from
"vitest" ;
import type { ExecApprovalsResolved } from
"../infra/exec-approvals.js" ;
import { captureEnv } from
"../test-utils/env.js" ;
import { sanitizeBinaryOutput } from
"./shell-utils.js" ;
const isWin = process.platform ===
"win32" ;
const FOREGROUND_TEST_YIELD_MS =
120 _
000 ;
type GetShellPathFromLoginShell =
typeof import (
"../infra/shell-env.js" ).getShellPat
hFromLoginShell;
const shellEnvMocks = vi.hoisted(() => ({
getShellPathFromLoginShell: vi.fn<GetShellPathFromLoginShell>(() => "/custom/bin:/opt/bin" ),
resolveShellEnvFallbackTimeoutMs: vi.fn(() => 1234 ),
}));
vi.mock("../infra/shell-env.js" , async () => {
const mod =
await vi.importActual<typeof import ("../infra/shell-env.js" )>("../infra/shell-env.js" );
return {
...mod,
getShellPathFromLoginShell: shellEnvMocks.getShellPathFromLoginShell,
resolveShellEnvFallbackTimeoutMs: shellEnvMocks.resolveShellEnvFallbackTimeoutMs,
};
});
vi.mock("../infra/exec-approvals.js" , async () => {
const mod = await vi.importActual<typeof import ("../infra/exec-approvals.js" )>(
"../infra/exec-approvals.js" ,
);
return { ...mod, resolveExecApprovals: () => createExecApprovals() };
});
vi.mock("../process/supervisor/index.js" , () => ({
getProcessSupervisor: () => ({
spawn: async (input: {
argv?: string[];
env?: NodeJS.ProcessEnv;
onStdout?: (chunk: string) => void ;
}) => {
const command = input.argv?.at(-1 ) ?? "" ;
const env = input.env ?? {};
if (command.includes("OPENCLAW_SHELL" )) {
input.onStdout?.(env.OPENCLAW_SHELL ?? "" );
} else if (command.includes("SSLKEYLOGFILE" )) {
input.onStdout?.(env.SSLKEYLOGFILE ?? "" );
} else if (command.includes("$PATH" )) {
input.onStdout?.(env.PATH ?? "" );
} else if (command === "echo ok" ) {
input.onStdout?.("ok\n" );
}
return {
runId: "mock-path-run" ,
startedAtMs: Date.now(),
stdin: undefined,
wait: async () => ({
reason: "exit" as const ,
exitCode: 0 ,
exitSignal: null ,
durationMs: 0 ,
stdout: "" ,
stderr: "" ,
timedOut: false ,
noOutputTimedOut: false ,
}),
cancel: vi.fn(),
};
},
cancel: vi.fn(),
cancelScope: vi.fn(),
reconcileOrphans: vi.fn(),
getRecord: vi.fn(),
}),
}));
let createExecTool: typeof import ("./bash-tools.exec.js" ).createExecTool;
function createExecApprovals(): ExecApprovalsResolved {
return {
path: "/tmp/exec-approvals.json" ,
socketPath: "/tmp/exec-approvals.sock" ,
token: "token" ,
defaults: {
security: "full" ,
ask: "off" ,
askFallback: "full" ,
autoAllowSkills: false ,
},
agent: {
security: "full" ,
ask: "off" ,
askFallback: "full" ,
autoAllowSkills: false ,
},
agentSources: {
security: "defaults.security" ,
ask: "defaults.ask" ,
askFallback: "defaults.askFallback" ,
},
allowlist: [],
file: {
version: 1 ,
socket: { path: "/tmp/exec-approvals.sock" , token: "token" },
defaults: {
security: "full" ,
ask: "off" ,
askFallback: "full" ,
autoAllowSkills: false ,
},
agents: {},
},
};
}
const normalizeText = (value?: string) =>
sanitizeBinaryOutput(value ?? "" )
.replace(/\r\n/g, "\n" )
.replace(/\r/g, "\n" )
.trim();
const normalizePathEntries = (value?: string) =>
normalizeText(value)
.split(/[:\s]+/)
.map((entry) => entry.trim())
.filter(Boolean );
describe("exec PATH login shell merge" , () => {
let envSnapshot: ReturnType<typeof captureEnv>;
beforeAll(async () => {
({ createExecTool } = await import ("./bash-tools.exec.js" ));
});
beforeEach(() => {
envSnapshot = captureEnv(["PATH" , "SHELL" ]);
shellEnvMocks.getShellPathFromLoginShell.mockReset();
shellEnvMocks.getShellPathFromLoginShell.mockReturnValue("/custom/bin:/opt/bin" );
shellEnvMocks.resolveShellEnvFallbackTimeoutMs.mockReset();
shellEnvMocks.resolveShellEnvFallbackTimeoutMs.mockReturnValue(1234 );
});
afterEach(() => {
envSnapshot.restore();
});
it("merges login-shell PATH for host=gateway" , async () => {
if (isWin) {
return ;
}
process.env.PATH = "/usr/bin" ;
const shellPathMock = shellEnvMocks.getShellPathFromLoginShell;
shellPathMock.mockClear();
shellPathMock.mockReturnValue("/custom/bin:/opt/bin" );
const tool = createExecTool({ host: "gateway" , security: "full" , ask: "off" });
const result = await tool.execute("call1" , {
command: "echo $PATH" ,
yieldMs: FOREGROUND_TEST_YIELD_MS,
});
const entries = normalizePathEntries(result.content.find((c) => c.type === "text" )?.text);
expect(entries).toEqual(["/custom/bin" , "/opt/bin" , "/usr/bin" ]);
expect(shellPathMock).toHaveBeenCalledTimes(1 );
});
it("sets OPENCLAW_SHELL for host=gateway commands" , async () => {
if (isWin) {
return ;
}
const tool = createExecTool({ host: "gateway" , security: "full" , ask: "off" });
const result = await tool.execute("call-openclaw-shell" , {
command: 'printf "%s" "${OPENCLAW_SHELL:-}"' ,
yieldMs: FOREGROUND_TEST_YIELD_MS,
});
const value = normalizeText(result.content.find((c) => c.type === "text" )?.text);
expect(value).toBe("exec" );
});
it("throws security violation when env.PATH is provided" , async () => {
if (isWin) {
return ;
}
process.env.PATH = "/usr/bin" ;
const shellPathMock = shellEnvMocks.getShellPathFromLoginShell;
shellPathMock.mockClear();
const tool = createExecTool({ host: "gateway" , security: "full" , ask: "off" });
await expect(
tool.execute("call1" , {
command: "echo $PATH" ,
env: { PATH: "/explicit/bin" },
}),
).rejects.toThrow(/Security Violation: Custom 'PATH' variable is forbidden/);
expect(shellPathMock).not.toHaveBeenCalled();
});
it("fails closed when a blocked runtime override key is requested" , async () => {
if (isWin) {
return ;
}
const tool = createExecTool({ host: "gateway" , security: "full" , ask: "off" });
await expect(
tool.execute("call-blocked-runtime-env" , {
command: "echo ok" ,
env: { CLASSPATH: "/tmp/evil-classpath" },
}),
).rejects.toThrow(
/Security Violation: Environment variable 'CLASSPATH' is forbidden during host execution\./,
);
});
it("does not apply login-shell PATH when probe rejects unregistered absolute SHELL" , async () => {
if (isWin) {
return ;
}
process.env.PATH = "/usr/bin" ;
const shellDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shell-env-" ));
const unregisteredShellPath = path.join(shellDir, "unregistered-shell" );
fs.writeFileSync(unregisteredShellPath, '#!/bin/sh\nexec /bin/sh "$@"\n' , {
encoding: "utf8" ,
mode: 0 o755,
});
process.env.SHELL = unregisteredShellPath;
try {
const shellPathMock = shellEnvMocks.getShellPathFromLoginShell;
shellPathMock.mockClear();
shellPathMock.mockImplementation((opts) =>
opts.env.SHELL?.trim() === unregisteredShellPath ? null : "/custom/bin:/opt/bin" ,
);
const tool = createExecTool({ host: "gateway" , security: "full" , ask: "off" });
const result = await tool.execute("call1" , {
command: "echo $PATH" ,
yieldMs: FOREGROUND_TEST_YIELD_MS,
});
const entries = normalizePathEntries(result.content.find((c) => c.type === "text" )?.text);
expect(entries).toEqual(["/usr/bin" ]);
expect(shellPathMock).toHaveBeenCalledTimes(1 );
expect(shellPathMock).toHaveBeenCalledWith(
expect.objectContaining({
env: process.env,
timeoutMs: 1234 ,
}),
);
} finally {
fs.rmSync(shellDir, { recursive: true , force: true });
}
});
});
describe("exec host env validation" , () => {
it("blocks LD_/DYLD_ env vars on host execution" , async () => {
const tool = createExecTool({ host: "gateway" , security: "full" , ask: "off" });
await expect(
tool.execute("call1" , {
command: "echo ok" ,
env: { LD_DEBUG: "1" },
}),
).rejects.toThrow(/Security Violation: Environment variable 'LD_DEBUG' is forbidden/);
});
it("blocks proxy and TLS override env vars on host execution" , async () => {
const tool = createExecTool({ host: "gateway" , security: "full" , ask: "off" });
await expect(
tool.execute("call1" , {
command: "echo ok" ,
env: {
HTTPS_PROXY: "http://proxy.example.test:8080 ",
NODE_TLS_REJECT_UNAUTHORIZED: "0" ,
},
}),
).rejects.toThrow(
/Security Violation: blocked override keys: HTTPS_PROXY, NODE_TLS_REJECT_UNAUTHORIZED\./,
);
});
it("strips dangerous inherited env vars from host execution" , async () => {
if (isWin) {
return ;
}
const original = process.env.SSLKEYLOGFILE;
process.env.SSLKEYLOGFILE = "/tmp/openclaw-ssl-keys.log" ;
try {
const tool = createExecTool({ host: "gateway" , security: "full" , ask: "off" });
const result = await tool.execute("call1" , {
command: "printf '%s' \" ${SSLKEYLOGFILE:-}\"" ,
yieldMs: FOREGROUND_TEST_YIELD_MS,
});
const output = normalizeText(result.content.find((c) => c.type === "text" )?.text);
expect(output).not.toContain("/tmp/openclaw-ssl-keys.log" );
} finally {
if (original === undefined) {
delete process.env.SSLKEYLOGFILE;
} else {
process.env.SSLKEYLOGFILE = original;
}
}
});
it("routes implicit auto host to gateway when sandbox runtime is unavailable" , async () => {
const tool = createExecTool({ security: "full" , ask: "off" });
const result = await tool.execute("call1" , {
command: "echo ok" ,
yieldMs: FOREGROUND_TEST_YIELD_MS,
});
expect(normalizeText(result.content.find((c) => c.type === "text" )?.text)).toBe("ok" );
});
it("fails closed when sandbox host is explicitly configured without sandbox runtime" , async () => {
const tool = createExecTool({ host: "sandbox" , security: "full" , ask: "off" });
await expect(
tool.execute("call1" , {
command: "echo ok" ,
}),
).rejects.toThrow(/requires a sandbox runtime/);
});
it.each([
"echo ok && /approve abc123 allow-once" ,
"echo ok | /approve abc123 deny" ,
"echo ok\n/approve abc123 allow-once" ,
"FOO=1 /approve abc123 allow-once" ,
"env -i /approve abc123 deny" ,
"env --ignore-environment /approve abc123 allow-once" ,
"env -i FOO=1 /approve abc123 allow-once" ,
"env -S '/approve abc123 deny'" ,
"command /approve abc123 deny" ,
"command -p /approve abc123 deny" ,
"exec -a openclaw /approve abc123 deny" ,
"sudo /approve abc123 allow-once" ,
"sudo -E /approve abc123 allow-once" ,
"bash -lc '/approve abc123 deny'" ,
"bash -c 'sudo /approve abc123 allow-once'" ,
"sh -c '/approve abc123 allow-once'" ,
])("rejects /approve shell commands in %s" , async (command) => {
const tool = createExecTool({ host: "gateway" , security: "full" , ask: "off" });
await expect(
tool.execute("call-approve" , {
command,
}),
).rejects.toThrow(/exec cannot run \/approve commands/);
});
});
Messung V0.5 in Prozent C=99 H=98 G=98
¤ Dauer der Verarbeitung: 0.1 Sekunden
(vorverarbeitet am 2026-05-26)
¤
*© Formatika GbR, Deutschland