Moderate severityNVD Advisory· Published Mar 19, 2026· Updated Mar 25, 2026
OpenClaw < 2026.2.22 - Authorization Bypass via allow-always Wrapper Persistence
CVE-2026-29607
Description
OpenClaw versions prior to 2026.2.22 contain an authorization bypass vulnerability in allow-always wrapper persistence that allows attackers to bypass approval checks by persisting wrapper-level allowlist entries instead of validating inner executable intent. Remote attackers can approve benign wrapped system.run commands and subsequently execute different payloads without approval, enabling remote code execution on gateway and node-host execution flows.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.22 | 2026.2.22 |
Affected products
1Patches
124c954d97240fix(security): harden allow-always wrapper persistence
8 files changed · +387 −11
CHANGELOG.md+1 −0 modified@@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai - Plugins/Media sandbox: propagate trusted `mediaLocalRoots` through plugin action dispatch (including Discord/Telegram action adapters) so plugin send paths enforce the same agent-scoped local-media sandbox roots as core outbound sends. (#20258, #22718) - Agents/Workspace guard: map sandbox container-workdir file-tool paths (for example `/workspace/...` and `file:///workspace/...`) to host workspace roots before workspace-only validation, preventing false `Path escapes sandbox root` rejections for sandbox file tools. (#9560) - Gateway/Exec approvals: expire approval requests immediately when no approval-capable gateway clients are connected and no forwarding targets are available, avoiding delayed approvals after restarts/offline approver windows. (#22144) +- Security/Exec approvals: when approving wrapper commands with allow-always in allowlist mode, persist inner executable paths for known dispatch wrappers (`env`, `nice`, `nohup`, `stdbuf`, `timeout`) and fail closed (no persisted entry) when wrapper unwrapping is not safe, preventing wrapper-path approval bypasses. - Node/macOS exec host: default headless macOS node `system.run` to local execution and only route through the companion app when `OPENCLAW_NODE_EXEC_HOST=app` is explicitly set, avoiding companion-app filesystem namespace mismatches during exec. (#23547) - Security/Exec: stop trusting `PATH`-derived directories for safe-bin allowlist checks, add explicit `tools.exec.safeBinTrustedDirs`, and pin safe-bin shell execution to resolved absolute executable paths to prevent binary-shadowing approval bypasses. This ships in the next npm release. Thanks @tdjackey for reporting. - Slack/Threading: sessions: keep parent-session forking and thread-history context active beyond first turn by removing first-turn-only gates in session init, thread-history fetch, and reply prompt context injection. (#23843, #23090) Thanks @vincentkoc and @Taskle.
docs/nodes/index.md+1 −0 modified@@ -279,6 +279,7 @@ Notes: - `system.notify` respects notification permission state on the macOS app. - `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`. - For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped `--env` values are reduced to an 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 unwrapping is not safe, no allowlist entry is persisted automatically. - On Windows node hosts in allowlist mode, shell-wrapper runs via `cmd.exe /c` require approval (allowlist entry alone does not auto-allow the wrapper form). - `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`. - Node hosts ignore `PATH` overrides and strip dangerous startup/shell keys (`DYLD_*`, `LD_*`, `NODE_OPTIONS`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`). If you need extra PATH entries, configure the node host service environment (or install tools in standard locations) instead of passing `PATH` via `--env`.
docs/platforms/macos.md+1 −0 modified@@ -107,6 +107,7 @@ Notes: - Choosing “Always Allow” in the prompt adds that command to the allowlist. - `system.run` environment overrides are filtered (drops `PATH`, `DYLD_*`, `LD_*`, `NODE_OPTIONS`, `PYTHON*`, `PERL*`, `RUBYOPT`, `SHELLOPTS`, `PS4`) and then merged with the app’s environment. - For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped environment overrides are reduced to a 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 unwrapping is not safe, no allowlist entry is persisted automatically. ## Deep links
docs/tools/exec-approvals.md+3 −0 modified@@ -161,6 +161,9 @@ On macOS companion-app approvals, raw shell text containing shell control or exp the shell binary itself is allowlisted. For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped env overrides are reduced to a 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. Default safe bins: `jq`, `cut`, `uniq`, `head`, `tail`, `tr`, `wc`.
src/infra/exec-approvals-allowlist.ts+51 −3 modified@@ -4,6 +4,7 @@ import { isWindowsPlatform, matchAllowlist, resolveAllowlistCandidatePath, + resolveCommandResolutionFromArgv, splitCommandChain, type ExecCommandAnalysis, type CommandResolution, @@ -16,6 +17,11 @@ import { validateSafeBinArgv, } from "./exec-safe-bin-policy.js"; import { isTrustedSafeBinPath } from "./exec-safe-bin-trust.js"; +import { + DISPATCH_WRAPPER_EXECUTABLES, + basenameLower, + unwrapKnownDispatchWrapperInvocation, +} from "./exec-wrapper-resolution.js"; function hasShellLineContinuation(command: string): boolean { return /\\(?:\r\n|\n|\r)/.test(command); @@ -255,6 +261,27 @@ function isShellWrapperSegment(segment: ExecCommandSegment): boolean { return false; } +function isDispatchWrapperSegment(segment: ExecCommandSegment): boolean { + const candidates = [ + normalizeExecutableName(segment.resolution?.executableName), + normalizeExecutableName(segment.resolution?.rawExecutable), + normalizeExecutableName(segment.argv[0]), + ]; + for (const candidate of candidates) { + if (!candidate) { + continue; + } + if (DISPATCH_WRAPPER_EXECUTABLES.has(candidate)) { + return true; + } + const base = basenameLower(candidate); + if (DISPATCH_WRAPPER_EXECUTABLES.has(base)) { + return true; + } + } + return false; +} + function extractShellInlineCommand(argv: string[]): string | null { for (let i = 1; i < argv.length; i += 1) { const token = argv[i]; @@ -296,6 +323,30 @@ function collectAllowAlwaysPatterns(params: { depth: number; out: Set<string>; }) { + if (params.depth >= 3) { + return; + } + + if (isDispatchWrapperSegment(params.segment)) { + const unwrappedArgv = unwrapKnownDispatchWrapperInvocation(params.segment.argv); + if (!unwrappedArgv || unwrappedArgv.length === 0) { + return; + } + collectAllowAlwaysPatterns({ + segment: { + raw: unwrappedArgv.join(" "), + argv: unwrappedArgv, + resolution: resolveCommandResolutionFromArgv(unwrappedArgv, 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; @@ -304,9 +355,6 @@ function collectAllowAlwaysPatterns(params: { params.out.add(candidatePath); return; } - if (params.depth >= 3) { - return; - } const inlineCommand = extractShellInlineCommand(params.segment.argv); if (!inlineCommand) { return;
src/infra/exec-approvals.test.ts+100 −0 modified@@ -293,6 +293,17 @@ describe("exec approvals command resolution", () => { expect(resolution?.rawExecutable).toBe("bash"); expect(resolution?.executableName.toLowerCase()).toContain("bash"); }); + + it("unwraps nice wrapper argv to resolve the effective executable", () => { + const resolution = resolveCommandResolutionFromArgv([ + "/usr/bin/nice", + "bash", + "-lc", + "echo hi", + ]); + expect(resolution?.rawExecutable).toBe("bash"); + expect(resolution?.executableName.toLowerCase()).toContain("bash"); + }); }); describe("exec approvals shell parsing", () => { @@ -1486,4 +1497,93 @@ describe("resolveAllowAlwaysPatterns", () => { }); expect(patterns).toEqual([whoami]); }); + + it("unwraps known dispatch wrappers before shell wrappers", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const whoami = makeExecutable(dir, "whoami"); + const patterns = resolveAllowAlwaysPatterns({ + segments: [ + { + raw: "/usr/bin/nice /bin/zsh -lc whoami", + argv: ["/usr/bin/nice", "/bin/zsh", "-lc", "whoami"], + resolution: { + rawExecutable: "/usr/bin/nice", + resolvedPath: "/usr/bin/nice", + executableName: "nice", + }, + }, + ], + cwd: dir, + env: makePathEnv(dir), + platform: process.platform, + }); + expect(patterns).toEqual([whoami]); + expect(patterns).not.toContain("/usr/bin/nice"); + }); + + it("fails closed for unresolved dispatch wrappers", () => { + const patterns = resolveAllowAlwaysPatterns({ + segments: [ + { + raw: "sudo /bin/zsh -lc whoami", + argv: ["sudo", "/bin/zsh", "-lc", "whoami"], + resolution: { + rawExecutable: "sudo", + resolvedPath: "/usr/bin/sudo", + executableName: "sudo", + }, + }, + ], + platform: process.platform, + }); + expect(patterns).toEqual([]); + }); + + it("prevents allow-always bypass for dispatch-wrapper + shell-wrapper chains", () => { + if (process.platform === "win32") { + return; + } + const dir = makeTempDir(); + const echo = makeExecutable(dir, "echo"); + makeExecutable(dir, "id"); + const safeBins = resolveSafeBins(undefined); + const env = makePathEnv(dir); + + const first = evaluateShellAllowlist({ + command: "/usr/bin/nice /bin/zsh -lc '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: "/usr/bin/nice /bin/zsh -lc '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); + }); });
src/infra/exec-wrapper-resolution.ts+214 −8 modified@@ -5,6 +5,30 @@ export const MAX_DISPATCH_WRAPPER_DEPTH = 4; export const POSIX_SHELL_WRAPPERS = new Set(["ash", "bash", "dash", "fish", "ksh", "sh", "zsh"]); export const WINDOWS_CMD_WRAPPERS = new Set(["cmd.exe", "cmd"]); export const POWERSHELL_WRAPPERS = new Set(["powershell", "powershell.exe", "pwsh", "pwsh.exe"]); +export const DISPATCH_WRAPPER_EXECUTABLES = new Set([ + "chrt", + "chrt.exe", + "doas", + "doas.exe", + "env", + "env.exe", + "ionice", + "ionice.exe", + "nice", + "nice.exe", + "nohup", + "nohup.exe", + "setsid", + "setsid.exe", + "stdbuf", + "stdbuf.exe", + "sudo", + "sudo.exe", + "taskset", + "taskset.exe", + "timeout", + "timeout.exe", +]); const POSIX_INLINE_COMMAND_FLAGS = new Set(["-lc", "-c", "--command"]); const POWERSHELL_INLINE_COMMAND_FLAGS = new Set(["-c", "-command", "--command"]); @@ -21,6 +45,10 @@ const ENV_OPTIONS_WITH_VALUE = new Set([ "--block-signal", ]); const ENV_FLAG_OPTIONS = new Set(["-i", "--ignore-environment", "-0", "--null"]); +const NICE_OPTIONS_WITH_VALUE = new Set(["-n", "--adjustment", "--priority"]); +const STDBUF_OPTIONS_WITH_VALUE = new Set(["-i", "--input", "-o", "--output", "-e", "--error"]); +const TIMEOUT_FLAG_OPTIONS = new Set(["--foreground", "--preserve-status", "-v", "--verbose"]); +const TIMEOUT_OPTIONS_WITH_VALUE = new Set(["-k", "--kill-after", "-s", "--signal"]); type ShellWrapperKind = "posix" | "cmd" | "powershell"; @@ -122,20 +150,198 @@ export function unwrapEnvInvocation(argv: string[]): string[] | null { return idx < argv.length ? argv.slice(idx) : null; } +function unwrapNiceInvocation(argv: string[]): string[] | null { + let idx = 1; + let expectsOptionValue = false; + while (idx < argv.length) { + const token = argv[idx]?.trim() ?? ""; + if (!token) { + idx += 1; + continue; + } + if (expectsOptionValue) { + expectsOptionValue = false; + idx += 1; + continue; + } + if (token === "--") { + idx += 1; + break; + } + if (token.startsWith("-") && token !== "-") { + const lower = token.toLowerCase(); + const [flag] = lower.split("=", 2); + if (/^-\d+$/.test(lower)) { + idx += 1; + continue; + } + if (NICE_OPTIONS_WITH_VALUE.has(flag)) { + if (!lower.includes("=") && lower === flag) { + expectsOptionValue = true; + } + idx += 1; + continue; + } + if (lower.startsWith("-n") && lower.length > 2) { + idx += 1; + continue; + } + return null; + } + break; + } + if (expectsOptionValue) { + return null; + } + return idx < argv.length ? argv.slice(idx) : null; +} + +function unwrapNohupInvocation(argv: string[]): string[] | null { + let idx = 1; + while (idx < argv.length) { + const token = argv[idx]?.trim() ?? ""; + if (!token) { + idx += 1; + continue; + } + if (token === "--") { + idx += 1; + break; + } + if (token.startsWith("-") && token !== "-") { + const lower = token.toLowerCase(); + if (lower === "--help" || lower === "--version") { + idx += 1; + continue; + } + return null; + } + break; + } + return idx < argv.length ? argv.slice(idx) : null; +} + +function unwrapStdbufInvocation(argv: string[]): string[] | null { + let idx = 1; + let expectsOptionValue = false; + while (idx < argv.length) { + const token = argv[idx]?.trim() ?? ""; + if (!token) { + idx += 1; + continue; + } + if (expectsOptionValue) { + expectsOptionValue = false; + idx += 1; + continue; + } + if (token === "--") { + idx += 1; + break; + } + if (token.startsWith("-") && token !== "-") { + const lower = token.toLowerCase(); + const [flag] = lower.split("=", 2); + if (STDBUF_OPTIONS_WITH_VALUE.has(flag)) { + if (!lower.includes("=")) { + expectsOptionValue = true; + } + idx += 1; + continue; + } + return null; + } + break; + } + if (expectsOptionValue) { + return null; + } + return idx < argv.length ? argv.slice(idx) : null; +} + +function unwrapTimeoutInvocation(argv: string[]): string[] | null { + let idx = 1; + let expectsOptionValue = false; + while (idx < argv.length) { + const token = argv[idx]?.trim() ?? ""; + if (!token) { + idx += 1; + continue; + } + if (expectsOptionValue) { + expectsOptionValue = false; + idx += 1; + continue; + } + if (token === "--") { + idx += 1; + break; + } + if (token.startsWith("-") && token !== "-") { + const lower = token.toLowerCase(); + const [flag] = lower.split("=", 2); + if (TIMEOUT_FLAG_OPTIONS.has(flag)) { + idx += 1; + continue; + } + if (TIMEOUT_OPTIONS_WITH_VALUE.has(flag)) { + if (!lower.includes("=")) { + expectsOptionValue = true; + } + idx += 1; + continue; + } + return null; + } + break; + } + if (expectsOptionValue || idx >= argv.length) { + return null; + } + idx += 1; // duration + return idx < argv.length ? argv.slice(idx) : null; +} + +export function unwrapKnownDispatchWrapperInvocation(argv: string[]): string[] | null | undefined { + const token0 = argv[0]?.trim(); + if (!token0) { + return undefined; + } + const base = basenameLower(token0); + const normalizedBase = base.endsWith(".exe") ? base.slice(0, -4) : base; + switch (normalizedBase) { + case "env": + return unwrapEnvInvocation(argv); + case "nice": + return unwrapNiceInvocation(argv); + case "nohup": + return unwrapNohupInvocation(argv); + case "stdbuf": + return unwrapStdbufInvocation(argv); + case "timeout": + return unwrapTimeoutInvocation(argv); + case "chrt": + case "doas": + case "ionice": + case "setsid": + case "sudo": + case "taskset": + return null; + default: + return undefined; + } +} + export function unwrapDispatchWrappersForResolution( argv: string[], maxDepth = MAX_DISPATCH_WRAPPER_DEPTH, ): string[] { let current = argv; for (let depth = 0; depth < maxDepth; depth += 1) { - const token0 = current[0]?.trim(); - if (!token0) { - break; - } - if (basenameLower(token0) !== "env") { + const unwrapped = unwrapKnownDispatchWrapperInvocation(current); + if (unwrapped === undefined) { break; } - const unwrapped = unwrapEnvInvocation(current); if (!unwrapped || unwrapped.length === 0) { break; } @@ -213,8 +419,8 @@ function extractShellWrapperCommandInternal( } const base0 = basenameLower(token0); - if (base0 === "env") { - const unwrapped = unwrapEnvInvocation(argv); + if (DISPATCH_WRAPPER_EXECUTABLES.has(base0)) { + const unwrapped = unwrapKnownDispatchWrapperInvocation(argv); if (!unwrapped) { return { isWrapper: false, command: null }; }
src/infra/system-run-command.test.ts+16 −0 modified@@ -36,6 +36,22 @@ describe("system run command helpers", () => { ); }); + test("extractShellCommandFromArgv unwraps known dispatch wrappers before shell wrappers", () => { + expect(extractShellCommandFromArgv(["/usr/bin/nice", "/bin/bash", "-lc", "echo hi"])).toBe( + "echo hi", + ); + expect( + extractShellCommandFromArgv([ + "/usr/bin/timeout", + "--signal=TERM", + "5", + "zsh", + "-lc", + "echo hi", + ]), + ).toBe("echo hi"); + }); + test("extractShellCommandFromArgv supports fish and pwsh wrappers", () => { expect(extractShellCommandFromArgv(["fish", "-c", "echo hi"])).toBe("echo hi"); expect(extractShellCommandFromArgv(["pwsh", "-Command", "Get-Date"])).toBe("Get-Date");
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/openclaw/openclaw/commit/24c954d972400f508814532dea0e4dcb38418bb0ghsapatchWEB
- github.com/advisories/GHSA-6j27-pc5c-m8w8ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-6j27-pc5c-m8w8ghsathird-party-advisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-29607ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-authorization-bypass-via-allow-always-wrapper-persistenceghsathird-party-advisoryWEB
News mentions
0No linked articles in our index yet.