fix(security): block safeBins shell expansion

This commit is contained in:
Peter Steinberger
2026-02-14 19:42:52 +01:00
parent a73ccf2b53
commit 77b89719d5
8 changed files with 266 additions and 5 deletions

View File

@@ -338,6 +338,9 @@ export function emitExecSystemEvent(
export async function runExecProcess(opts: {
command: string;
// Execute this instead of `command` (which is kept for display/session/logging).
// Used to sanitize safeBins execution while preserving the original user input.
execCommand?: string;
workdir: string;
env: Record<string, string>;
sandbox?: BashSandboxConfig;
@@ -357,6 +360,7 @@ export async function runExecProcess(opts: {
let child: ChildProcessWithoutNullStreams | null = null;
let pty: PtyHandle | null = null;
let stdin: SessionStdin | undefined;
const execCommand = opts.execCommand ?? opts.command;
if (opts.sandbox) {
const { child: spawned } = await spawnWithFallback({
@@ -364,7 +368,7 @@ export async function runExecProcess(opts: {
"docker",
...buildDockerExecArgs({
containerName: opts.sandbox.containerName,
command: opts.command,
command: execCommand,
workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir,
env: opts.env,
tty: opts.usePty,
@@ -403,7 +407,7 @@ export async function runExecProcess(opts: {
if (!spawnPty) {
throw new Error("PTY support is unavailable (node-pty spawn not found).");
}
pty = spawnPty(shell, [...shellArgs, opts.command], {
pty = spawnPty(shell, [...shellArgs, execCommand], {
cwd: opts.workdir,
env: opts.env,
name: process.env.TERM ?? "xterm-256color",
@@ -435,7 +439,7 @@ export async function runExecProcess(opts: {
logWarn(`exec: PTY spawn failed (${errText}); retrying without PTY for "${opts.command}".`);
opts.warnings.push(warning);
const { child: spawned } = await spawnWithFallback({
argv: [shell, ...shellArgs, opts.command],
argv: [shell, ...shellArgs, execCommand],
options: {
cwd: opts.workdir,
env: opts.env,
@@ -462,7 +466,7 @@ export async function runExecProcess(opts: {
} else {
const { shell, args: shellArgs } = getShellConfig();
const { child: spawned } = await spawnWithFallback({
argv: [shell, ...shellArgs, opts.command],
argv: [shell, ...shellArgs, execCommand],
options: {
cwd: opts.workdir,
env: opts.env,

View File

@@ -15,6 +15,7 @@ import {
recordAllowlistUse,
resolveExecApprovals,
resolveExecApprovalsFromFile,
buildSafeShellCommand,
} from "../infra/exec-approvals.js";
import { buildNodeShellCommand } from "../infra/node-shell.js";
import {
@@ -170,6 +171,7 @@ export function createExecTool(
const maxOutput = DEFAULT_MAX_OUTPUT;
const pendingMaxOutput = DEFAULT_PENDING_MAX_OUTPUT;
const warnings: string[] = [];
let execCommandOverride: string | undefined;
const backgroundRequested = params.background === true;
const yieldRequested = typeof params.yieldMs === "number";
if (!allowBackground && (backgroundRequested || yieldRequested)) {
@@ -804,6 +806,25 @@ export function createExecTool(
throw new Error("exec denied: allowlist miss");
}
// If allowlist is satisfied only via safeBins (no explicit allowlist match),
// run a sanitized `shell -c` command that disables glob/var expansion by
// forcing every argv token to be literal via single-quoting.
if (
hostSecurity === "allowlist" &&
analysisOk &&
allowlistSatisfied &&
allowlistMatches.length === 0
) {
const safe = buildSafeShellCommand({
command: params.command,
platform: process.platform,
});
if (!safe.ok || !safe.command) {
throw new Error(`exec denied: safeBins sanitize failed (${safe.reason ?? "unknown"})`);
}
execCommandOverride = safe.command;
}
if (allowlistMatches.length > 0) {
const seen = new Set<string>();
for (const match of allowlistMatches) {
@@ -828,6 +849,7 @@ export function createExecTool(
const usePty = params.pty === true && !sandbox;
const run = await runExecProcess({
command: params.command,
execCommand: execCommandOverride,
workdir,
env,
sandbox,

View File

@@ -130,4 +130,46 @@ describe("createOpenClawCodingTools safeBins", () => {
expect(result.details.status).toBe("completed");
expect(text).toContain(marker);
});
it("does not allow env var expansion to smuggle file args via safeBins", async () => {
if (process.platform === "win32") {
return;
}
const { createOpenClawCodingTools } = await import("./pi-tools.js");
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-safe-bins-expand-"));
const secret = `TOP_SECRET_${Date.now()}`;
fs.writeFileSync(path.join(tmpDir, "secret.txt"), `${secret}\n`, "utf8");
const cfg: OpenClawConfig = {
tools: {
exec: {
host: "gateway",
security: "allowlist",
ask: "off",
safeBins: ["head", "wc"],
},
},
};
const tools = createOpenClawCodingTools({
config: cfg,
sessionKey: "agent:main:main",
workspaceDir: tmpDir,
agentDir: path.join(tmpDir, "agent"),
});
const execTool = tools.find((tool) => tool.name === "exec");
expect(execTool).toBeDefined();
const result = await execTool!.execute("call1", {
command: "head $FOO ; wc -l",
workdir: tmpDir,
env: { FOO: "secret.txt" },
});
const text = result.content.find((content) => content.type === "text")?.text ?? "";
expect(result.details.status).toBe("completed");
expect(text).not.toContain(secret);
});
});