From 0f0a680d3df81739ea5088a2f88e65f938b7936b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Feb 2026 15:16:55 +0000 Subject: [PATCH] fix(exec): block shell-wrapper positional argv approval smuggling --- CHANGELOG.md | 1 + src/infra/system-run-command.test.ts | 19 +++++++ src/infra/system-run-command.ts | 78 +++++++++++++++++++++++++++- 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1eb085c4b5..25dfd536167 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Security/Native images: enforce `tools.fs.workspaceOnly` for native prompt image auto-load (including history refs), preventing out-of-workspace sandbox mounts from being implicitly ingested as vision input. This ships in the next npm release. Thanks @tdjackey for reporting. +- Security/Exec approvals: bind `system.run` command display/approval text to full argv when shell-wrapper inline payloads carry positional argv values, and reject payload-only `rawCommand` mismatches for those wrapper-carrier forms, preventing hidden command execution under misleading approval text. This ships in the next npm release. Thanks @tdjackey for reporting. - Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg. - Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell. - Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001. diff --git a/src/infra/system-run-command.test.ts b/src/infra/system-run-command.test.ts index 4b99c5e1365..7186823d84b 100644 --- a/src/infra/system-run-command.test.ts +++ b/src/infra/system-run-command.test.ts @@ -103,6 +103,13 @@ describe("system run command helpers", () => { expect(res.ok).toBe(true); }); + test("validateSystemRunCommandConsistency rejects shell-only rawCommand for positional-argv carrier wrappers", () => { + expectRawCommandMismatch({ + argv: ["/bin/sh", "-lc", '$0 "$1"', "/usr/bin/touch", "/tmp/marker"], + rawCommand: '$0 "$1"', + }); + }); + test("validateSystemRunCommandConsistency accepts rawCommand matching env shell wrapper argv", () => { const res = validateSystemRunCommandConsistency({ argv: ["/usr/bin/env", "bash", "-lc", "echo hi"], @@ -170,6 +177,18 @@ describe("system run command helpers", () => { expect(res.cmdText).toBe("echo SAFE&&whoami"); }); + test("resolveSystemRunCommand binds cmdText to full argv for shell-wrapper positional-argv carriers", () => { + const res = resolveSystemRunCommand({ + command: ["/bin/sh", "-lc", '$0 "$1"', "/usr/bin/touch", "/tmp/marker"], + }); + expect(res.ok).toBe(true); + if (!res.ok) { + throw new Error("unreachable"); + } + expect(res.shellCommand).toBe('$0 "$1"'); + expect(res.cmdText).toBe('/bin/sh -lc "$0 \\"$1\\"" /usr/bin/touch /tmp/marker'); + }); + test("resolveSystemRunCommand binds cmdText to full argv when env prelude modifies shell wrapper", () => { const res = resolveSystemRunCommand({ command: ["/usr/bin/env", "BASH_ENV=/tmp/payload.sh", "bash", "-lc", "echo hi"], diff --git a/src/infra/system-run-command.ts b/src/infra/system-run-command.ts index c8bbac6e7a9..b03d715fc72 100644 --- a/src/infra/system-run-command.ts +++ b/src/infra/system-run-command.ts @@ -1,6 +1,9 @@ import { extractShellWrapperCommand, hasEnvManipulationBeforeShellWrapper, + normalizeExecutableToken, + unwrapDispatchWrappersForResolution, + unwrapKnownShellMultiplexerInvocation, } from "./exec-wrapper-resolution.js"; export type SystemRunCommandValidation = @@ -49,6 +52,77 @@ export function extractShellCommandFromArgv(argv: string[]): string | null { return extractShellWrapperCommand(argv).command; } +const POSIX_OR_POWERSHELL_INLINE_WRAPPER_NAMES = new Set([ + "ash", + "bash", + "dash", + "fish", + "ksh", + "powershell", + "pwsh", + "sh", + "zsh", +]); + +const POSIX_INLINE_COMMAND_FLAGS = new Set(["-lc", "-c", "--command"]); +const POWERSHELL_INLINE_COMMAND_FLAGS = new Set(["-c", "-command", "--command"]); + +function unwrapShellWrapperArgv(argv: string[]): string[] { + const dispatchUnwrapped = unwrapDispatchWrappersForResolution(argv); + const shellMultiplexer = unwrapKnownShellMultiplexerInvocation(dispatchUnwrapped); + return shellMultiplexer.kind === "unwrapped" ? shellMultiplexer.argv : dispatchUnwrapped; +} + +function resolveInlineCommandTokenIndex( + argv: string[], + flags: ReadonlySet, + options: { allowCombinedC?: boolean } = {}, +): number | null { + for (let i = 1; i < argv.length; i += 1) { + const token = argv[i]?.trim(); + if (!token) { + continue; + } + const lower = token.toLowerCase(); + if (lower === "--") { + break; + } + if (flags.has(lower)) { + return i + 1 < argv.length ? i + 1 : null; + } + if (options.allowCombinedC && /^-[^-]*c[^-]*$/i.test(token)) { + const commandIndex = lower.indexOf("c"); + const inline = token.slice(commandIndex + 1).trim(); + return inline ? i : i + 1 < argv.length ? i + 1 : null; + } + } + return null; +} + +function hasTrailingPositionalArgvAfterInlineCommand(argv: string[]): boolean { + const wrapperArgv = unwrapShellWrapperArgv(argv); + const token0 = wrapperArgv[0]?.trim(); + if (!token0) { + return false; + } + + const wrapper = normalizeExecutableToken(token0); + if (!POSIX_OR_POWERSHELL_INLINE_WRAPPER_NAMES.has(wrapper)) { + return false; + } + + const inlineCommandIndex = + wrapper === "powershell" || wrapper === "pwsh" + ? resolveInlineCommandTokenIndex(wrapperArgv, POWERSHELL_INLINE_COMMAND_FLAGS) + : resolveInlineCommandTokenIndex(wrapperArgv, POSIX_INLINE_COMMAND_FLAGS, { + allowCombinedC: true, + }); + if (inlineCommandIndex === null) { + return false; + } + return wrapperArgv.slice(inlineCommandIndex + 1).some((entry) => entry.trim().length > 0); +} + export function validateSystemRunCommandConsistency(params: { argv: string[]; rawCommand?: string | null; @@ -59,10 +133,12 @@ export function validateSystemRunCommandConsistency(params: { : null; const shellWrapperResolution = extractShellWrapperCommand(params.argv); const shellCommand = shellWrapperResolution.command; + const shellWrapperPositionalArgv = hasTrailingPositionalArgvAfterInlineCommand(params.argv); const envManipulationBeforeShellWrapper = shellWrapperResolution.isWrapper && hasEnvManipulationBeforeShellWrapper(params.argv); + const mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv; const inferred = - shellCommand !== null && !envManipulationBeforeShellWrapper + shellCommand !== null && !mustBindDisplayToFullArgv ? shellCommand.trim() : formatExecCommand(params.argv);