fix: harden allow-always shell multiplexer wrapper handling

This commit is contained in:
Peter Steinberger
2026-02-24 03:06:34 +00:00
parent 4a3f8438e5
commit a67689a7e3
8 changed files with 193 additions and 1 deletions

View File

@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
- Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. This ships in the next npm release. Thanks @tdjackey and @jiseoung for reporting.
- Security/Channels: unify dangerous name-matching policy checks (`dangerouslyAllowNameMatching`) across core and extension channels, share mutable-allowlist detectors between `openclaw doctor` and `openclaw security audit`, and scan all configured accounts (not only the default account) in channel security audit findings.
- Security/Exec approvals: enforce canonical wrapper execution plans across allowlist analysis and runtime execution (node host + gateway host), fail closed on semantic `env` wrapper usage, and reject unknown short safe-bin flags to prevent `env -S/--split-string` interpretation-mismatch bypasses. This ships in the next npm release. Thanks @tdjackey for reporting.
- Security/Exec approvals: recognize `busybox`/`toybox` shell applets in wrapper analysis and allow-always persistence, persist inner executables instead of multiplexer wrapper binaries, and fail closed when multiplexer unwrapping is unsafe to prevent allow-always bypasses. This ships in the next npm release. Thanks @jiseoung for reporting.
- Security/Image tool: enforce `tools.fs.workspaceOnly` for sandboxed `image` path resolution so mounted out-of-workspace paths are blocked before media bytes are loaded/sent to vision providers. This ships in the next npm release. Thanks @tdjackey for reporting.
- Security/Session export: harden exported HTML image rendering against data-URL attribute injection by validating image MIME/base64 fields, rejecting malformed base64 input in media ingestion paths, and dropping invalid tool-image payloads.

View File

@@ -178,7 +178,9 @@ For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped env overrides are
small explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`).
For allow-always decisions in allowlist mode, known dispatch wrappers
(`env`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper
paths. If a wrapper cannot be safely unwrapped, no allowlist entry is persisted automatically.
paths. Shell multiplexers (`busybox`, `toybox`) are also unwrapped for shell applets (`sh`, `ash`,
etc.) so inner executables are persisted instead of multiplexer binaries. If a wrapper or
multiplexer cannot be safely unwrapped, no allowlist entry is persisted automatically.
Default safe bins: `jq`, `cut`, `uniq`, `head`, `tail`, `tr`, `wc`.

View File

@@ -153,6 +153,60 @@ describe("resolveAllowAlwaysPatterns", () => {
expect(patterns).not.toContain("/usr/bin/nice");
});
it("unwraps busybox/toybox shell applets and persists inner executables", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const busybox = makeExecutable(dir, "busybox");
makeExecutable(dir, "toybox");
const whoami = makeExecutable(dir, "whoami");
const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` };
const patterns = resolveAllowAlwaysPatterns({
segments: [
{
raw: `${busybox} sh -lc whoami`,
argv: [busybox, "sh", "-lc", "whoami"],
resolution: {
rawExecutable: busybox,
resolvedPath: busybox,
executableName: "busybox",
},
},
],
cwd: dir,
env,
platform: process.platform,
});
expect(patterns).toEqual([whoami]);
expect(patterns).not.toContain(busybox);
});
it("fails closed for unsupported busybox/toybox applets", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const busybox = makeExecutable(dir, "busybox");
const patterns = resolveAllowAlwaysPatterns({
segments: [
{
raw: `${busybox} sed -n 1p`,
argv: [busybox, "sed", "-n", "1p"],
resolution: {
rawExecutable: busybox,
resolvedPath: busybox,
executableName: "busybox",
},
},
],
cwd: dir,
env: makePathEnv(dir),
platform: process.platform,
});
expect(patterns).toEqual([]);
});
it("fails closed for unresolved dispatch wrappers", () => {
const patterns = resolveAllowAlwaysPatterns({
segments: [
@@ -171,6 +225,52 @@ describe("resolveAllowAlwaysPatterns", () => {
expect(patterns).toEqual([]);
});
it("prevents allow-always bypass for busybox shell applets", () => {
if (process.platform === "win32") {
return;
}
const dir = makeTempDir();
const busybox = makeExecutable(dir, "busybox");
const echo = makeExecutable(dir, "echo");
makeExecutable(dir, "id");
const safeBins = resolveSafeBins(undefined);
const env = { PATH: `${dir}${path.delimiter}${process.env.PATH ?? ""}` };
const first = evaluateShellAllowlist({
command: `${busybox} sh -c 'echo warmup-ok'`,
allowlist: [],
safeBins,
cwd: dir,
env,
platform: process.platform,
});
const persisted = resolveAllowAlwaysPatterns({
segments: first.segments,
cwd: dir,
env,
platform: process.platform,
});
expect(persisted).toEqual([echo]);
const second = evaluateShellAllowlist({
command: `${busybox} sh -c 'id > marker'`,
allowlist: [{ pattern: echo }],
safeBins,
cwd: dir,
env,
platform: process.platform,
});
expect(second.allowlistSatisfied).toBe(false);
expect(
requiresExecApproval({
ask: "on-miss",
security: "allowlist",
analysisOk: second.analysisOk,
allowlistSatisfied: second.allowlistSatisfied,
}),
).toBe(true);
});
it("prevents allow-always bypass for dispatch-wrapper + shell-wrapper chains", () => {
if (process.platform === "win32") {
return;

View File

@@ -21,6 +21,7 @@ import {
extractShellWrapperInlineCommand,
isDispatchWrapperExecutable,
isShellWrapperExecutable,
unwrapKnownShellMultiplexerInvocation,
unwrapKnownDispatchWrapperInvocation,
} from "./exec-wrapper-resolution.js";
@@ -299,6 +300,30 @@ function collectAllowAlwaysPatterns(params: {
return;
}
const shellMultiplexerUnwrap = unwrapKnownShellMultiplexerInvocation(params.segment.argv);
if (shellMultiplexerUnwrap.kind === "blocked") {
return;
}
if (shellMultiplexerUnwrap.kind === "unwrapped") {
collectAllowAlwaysPatterns({
segment: {
raw: shellMultiplexerUnwrap.argv.join(" "),
argv: shellMultiplexerUnwrap.argv,
resolution: resolveCommandResolutionFromArgv(
shellMultiplexerUnwrap.argv,
params.cwd,
params.env,
),
},
cwd: params.cwd,
env: params.env,
platform: params.platform,
depth: params.depth + 1,
out: params.out,
});
return;
}
const candidatePath = resolveAllowlistCandidatePath(params.segment.resolution, params.cwd);
if (!candidatePath) {
return;

View File

@@ -15,6 +15,8 @@ describe("exec safe-bin runtime policy", () => {
{ bin: "node20", expected: true },
{ bin: "ruby3.2", expected: true },
{ bin: "bash", expected: true },
{ bin: "busybox", expected: true },
{ bin: "toybox", expected: true },
{ bin: "myfilter", expected: false },
{ bin: "jq", expected: false },
];

View File

@@ -17,6 +17,7 @@ export type ExecSafeBinConfigScope = {
const INTERPRETER_LIKE_SAFE_BINS = new Set([
"ash",
"bash",
"busybox",
"bun",
"cmd",
"cmd.exe",
@@ -40,6 +41,7 @@ const INTERPRETER_LIKE_SAFE_BINS = new Set([
"python3",
"ruby",
"sh",
"toybox",
"wscript",
"zsh",
]);

View File

@@ -7,6 +7,7 @@ const WINDOWS_EXE_SUFFIX = ".exe";
const POSIX_SHELL_WRAPPER_NAMES = ["ash", "bash", "dash", "fish", "ksh", "sh", "zsh"] as const;
const WINDOWS_CMD_WRAPPER_NAMES = ["cmd"] as const;
const POWERSHELL_WRAPPER_NAMES = ["powershell", "pwsh"] as const;
const SHELL_MULTIPLEXER_WRAPPER_NAMES = ["busybox", "toybox"] as const;
const DISPATCH_WRAPPER_NAMES = [
"chrt",
"doas",
@@ -42,6 +43,7 @@ export const DISPATCH_WRAPPER_EXECUTABLES = new Set(withWindowsExeAliases(DISPAT
const POSIX_SHELL_WRAPPER_CANONICAL = new Set<string>(POSIX_SHELL_WRAPPER_NAMES);
const WINDOWS_CMD_WRAPPER_CANONICAL = new Set<string>(WINDOWS_CMD_WRAPPER_NAMES);
const POWERSHELL_WRAPPER_CANONICAL = new Set<string>(POWERSHELL_WRAPPER_NAMES);
const SHELL_MULTIPLEXER_WRAPPER_CANONICAL = new Set<string>(SHELL_MULTIPLEXER_WRAPPER_NAMES);
const DISPATCH_WRAPPER_CANONICAL = new Set<string>(DISPATCH_WRAPPER_NAMES);
const SHELL_WRAPPER_CANONICAL = new Set<string>([
...POSIX_SHELL_WRAPPER_NAMES,
@@ -133,6 +135,39 @@ function findShellWrapperSpec(baseExecutable: string): ShellWrapperSpec | null {
return null;
}
export type ShellMultiplexerUnwrapResult =
| { kind: "not-wrapper" }
| { kind: "blocked"; wrapper: string }
| { kind: "unwrapped"; wrapper: string; argv: string[] };
export function unwrapKnownShellMultiplexerInvocation(
argv: string[],
): ShellMultiplexerUnwrapResult {
const token0 = argv[0]?.trim();
if (!token0) {
return { kind: "not-wrapper" };
}
const wrapper = normalizeExecutableToken(token0);
if (!SHELL_MULTIPLEXER_WRAPPER_CANONICAL.has(wrapper)) {
return { kind: "not-wrapper" };
}
let appletIndex = 1;
if (argv[appletIndex]?.trim() === "--") {
appletIndex += 1;
}
const applet = argv[appletIndex]?.trim();
if (!applet || !isShellWrapperExecutable(applet)) {
return { kind: "blocked", wrapper };
}
const unwrapped = argv.slice(appletIndex);
if (unwrapped.length === 0) {
return { kind: "blocked", wrapper };
}
return { kind: "unwrapped", wrapper, argv: unwrapped };
}
export function isEnvAssignment(token: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token);
}
@@ -474,6 +509,18 @@ function hasEnvManipulationBeforeShellWrapperInternal(
);
}
const shellMultiplexerUnwrap = unwrapKnownShellMultiplexerInvocation(argv);
if (shellMultiplexerUnwrap.kind === "blocked") {
return false;
}
if (shellMultiplexerUnwrap.kind === "unwrapped") {
return hasEnvManipulationBeforeShellWrapperInternal(
shellMultiplexerUnwrap.argv,
depth + 1,
envManipulationSeen,
);
}
const wrapper = findShellWrapperSpec(normalizeExecutableToken(token0));
if (!wrapper) {
return false;
@@ -577,6 +624,14 @@ function extractShellWrapperCommandInternal(
return extractShellWrapperCommandInternal(dispatchUnwrap.argv, rawCommand, depth + 1);
}
const shellMultiplexerUnwrap = unwrapKnownShellMultiplexerInvocation(argv);
if (shellMultiplexerUnwrap.kind === "blocked") {
return { isWrapper: false, command: null };
}
if (shellMultiplexerUnwrap.kind === "unwrapped") {
return extractShellWrapperCommandInternal(shellMultiplexerUnwrap.argv, rawCommand, depth + 1);
}
const base0 = normalizeExecutableToken(token0);
const wrapper = findShellWrapperSpec(base0);
if (!wrapper) {

View File

@@ -57,6 +57,11 @@ describe("system run command helpers", () => {
expect(extractShellCommandFromArgv(["pwsh", "-Command", "Get-Date"])).toBe("Get-Date");
});
test("extractShellCommandFromArgv unwraps busybox/toybox shell applets", () => {
expect(extractShellCommandFromArgv(["busybox", "sh", "-c", "echo hi"])).toBe("echo hi");
expect(extractShellCommandFromArgv(["toybox", "ash", "-lc", "echo hi"])).toBe("echo hi");
});
test("extractShellCommandFromArgv ignores env wrappers when no shell wrapper follows", () => {
expect(extractShellCommandFromArgv(["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"])).toBe(
null,