mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-20 13:41:30 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
Reference in New Issue
Block a user