High severityNVD Advisory· Published Mar 11, 2026· Updated Mar 11, 2026
OpenClaw 2026.2.22-2 < 2026.2.23 - Allowlist Bypass via sort Long-Option Abbreviation in tools.exec.safeBins
CVE-2026-32059
Description
OpenClaw version 2026.2.22-2 prior to 2026.2.23 tools.exec.safeBins validation for sort command fails to properly validate GNU long-option abbreviations, allowing attackers to bypass denied-flag checks via abbreviated options. Remote attackers can execute sort commands with abbreviated long options to skip approval requirements in allowlist mode.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.23 | 2026.2.23 |
Affected products
1Patches
13b8e33037ae2fix(security): harden safeBins long-option validation
4 files changed · +136 −8
docs/tools/exec-approvals.md+5 −2 modified@@ -131,17 +131,20 @@ Custom safe bins must define an explicit profile in `tools.exec.safeBinProfiles. Validation is deterministic from argv shape only (no host filesystem existence checks), which prevents file-existence oracle behavior from allow/deny differences. File-oriented options are denied for default safe bins (for example `sort -o`, `sort --output`, -`sort --files0-from`, `sort --compress-program`, `wc --files0-from`, `jq -f/--from-file`, +`sort --files0-from`, `sort --compress-program`, `sort --random-source`, +`sort --temporary-directory`/`-T`, `wc --files0-from`, `jq -f/--from-file`, `grep -f/--file`). Safe bins also enforce explicit per-binary flag policy for options that break stdin-only behavior (for example `sort -o/--output/--compress-program` and grep recursive flags). +Long options are validated fail-closed in safe-bin mode: unknown flags and ambiguous +abbreviations are rejected. Denied flags by safe-bin profile: <!-- SAFE_BIN_DENIED_FLAGS:START --> - `grep`: `--dereference-recursive`, `--directories`, `--exclude-from`, `--file`, `--recursive`, `-R`, `-d`, `-f`, `-r` - `jq`: `--argfile`, `--from-file`, `--library-path`, `--rawfile`, `--slurpfile`, `-L`, `-f` -- `sort`: `--compress-program`, `--files0-from`, `--output`, `-o` +- `sort`: `--compress-program`, `--files0-from`, `--output`, `--random-source`, `--temporary-directory`, `-T`, `-o` - `wc`: `--files0-from` <!-- SAFE_BIN_DENIED_FLAGS:END -->
src/infra/exec-approvals-safe-bins.test.ts+58 −0 modified@@ -81,6 +81,41 @@ describe("exec approvals safe bins", () => { takesValue: true, label: "blocks sort external program flag", }), + ...buildDeniedFlagVariantCases({ + executableName: "sort", + resolvedPath: "/usr/bin/sort", + flag: "--compress-prog", + takesValue: true, + label: "blocks sort denied flag abbreviations", + }), + ...buildDeniedFlagVariantCases({ + executableName: "sort", + resolvedPath: "/usr/bin/sort", + flag: "--files0-fro", + takesValue: true, + label: "blocks sort denied flag abbreviations", + }), + ...buildDeniedFlagVariantCases({ + executableName: "sort", + resolvedPath: "/usr/bin/sort", + flag: "--random-source", + takesValue: true, + label: "blocks sort filesystem-dependent flags", + }), + ...buildDeniedFlagVariantCases({ + executableName: "sort", + resolvedPath: "/usr/bin/sort", + flag: "--temporary-directory", + takesValue: true, + label: "blocks sort filesystem-dependent flags", + }), + ...buildDeniedFlagVariantCases({ + executableName: "sort", + resolvedPath: "/usr/bin/sort", + flag: "-T", + takesValue: true, + label: "blocks sort filesystem-dependent flags", + }), ...buildDeniedFlagVariantCases({ executableName: "grep", resolvedPath: "/usr/bin/grep", @@ -123,6 +158,13 @@ describe("exec approvals safe bins", () => { takesValue: true, label: "blocks wc file-list flag", }), + ...buildDeniedFlagVariantCases({ + executableName: "wc", + resolvedPath: "/usr/bin/wc", + flag: "--files0-fro", + takesValue: true, + label: "blocks wc denied flag abbreviations", + }), ]; const cases: SafeBinCase[] = [ @@ -163,6 +205,22 @@ describe("exec approvals safe bins", () => { safeBins: ["grep"], executableName: "grep", }, + { + name: "rejects unknown long options in safe-bin mode", + argv: ["sort", "--totally-unknown=1"], + resolvedPath: "/usr/bin/sort", + expected: false, + safeBins: ["sort"], + executableName: "sort", + }, + { + name: "rejects ambiguous long-option abbreviations in safe-bin mode", + argv: ["sort", "--f=1"], + resolvedPath: "/usr/bin/sort", + expected: false, + safeBins: ["sort"], + executableName: "sort", + }, ]; for (const testCase of cases) {
src/infra/exec-safe-bin-policy.test.ts+20 −0 modified@@ -48,12 +48,32 @@ describe("exec safe bin policy sort", () => { it("allows stdin-only sort flags", () => { expect(validateSafeBinArgv(["-S", "1M"], sortProfile)).toBe(true); expect(validateSafeBinArgv(["--key=1,1"], sortProfile)).toBe(true); + expect(validateSafeBinArgv(["--ke=1,1"], sortProfile)).toBe(true); }); it("blocks sort --compress-program in safe-bin mode", () => { expect(validateSafeBinArgv(["--compress-program=sh"], sortProfile)).toBe(false); expect(validateSafeBinArgv(["--compress-program", "sh"], sortProfile)).toBe(false); }); + + it("blocks denied long-option abbreviations in safe-bin mode", () => { + expect(validateSafeBinArgv(["--compress-prog=sh"], sortProfile)).toBe(false); + expect(validateSafeBinArgv(["--files0-fro=list.txt"], sortProfile)).toBe(false); + }); + + it("rejects unknown or ambiguous long options in safe-bin mode", () => { + expect(validateSafeBinArgv(["--totally-unknown=1"], sortProfile)).toBe(false); + expect(validateSafeBinArgv(["--f=1"], sortProfile)).toBe(false); + }); +}); + +describe("exec safe bin policy wc", () => { + const wcProfile = SAFE_BIN_PROFILES.wc; + + it("blocks wc --files0-from abbreviations in safe-bin mode", () => { + expect(validateSafeBinArgv(["--files0-fro=list.txt"], wcProfile)).toBe(false); + expect(validateSafeBinArgv(["--files0-fro", "list.txt"], wcProfile)).toBe(false); + }); }); describe("exec safe bin policy denied-flag matrix", () => {
src/infra/exec-safe-bin-policy.ts+53 −6 modified@@ -134,17 +134,23 @@ export const SAFE_BIN_PROFILE_FIXTURES: Record<string, SafeBinProfileFixture> = "--key", "--field-separator", "--buffer-size", - "--temporary-directory", "--parallel", "--batch-size", - "--random-source", "-k", "-t", "-S", - "-T", ], // --compress-program can invoke an external executable and breaks stdin-only guarantees. - deniedFlags: ["--compress-program", "--files0-from", "--output", "-o"], + // --random-source/--temporary-directory/-T are filesystem-dependent and not stdin-only. + deniedFlags: [ + "--compress-program", + "--files0-from", + "--output", + "--random-source", + "--temporary-directory", + "-T", + "-o", + ], }, uniq: { maxPositional: 0, @@ -293,6 +299,38 @@ function isInvalidValueToken(value: string | undefined): boolean { return !value || !isSafeLiteralToken(value); } +function collectKnownLongFlags( + allowedValueFlags: ReadonlySet<string>, + deniedFlags: ReadonlySet<string>, +): string[] { + const known = new Set<string>(); + for (const flag of allowedValueFlags) { + if (flag.startsWith("--")) { + known.add(flag); + } + } + for (const flag of deniedFlags) { + if (flag.startsWith("--")) { + known.add(flag); + } + } + return Array.from(known); +} + +function resolveCanonicalLongFlag(flag: string, knownLongFlags: string[]): string | null { + if (!flag.startsWith("--") || flag.length <= 2) { + return null; + } + if (knownLongFlags.includes(flag)) { + return flag; + } + const matches = knownLongFlags.filter((candidate) => candidate.startsWith(flag)); + if (matches.length !== 1) { + return null; + } + return matches[0] ?? null; +} + function consumeLongOptionToken( args: string[], index: number, @@ -301,13 +339,22 @@ function consumeLongOptionToken( allowedValueFlags: ReadonlySet<string>, deniedFlags: ReadonlySet<string>, ): number { - if (deniedFlags.has(flag)) { + const knownLongFlags = collectKnownLongFlags(allowedValueFlags, deniedFlags); + const canonicalFlag = resolveCanonicalLongFlag(flag, knownLongFlags); + if (!canonicalFlag) { + return -1; + } + if (deniedFlags.has(canonicalFlag)) { return -1; } + const expectsValue = allowedValueFlags.has(canonicalFlag); if (inlineValue !== undefined) { + if (!expectsValue) { + return -1; + } return isSafeLiteralToken(inlineValue) ? index + 1 : -1; } - if (!allowedValueFlags.has(flag)) { + if (!expectsValue) { return index + 1; } return isInvalidValueToken(args[index + 1]) ? -1 : index + 2;
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/3b8e33037ae2e12af7beb56fcf0346f1f8cbde6fghsapatchWEB
- github.com/advisories/GHSA-3c6h-g97w-fg78ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-3c6h-g97w-fg78ghsavendor-advisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-32059ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-allowlist-bypass-via-sort-long-option-abbreviation-in-toolsexecsafebinsghsathird-party-advisoryWEB
News mentions
0No linked articles in our index yet.