fix(exec): unwrap arch and xcrun dispatch wrappers (#58203)

* fix(exec): unwrap arch and xcrun dispatch wrappers

* fix(infra): scope arch wrapper unwrapping to macos

* fix(exec): scope arch wrapper unwrapping to macos

* fix(infra): validate macos arch wrapper selectors

* test(infra): cover invalid arch name wrappers
This commit is contained in:
Vincent Koc
2026-03-31 21:00:14 +09:00
committed by GitHub
parent 2ce44ca6a1
commit 730ba40763
5 changed files with 216 additions and 10 deletions

View File

@@ -136,6 +136,7 @@ Docs: https://docs.openclaw.ai
- Media/downloads: stop forwarding auth and cookie headers across cross-origin redirects during media saves, while preserving safe request headers for same-origin redirect chains. Thanks @AntAISecurityLab and @vincentkoc.
- Doctor/plugins: skip false Matrix legacy-helper warnings when no migration plans exist, and keep bundled `enabledByDefault` plugins in the gateway startup set. (#57931) Thanks @dinakars777.
- Zalo/webhooks: scope replay dedupe to the authenticated target so one configured account can no longer cause same-id inbound events for another target to be dropped. Thanks @smaeljaish771 and @vincentkoc.
- Exec approvals/macOS: unwrap `arch` and `xcrun` before deriving shell payloads and allow-always patterns, so wrapper approvals stay bound to the carried command instead of the outer carrier. Thanks @tdjackey and @vincentkoc.
- Matrix/CLI send: start one-off Matrix send clients before outbound delivery so `openclaw message send --channel matrix` restores E2EE in encrypted rooms instead of sending plain events. (#57936) Thanks @gumadeiras.
- xAI/Responses: normalize image-bearing tool results for xAI responses payloads, including OpenResponses-style `input_image.source` parts, so image tool replays no longer 422 on the follow-up turn. (#58017) Thanks @neeravmakwana.
- Cron/isolated sessions: carry the full live-session provider, model, and auth-profile selection across retry restarts so cron jobs with model overrides no longer fail or loop on mid-run model-switch requests. (#57972) Thanks @issaba1.

View File

@@ -48,6 +48,36 @@ const BSD_SCRIPT_OPTIONS_WITH_VALUE = new Set(["-F", "-t"]);
const SANDBOX_EXEC_OPTIONS_WITH_VALUE = new Set(["-f", "-p", "-d"]);
const TIMEOUT_FLAG_OPTIONS = new Set(["--foreground", "--preserve-status", "-v", "--verbose"]);
const TIMEOUT_OPTIONS_WITH_VALUE = new Set(["-k", "--kill-after", "-s", "--signal"]);
const XCRUN_FLAG_OPTIONS = new Set([
"-k",
"--kill-cache",
"-l",
"--log",
"-n",
"--no-cache",
"-r",
"--run",
"-v",
"--verbose",
]);
function isArchSelectorToken(token: string): boolean {
return /^-[A-Za-z0-9_]+$/.test(token);
}
function isKnownArchSelectorToken(token: string): boolean {
return (
token === "-arm64" ||
token === "-arm64e" ||
token === "-i386" ||
token === "-x86_64" ||
token === "-x86_64h"
);
}
function isKnownArchNameToken(token: string): boolean {
return isKnownArchSelectorToken(`-${token}`);
}
type WrapperScanDirective = "continue" | "consume-next" | "stop" | "invalid";
@@ -347,13 +377,68 @@ function unwrapTimeoutInvocation(argv: string[]): string[] | null {
});
}
function unwrapArchInvocation(argv: string[]): string[] | null {
let expectsArchName = false;
return scanWrapperInvocation(argv, {
onToken: (token, lower) => {
if (expectsArchName) {
expectsArchName = false;
return isKnownArchNameToken(lower) ? "continue" : "invalid";
}
if (!token.startsWith("-") || token === "-") {
return "stop";
}
if (lower === "-32" || lower === "-64") {
return "continue";
}
if (lower === "-arch") {
expectsArchName = true;
return "continue";
}
// `arch` can also mutate the launched environment, which is not transparent.
if (lower === "-c" || lower === "-d" || lower === "-e" || lower === "-h") {
return "invalid";
}
return isArchSelectorToken(token) && isKnownArchSelectorToken(lower) ? "continue" : "invalid";
},
});
}
function supportsArchDispatchWrapper(platform: NodeJS.Platform = process.platform): boolean {
return platform === "darwin";
}
function supportsXcrunDispatchWrapper(platform: NodeJS.Platform = process.platform): boolean {
return platform === "darwin";
}
function unwrapXcrunInvocation(argv: string[]): string[] | null {
return scanWrapperInvocation(argv, {
onToken: (token, lower) => {
if (!token.startsWith("-") || token === "-") {
return "stop";
}
if (XCRUN_FLAG_OPTIONS.has(lower)) {
return "continue";
}
return "invalid";
},
});
}
type DispatchWrapperSpec = {
name: string;
unwrap?: (argv: string[]) => string[] | null;
transparentUsage?: boolean | ((argv: string[]) => boolean);
unwrap?: (argv: string[], platform?: NodeJS.Platform) => string[] | null;
transparentUsage?: boolean | ((argv: string[], platform?: NodeJS.Platform) => boolean);
};
const DISPATCH_WRAPPER_SPECS: readonly DispatchWrapperSpec[] = [
{
name: "arch",
unwrap: (argv, platform) =>
supportsArchDispatchWrapper(platform) ? unwrapArchInvocation(argv) : null,
transparentUsage: (_argv, platform) => supportsArchDispatchWrapper(platform),
},
{ name: "caffeinate", unwrap: unwrapCaffeinateInvocation, transparentUsage: true },
{ name: "chrt" },
{ name: "doas" },
@@ -373,6 +458,12 @@ const DISPATCH_WRAPPER_SPECS: readonly DispatchWrapperSpec[] = [
{ name: "taskset" },
{ name: "time", unwrap: unwrapTimeInvocation, transparentUsage: true },
{ name: "timeout", unwrap: unwrapTimeoutInvocation, transparentUsage: true },
{
name: "xcrun",
unwrap: (argv, platform) =>
supportsXcrunDispatchWrapper(platform) ? unwrapXcrunInvocation(argv) : null,
transparentUsage: (_argv, platform) => supportsXcrunDispatchWrapper(platform),
},
];
const DISPATCH_WRAPPER_SPEC_BY_NAME = new Map(
@@ -412,7 +503,10 @@ export function isDispatchWrapperExecutable(token: string): boolean {
return DISPATCH_WRAPPER_SPEC_BY_NAME.has(normalizeExecutableToken(token));
}
export function unwrapKnownDispatchWrapperInvocation(argv: string[]): DispatchWrapperUnwrapResult {
export function unwrapKnownDispatchWrapperInvocation(
argv: string[],
platform: NodeJS.Platform = process.platform,
): DispatchWrapperUnwrapResult {
const token0 = argv[0]?.trim();
if (!token0) {
return { kind: "not-wrapper" };
@@ -423,26 +517,31 @@ export function unwrapKnownDispatchWrapperInvocation(argv: string[]): DispatchWr
return { kind: "not-wrapper" };
}
return spec.unwrap
? unwrapDispatchWrapper(wrapper, spec.unwrap(argv))
? unwrapDispatchWrapper(wrapper, spec.unwrap(argv, platform))
: blockDispatchWrapper(wrapper);
}
export function unwrapDispatchWrappersForResolution(
argv: string[],
maxDepth = MAX_DISPATCH_WRAPPER_DEPTH,
platform: NodeJS.Platform = process.platform,
): string[] {
const plan = resolveDispatchWrapperTrustPlan(argv, maxDepth);
const plan = resolveDispatchWrapperTrustPlan(argv, maxDepth, platform);
return plan.argv;
}
function isSemanticDispatchWrapperUsage(wrapper: string, argv: string[]): boolean {
function isSemanticDispatchWrapperUsage(
wrapper: string,
argv: string[],
platform: NodeJS.Platform = process.platform,
): boolean {
const spec = DISPATCH_WRAPPER_SPEC_BY_NAME.get(wrapper);
if (!spec?.unwrap) {
return true;
}
const transparentUsage = spec.transparentUsage;
if (typeof transparentUsage === "function") {
return !transparentUsage(argv);
return !transparentUsage(argv, platform);
}
return transparentUsage !== true;
}
@@ -463,11 +562,12 @@ function blockedDispatchWrapperPlan(params: {
export function resolveDispatchWrapperTrustPlan(
argv: string[],
maxDepth = MAX_DISPATCH_WRAPPER_DEPTH,
platform: NodeJS.Platform = process.platform,
): DispatchWrapperTrustPlan {
let current = argv;
const wrappers: string[] = [];
for (let depth = 0; depth < maxDepth; depth += 1) {
const unwrap = unwrapKnownDispatchWrapperInvocation(current);
const unwrap = unwrapKnownDispatchWrapperInvocation(current, platform);
if (unwrap.kind === "blocked") {
return blockedDispatchWrapperPlan({
argv: current,
@@ -479,7 +579,7 @@ export function resolveDispatchWrapperTrustPlan(
break;
}
wrappers.push(unwrap.wrapper);
if (isSemanticDispatchWrapperUsage(unwrap.wrapper, current)) {
if (isSemanticDispatchWrapperUsage(unwrap.wrapper, current, platform)) {
return blockedDispatchWrapperPlan({
argv: current,
wrappers,
@@ -489,7 +589,7 @@ export function resolveDispatchWrapperTrustPlan(
current = unwrap.argv;
}
if (wrappers.length >= maxDepth) {
const overflow = unwrapKnownDispatchWrapperInvocation(current);
const overflow = unwrapKnownDispatchWrapperInvocation(current, platform);
if (overflow.kind === "blocked" || overflow.kind === "unwrapped") {
return blockedDispatchWrapperPlan({
argv: current,

View File

@@ -637,6 +637,30 @@ $0 \\"$1\\"" touch {marker}`);
});
});
it("prevents allow-always bypass for macOS dispatch-wrapper chains", () => {
if (process.platform !== "darwin") {
return;
}
const dir = makeTempDir();
const echo = makeExecutable(dir, "echo");
makeExecutable(dir, "id");
const env = makePathEnv(dir);
expectAllowAlwaysBypassBlocked({
dir,
firstCommand: "/usr/bin/arch -arm64 /bin/zsh -lc 'echo warmup-ok'",
secondCommand: "/usr/bin/arch -arm64 /bin/zsh -lc 'id > marker-arch'",
env,
persistedPattern: echo,
});
expectAllowAlwaysBypassBlocked({
dir,
firstCommand: "/usr/bin/xcrun /bin/zsh -lc 'echo warmup-ok'",
secondCommand: "/usr/bin/xcrun /bin/zsh -lc 'id > marker-xcrun'",
env,
persistedPattern: echo,
});
});
it("prevents allow-always bypass for awk interpreters", () => {
if (process.platform === "win32") {
return;

View File

@@ -186,6 +186,13 @@ describe("unwrapKnownDispatchWrapperInvocation", () => {
argv: ["bash", "-lc", "echo hi"],
},
},
{
argv: ["xcrun", "bash", "-lc", "echo hi"],
expected:
process.platform === "darwin"
? { kind: "unwrapped", wrapper: "xcrun", argv: ["bash", "-lc", "echo hi"] }
: { kind: "blocked", wrapper: "xcrun" },
},
{
argv: ["script", "-q", "/dev/null"],
expected: { kind: "blocked", wrapper: "script" },
@@ -198,10 +205,35 @@ describe("unwrapKnownDispatchWrapperInvocation", () => {
argv: ["timeout", "--bogus", "5s", "bash", "-lc", "echo hi"],
expected: { kind: "blocked", wrapper: "timeout" },
},
{
argv: ["arch", "-e", "FOO=bar", "bash", "-lc", "echo hi"],
expected: { kind: "blocked", wrapper: "arch" },
},
{
argv: ["arch", "-arch", "bogus", "bash", "-lc", "echo hi"],
expected: { kind: "blocked", wrapper: "arch" },
},
{
argv: ["arch", "-arch", "bogus", "bash", "-lc", "echo hi"],
expected: { kind: "blocked", wrapper: "arch" },
},
{
argv: ["xcrun", "--sdk", "macosx", "bash", "-lc", "echo hi"],
expected: { kind: "blocked", wrapper: "xcrun" },
},
])("unwraps known dispatch wrappers for %j", ({ argv, expected }) => {
expect(unwrapKnownDispatchWrapperInvocation(argv)).toEqual(expected);
});
test("blocks arch dispatch unwrapping outside macOS", () => {
expect(
unwrapKnownDispatchWrapperInvocation(["arch", "-arm64", "bash", "-lc", "echo hi"], "linux"),
).toEqual({
kind: "blocked",
wrapper: "arch",
});
});
test.each(["chrt", "doas", "ionice", "setsid", "sudo", "taskset"])(
"fails closed for blocked dispatch wrapper %s",
(wrapper) => {
@@ -263,6 +295,20 @@ describe("resolveDispatchWrapperTrustPlan", () => {
wrapper: "timeout",
effectiveArgv: ["bash", "-lc", "echo hi"],
},
...(process.platform === "darwin"
? [
{
argv: ["arch", "-arm64", "bash", "-lc", "echo hi"],
wrapper: "arch",
effectiveArgv: ["bash", "-lc", "echo hi"],
},
{
argv: ["xcrun", "bash", "-lc", "echo hi"],
wrapper: "xcrun",
effectiveArgv: ["bash", "-lc", "echo hi"],
},
]
: []),
])("keeps transparent wrapper handling in sync for %s", ({ argv, wrapper, effectiveArgv }) => {
expectTransparentDispatchWrapperCase({ argv, wrapper, effectiveArgv });
});
@@ -277,6 +323,21 @@ describe("resolveDispatchWrapperTrustPlan", () => {
});
});
test("blocks arch trust unwrapping outside macOS", () => {
expect(
resolveDispatchWrapperTrustPlan(
["arch", "-arm64", "bash", "-lc", "echo hi"],
undefined,
"linux",
),
).toEqual({
argv: ["arch", "-arm64", "bash", "-lc", "echo hi"],
wrappers: [],
policyBlocked: true,
blockedWrapper: "arch",
});
});
test("blocks semantic env usage even when it reaches a shell wrapper", () => {
expect(resolveDispatchWrapperTrustPlan(["env", "FOO=bar", "bash", "-lc", "echo hi"])).toEqual({
argv: ["env", "FOO=bar", "bash", "-lc", "echo hi"],

View File

@@ -235,6 +235,26 @@ describe("system run command helpers", () => {
});
test.each([
{
name: "resolveSystemRunCommand unwraps macOS dispatch wrappers before deriving shell previews",
run: () =>
resolveSystemRunCommand({
command: ["/usr/bin/arch", "-arm64", "/bin/sh", "-lc", "echo hi"],
}),
expectedShellPayload: process.platform === "darwin" ? "echo hi" : null,
expectedCommandText: '/usr/bin/arch -arm64 /bin/sh -lc "echo hi"',
expectedPreviewText: process.platform === "darwin" ? "echo hi" : null,
},
{
name: "resolveSystemRunCommand unwraps xcrun before deriving shell previews",
run: () =>
resolveSystemRunCommand({
command: ["/usr/bin/xcrun", "/bin/sh", "-lc", "echo hi"],
}),
expectedShellPayload: process.platform === "darwin" ? "echo hi" : null,
expectedCommandText: '/usr/bin/xcrun /bin/sh -lc "echo hi"',
expectedPreviewText: process.platform === "darwin" ? "echo hi" : null,
},
{
name: "resolveSystemRunCommandRequest accepts legacy shell payloads but returns canonical command text",
run: () =>