VYPR
Moderate severityNVD Advisory· Published Mar 19, 2026· Updated Mar 19, 2026

OpenClaw < 2026.2.23 - Allowlist Exec-Guard Bypass via env -S

CVE-2026-31992

Description

OpenClaw versions prior to 2026.2.23 contain an allowlist bypass vulnerability in system.run guardrails that allows authenticated operators to execute unintended commands. When /usr/bin/env is allowlisted, attackers can use env -S to bypass policy analysis and execute shell wrapper payloads at runtime.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.2.232026.2.23

Affected products

1

Patches

2
3f923e831364

test: add env -S allowlist bypass regressions

https://github.com/openclaw/openclawPeter SteinbergerFeb 24, 2026via ghsa
4 files changed · +50 2
  • CHANGELOG.md+1 1 modified
    @@ -16,7 +16,7 @@ Docs: https://docs.openclaw.ai
     - Security/Config writes: block reserved prototype keys in account-id normalization and route account config resolution through own-key lookups, hardening `/allowlist` and account-scoped config paths against prototype-chain pollution.
     - Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. Thanks @jiseoung.
     - Security/Channels: unify dangerous name-matching policy checks (`dangerouslyAllowNameMatching`) across core and extension channels, share mutable-allowlist detectors between `openclaw doctor` and `openclaw security audit`, and scan all configured accounts (not only the default account) in channel security audit findings.
    -- Security/Exec approvals: enforce canonical wrapper execution plans across allowlist analysis and runtime execution (node host + gateway host), fail closed on semantic `env` wrapper usage, and reject unknown short safe-bin flags to prevent `env -S/--split-string` interpretation-mismatch bypasses. This ships in the next npm release. Thanks @jiseoung for reporting.
    +- Security/Exec approvals: enforce canonical wrapper execution plans across allowlist analysis and runtime execution (node host + gateway host), fail closed on semantic `env` wrapper usage, and reject unknown short safe-bin flags to prevent `env -S/--split-string` interpretation-mismatch bypasses. This ships in the next npm release. Thanks @tdjackey for reporting.
     - Security/Image tool: enforce `tools.fs.workspaceOnly` for sandboxed `image` path resolution so mounted out-of-workspace paths are blocked before media bytes are loaded/sent to vision providers. This ships in the next npm release. Thanks @tdjackey for reporting.
     
     ## 2026.2.23 (Unreleased)
    
  • src/browser/server.agent-contract-snapshot-endpoints.test.ts+3 0 modified
    @@ -68,6 +68,9 @@ describe("browser control server", () => {
           cdpUrl: state.cdpBaseUrl,
           targetId: "abcd1234",
           url: "https://example.com",
    +      ssrfPolicy: {
    +        dangerouslyAllowPrivateNetwork: true,
    +      },
         });
     
         const click = await postJson<{ ok: boolean }>(`${base}/act`, {
    
  • src/infra/exec-approvals.test.ts+29 0 modified
    @@ -254,6 +254,35 @@ describe("exec approvals command resolution", () => {
         expect(resolution?.rawExecutable).toBe("/usr/bin/env");
       });
     
    +  it("fails closed for env -S even when env itself is allowlisted", () => {
    +    const dir = makeTempDir();
    +    const binDir = path.join(dir, "bin");
    +    fs.mkdirSync(binDir, { recursive: true });
    +    const envName = process.platform === "win32" ? "env.exe" : "env";
    +    const envPath = path.join(binDir, envName);
    +    fs.writeFileSync(envPath, process.platform === "win32" ? "" : "#!/bin/sh\n");
    +    if (process.platform !== "win32") {
    +      fs.chmodSync(envPath, 0o755);
    +    }
    +
    +    const analysis = analyzeArgvCommand({
    +      argv: [envPath, "-S", 'sh -c "echo pwned"'],
    +      cwd: dir,
    +      env: makePathEnv(binDir),
    +    });
    +    const allowlistEval = evaluateExecAllowlist({
    +      analysis,
    +      allowlist: [{ pattern: envPath }],
    +      safeBins: normalizeSafeBins([]),
    +      cwd: dir,
    +    });
    +
    +    expect(analysis.ok).toBe(true);
    +    expect(analysis.segments[0]?.resolution?.policyBlocked).toBe(true);
    +    expect(allowlistEval.allowlistSatisfied).toBe(false);
    +    expect(allowlistEval.segmentSatisfiedBy).toEqual([null]);
    +  });
    +
       it("unwraps env wrapper with shell inner executable", () => {
         const resolution = resolveCommandResolutionFromArgv(["/usr/bin/env", "bash", "-lc", "echo hi"]);
         expect(resolution?.rawExecutable).toBe("bash");
    
  • src/node-host/invoke-system-run.test.ts+17 1 modified
    @@ -150,7 +150,6 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
           }),
         );
       });
    -
       it("denies ./sh wrapper spoof in allowlist on-miss mode before execution", async () => {
         const marker = path.join(os.tmpdir(), `openclaw-wrapper-spoof-${process.pid}-${Date.now()}`);
         const runCommand = vi.fn(async () => {
    @@ -213,4 +212,21 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
           // no-op
         }
       });
    +
    +  it("denies env -S shell payloads in allowlist mode", async () => {
    +    const { runCommand, sendInvokeResult } = await runSystemInvoke({
    +      preferMacAppExecHost: false,
    +      security: "allowlist",
    +      command: ["env", "-S", 'sh -c "echo pwned"'],
    +    });
    +    expect(runCommand).not.toHaveBeenCalled();
    +    expect(sendInvokeResult).toHaveBeenCalledWith(
    +      expect.objectContaining({
    +        ok: false,
    +        error: expect.objectContaining({
    +          message: expect.stringContaining("allowlist miss"),
    +        }),
    +      }),
    +    );
    +  });
     });
    
a1c4bf07c6ba

fix(security): harden exec wrapper allowlist execution parity

https://github.com/openclaw/openclawPeter SteinbergerFeb 24, 2026via ghsa
12 files changed · +289 65
  • CHANGELOG.md+1 0 modified
    @@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai
     - Security/Config writes: block reserved prototype keys in account-id normalization and route account config resolution through own-key lookups, hardening `/allowlist` and account-scoped config paths against prototype-chain pollution.
     - Security/Exec: harden `safeBins` long-option validation by rejecting unknown/ambiguous GNU long-option abbreviations and denying sort filesystem-dependent flags (`--random-source`, `--temporary-directory`, `-T`), closing safe-bin denylist bypasses. Thanks @jiseoung.
     - Security/Channels: unify dangerous name-matching policy checks (`dangerouslyAllowNameMatching`) across core and extension channels, share mutable-allowlist detectors between `openclaw doctor` and `openclaw security audit`, and scan all configured accounts (not only the default account) in channel security audit findings.
    +- Security/Exec approvals: enforce canonical wrapper execution plans across allowlist analysis and runtime execution (node host + gateway host), fail closed on semantic `env` wrapper usage, and reject unknown short safe-bin flags to prevent `env -S/--split-string` interpretation-mismatch bypasses. This ships in the next npm release. Thanks @jiseoung for reporting.
     
     ## 2026.2.23 (Unreleased)
     
    
  • src/agents/bash-tools.exec-host-gateway.ts+15 39 modified
    @@ -4,8 +4,7 @@ import {
       addAllowlistEntry,
       type ExecAsk,
       type ExecSecurity,
    -  buildSafeBinsShellCommand,
    -  buildSafeShellCommand,
    +  buildEnforcedShellCommand,
       evaluateShellAllowlist,
       maxAsk,
       minSecurity,
    @@ -83,6 +82,18 @@ export async function processGatewayAllowlist(
       const analysisOk = allowlistEval.analysisOk;
       const allowlistSatisfied =
         hostSecurity === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false;
    +  let enforcedCommand: string | undefined;
    +  if (hostSecurity === "allowlist" && analysisOk && allowlistSatisfied) {
    +    const enforced = buildEnforcedShellCommand({
    +      command: params.command,
    +      segments: allowlistEval.segments,
    +      platform: process.platform,
    +    });
    +    if (!enforced.ok || !enforced.command) {
    +      throw new Error(`exec denied: allowlist execution plan unavailable (${enforced.reason})`);
    +    }
    +    enforcedCommand = enforced.command;
    +  }
       const obfuscation = detectCommandObfuscation(params.command);
       if (obfuscation.detected) {
         logInfo(`exec: obfuscation detected (gateway): ${obfuscation.reasons.join(", ")}`);
    @@ -216,6 +227,7 @@ export async function processGatewayAllowlist(
           try {
             run = await runExecProcess({
               command: params.command,
    +          execCommand: enforcedCommand,
               workdir: params.workdir,
               env: params.env,
               sandbox: undefined,
    @@ -294,43 +306,7 @@ export async function processGatewayAllowlist(
         throw new Error("exec denied: allowlist miss");
       }
     
    -  let execCommandOverride: string | undefined;
    -  // If allowlist uses safeBins, sanitize only those stdin-only segments:
    -  // disable glob/var expansion by forcing argv tokens to be literal via single-quoting.
    -  if (
    -    hostSecurity === "allowlist" &&
    -    analysisOk &&
    -    allowlistSatisfied &&
    -    allowlistEval.segmentSatisfiedBy.some((by) => by === "safeBins")
    -  ) {
    -    const safe = buildSafeBinsShellCommand({
    -      command: params.command,
    -      segments: allowlistEval.segments,
    -      segmentSatisfiedBy: allowlistEval.segmentSatisfiedBy,
    -      platform: process.platform,
    -    });
    -    if (!safe.ok || !safe.command) {
    -      // Fallback: quote everything (safe, but may change glob behavior).
    -      const fallback = buildSafeShellCommand({
    -        command: params.command,
    -        platform: process.platform,
    -      });
    -      if (!fallback.ok || !fallback.command) {
    -        throw new Error(`exec denied: safeBins sanitize failed (${safe.reason ?? "unknown"})`);
    -      }
    -      params.warnings.push(
    -        "Warning: safeBins hardening used fallback quoting due to parser mismatch.",
    -      );
    -      execCommandOverride = fallback.command;
    -    } else {
    -      params.warnings.push(
    -        "Warning: safeBins hardening disabled glob/variable expansion for stdin-only segments.",
    -      );
    -      execCommandOverride = safe.command;
    -    }
    -  }
    -
       recordMatchedAllowlistUse(allowlistEval.segments[0]?.resolution?.resolvedPath);
     
    -  return { execCommandOverride };
    +  return { execCommandOverride: enforcedCommand };
     }
    
  • src/infra/exec-approvals-allowlist.ts+9 1 modified
    @@ -122,6 +122,14 @@ function evaluateSegments(
       const segmentSatisfiedBy: ExecSegmentSatisfiedBy[] = [];
     
       const satisfied = segments.every((segment) => {
    +    if (segment.resolution?.policyBlocked === true) {
    +      segmentSatisfiedBy.push(null);
    +      return false;
    +    }
    +    const effectiveArgv =
    +      segment.resolution?.effectiveArgv && segment.resolution.effectiveArgv.length > 0
    +        ? segment.resolution.effectiveArgv
    +        : segment.argv;
         const candidatePath = resolveAllowlistCandidatePath(segment.resolution, params.cwd);
         const candidateResolution =
           candidatePath && segment.resolution
    @@ -132,7 +140,7 @@ function evaluateSegments(
           matches.push(match);
         }
         const safe = isSafeBinUsage({
    -      argv: segment.argv,
    +      argv: effectiveArgv,
           resolution: segment.resolution,
           safeBins: params.safeBins,
           safeBinProfiles: params.safeBinProfiles,
    
  • src/infra/exec-approvals-analysis.ts+60 6 modified
    @@ -626,12 +626,30 @@ function renderQuotedArgv(argv: string[]): string {
       return argv.map((token) => shellEscapeSingleArg(token)).join(" ");
     }
     
    -function renderSafeBinSegmentArgv(segment: ExecCommandSegment): string {
    -  if (segment.argv.length === 0) {
    -    return "";
    +function resolvePlannedSegmentArgv(segment: ExecCommandSegment): string[] | null {
    +  if (segment.resolution?.policyBlocked === true) {
    +    return null;
    +  }
    +  const baseArgv =
    +    segment.resolution?.effectiveArgv && segment.resolution.effectiveArgv.length > 0
    +      ? segment.resolution.effectiveArgv
    +      : segment.argv;
    +  if (baseArgv.length === 0) {
    +    return null;
    +  }
    +  const argv = [...baseArgv];
    +  const resolvedExecutable = segment.resolution?.resolvedPath?.trim() ?? "";
    +  if (resolvedExecutable) {
    +    argv[0] = resolvedExecutable;
    +  }
    +  return argv;
    +}
    +
    +function renderSafeBinSegmentArgv(segment: ExecCommandSegment): string | null {
    +  const argv = resolvePlannedSegmentArgv(segment);
    +  if (!argv || argv.length === 0) {
    +    return null;
       }
    -  const resolvedExecutable = segment.resolution?.resolvedPath?.trim();
    -  const argv = resolvedExecutable ? [resolvedExecutable, ...segment.argv.slice(1)] : segment.argv;
       return renderQuotedArgv(argv);
     }
     
    @@ -659,7 +677,43 @@ export function buildSafeBinsShellCommand(params: {
             return { ok: false, reason: "segment mapping failed" };
           }
           const needsLiteral = by === "safeBins";
    -      return { ok: true, rendered: needsLiteral ? renderSafeBinSegmentArgv(seg) : raw.trim() };
    +      if (!needsLiteral) {
    +        return { ok: true, rendered: raw.trim() };
    +      }
    +      const rendered = renderSafeBinSegmentArgv(seg);
    +      if (!rendered) {
    +        return { ok: false, reason: "segment execution plan unavailable" };
    +      }
    +      return { ok: true, rendered };
    +    },
    +  });
    +  if (!rebuilt.ok) {
    +    return { ok: false, reason: rebuilt.reason };
    +  }
    +  if (rebuilt.segmentCount !== params.segments.length) {
    +    return { ok: false, reason: "segment count mismatch" };
    +  }
    +  return { ok: true, command: rebuilt.command };
    +}
    +
    +export function buildEnforcedShellCommand(params: {
    +  command: string;
    +  segments: ExecCommandSegment[];
    +  platform?: string | null;
    +}): { ok: boolean; command?: string; reason?: string } {
    +  const rebuilt = rebuildShellCommandFromSource({
    +    command: params.command,
    +    platform: params.platform,
    +    renderSegment: (_raw, segmentIndex) => {
    +      const seg = params.segments[segmentIndex];
    +      if (!seg) {
    +        return { ok: false, reason: "segment mapping failed" };
    +      }
    +      const argv = resolvePlannedSegmentArgv(seg);
    +      if (!argv) {
    +        return { ok: false, reason: "segment execution plan unavailable" };
    +      }
    +      return { ok: true, rendered: renderQuotedArgv(argv) };
         },
       });
       if (!rebuilt.ok) {
    
  • src/infra/exec-approvals-safe-bins.test.ts+25 0 modified
    @@ -221,6 +221,14 @@ describe("exec approvals safe bins", () => {
           safeBins: ["sort"],
           executableName: "sort",
         },
    +    {
    +      name: "rejects unknown short options in safe-bin mode",
    +      argv: ["tr", "-S", "a", "b"],
    +      resolvedPath: "/usr/bin/tr",
    +      expected: false,
    +      safeBins: ["tr"],
    +      executableName: "tr",
    +    },
       ];
     
       for (const testCase of cases) {
    @@ -464,4 +472,21 @@ describe("exec approvals safe bins", () => {
         expect(result.segmentSatisfiedBy).toEqual([null]);
         expect(result.segments[0]?.resolution?.resolvedPath).toBe(fakeHead);
       });
    +
    +  it("fails closed for semantic env wrappers in allowlist mode", () => {
    +    if (process.platform === "win32") {
    +      return;
    +    }
    +    const result = evaluateShellAllowlist({
    +      command: "env -S 'sh -c \"echo pwned\"' tr",
    +      allowlist: [{ pattern: "/usr/bin/tr" }],
    +      safeBins: normalizeSafeBins(["tr"]),
    +      cwd: "/tmp",
    +      platform: process.platform,
    +    });
    +    expect(result.analysisOk).toBe(true);
    +    expect(result.allowlistSatisfied).toBe(false);
    +    expect(result.segmentSatisfiedBy).toEqual([null]);
    +    expect(result.segments[0]?.resolution?.policyBlocked).toBe(true);
    +  });
     });
    
  • src/infra/exec-approvals.test.ts+36 2 modified
    @@ -5,6 +5,7 @@ import { makePathEnv, makeTempDir } from "./exec-approvals-test-helpers.js";
     import {
       analyzeArgvCommand,
       analyzeShellCommand,
    +  buildEnforcedShellCommand,
       buildSafeBinsShellCommand,
       evaluateExecAllowlist,
       evaluateShellAllowlist,
    @@ -130,6 +131,27 @@ describe("exec approvals safe shell command builder", () => {
         // SafeBins segment is fully quoted and pinned to its resolved absolute path.
         expect(res.command).toMatch(/'[^']*\/head' '-n' '5'/);
       });
    +
    +  it("enforces canonical planned argv for every approved segment", () => {
    +    if (process.platform === "win32") {
    +      return;
    +    }
    +    const analysis = analyzeShellCommand({
    +      command: "env rg -n needle",
    +      cwd: "/tmp",
    +      env: { PATH: "/usr/bin:/bin" },
    +      platform: process.platform,
    +    });
    +    expect(analysis.ok).toBe(true);
    +    const res = buildEnforcedShellCommand({
    +      command: "env rg -n needle",
    +      segments: analysis.segments,
    +      platform: process.platform,
    +    });
    +    expect(res.ok).toBe(true);
    +    expect(res.command).toMatch(/'(?:[^']*\/)?rg' '-n' 'needle'/);
    +    expect(res.command).not.toContain("'env'");
    +  });
     });
     
     describe("exec approvals command resolution", () => {
    @@ -202,7 +224,7 @@ describe("exec approvals command resolution", () => {
         }
       });
     
    -  it("unwraps env wrapper argv to resolve the effective executable", () => {
    +  it("unwraps transparent env wrapper argv to resolve the effective executable", () => {
         const dir = makeTempDir();
         const binDir = path.join(dir, "bin");
         fs.mkdirSync(binDir, { recursive: true });
    @@ -212,14 +234,26 @@ describe("exec approvals command resolution", () => {
         fs.chmodSync(exe, 0o755);
     
         const resolution = resolveCommandResolutionFromArgv(
    -      ["/usr/bin/env", "FOO=bar", "rg", "-n", "needle"],
    +      ["/usr/bin/env", "rg", "-n", "needle"],
           undefined,
           makePathEnv(binDir),
         );
         expect(resolution?.resolvedPath).toBe(exe);
         expect(resolution?.executableName).toBe(exeName);
       });
     
    +  it("blocks semantic env wrappers from allowlist/safeBins auto-resolution", () => {
    +    const resolution = resolveCommandResolutionFromArgv([
    +      "/usr/bin/env",
    +      "FOO=bar",
    +      "rg",
    +      "-n",
    +      "needle",
    +    ]);
    +    expect(resolution?.policyBlocked).toBe(true);
    +    expect(resolution?.rawExecutable).toBe("/usr/bin/env");
    +  });
    +
       it("unwraps env wrapper with shell inner executable", () => {
         const resolution = resolveCommandResolutionFromArgv(["/usr/bin/env", "bash", "-lc", "echo hi"]);
         expect(resolution?.rawExecutable).toBe("bash");
    
  • src/infra/exec-command-resolution.ts+24 4 modified
    @@ -1,7 +1,7 @@
     import fs from "node:fs";
     import path from "node:path";
     import type { ExecAllowlistEntry } from "./exec-approvals.js";
    -import { unwrapDispatchWrappersForResolution } from "./exec-wrapper-resolution.js";
    +import { resolveDispatchWrapperExecutionPlan } from "./exec-wrapper-resolution.js";
     import { expandHomePrefix } from "./home-dir.js";
     
     export const DEFAULT_SAFE_BINS = ["jq", "cut", "uniq", "head", "tail", "tr", "wc"];
    @@ -10,6 +10,10 @@ export type CommandResolution = {
       rawExecutable: string;
       resolvedPath?: string;
       executableName: string;
    +  effectiveArgv?: string[];
    +  wrapperChain?: string[];
    +  policyBlocked?: boolean;
    +  blockedWrapper?: string;
     };
     
     function isExecutableFile(filePath: string): boolean {
    @@ -93,22 +97,38 @@ export function resolveCommandResolution(
       }
       const resolvedPath = resolveExecutablePath(rawExecutable, cwd, env);
       const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable;
    -  return { rawExecutable, resolvedPath, executableName };
    +  return {
    +    rawExecutable,
    +    resolvedPath,
    +    executableName,
    +    effectiveArgv: [rawExecutable],
    +    wrapperChain: [],
    +    policyBlocked: false,
    +  };
     }
     
     export function resolveCommandResolutionFromArgv(
       argv: string[],
       cwd?: string,
       env?: NodeJS.ProcessEnv,
     ): CommandResolution | null {
    -  const effectiveArgv = unwrapDispatchWrappersForResolution(argv);
    +  const plan = resolveDispatchWrapperExecutionPlan(argv);
    +  const effectiveArgv = plan.argv;
       const rawExecutable = effectiveArgv[0]?.trim();
       if (!rawExecutable) {
         return null;
       }
       const resolvedPath = resolveExecutablePath(rawExecutable, cwd, env);
       const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable;
    -  return { rawExecutable, resolvedPath, executableName };
    +  return {
    +    rawExecutable,
    +    resolvedPath,
    +    executableName,
    +    effectiveArgv,
    +    wrapperChain: plan.wrappers,
    +    policyBlocked: plan.policyBlocked,
    +    blockedWrapper: plan.blockedWrapper,
    +  };
     }
     
     function normalizeMatchTarget(value: string): string {
    
  • src/infra/exec-safe-bin-policy.ts+2 2 modified
    @@ -363,7 +363,7 @@ function consumeLongOptionToken(
     function consumeShortOptionClusterToken(
       args: string[],
       index: number,
    -  raw: string,
    +  _raw: string,
       cluster: string,
       flags: string[],
       allowedValueFlags: ReadonlySet<string>,
    @@ -383,7 +383,7 @@ function consumeShortOptionClusterToken(
         }
         return isInvalidValueToken(args[index + 1]) ? -1 : index + 2;
       }
    -  return hasGlobToken(raw) ? -1 : index + 1;
    +  return -1;
     }
     
     function consumePositionalToken(token: string, positional: string[]): boolean {
    
  • src/infra/exec-wrapper-resolution.ts+42 1 modified
    @@ -79,6 +79,7 @@ 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"]);
    +const TRANSPARENT_DISPATCH_WRAPPERS = new Set(["nice", "nohup", "stdbuf", "timeout"]);
     
     type ShellWrapperKind = "posix" | "cmd" | "powershell";
     
    @@ -348,6 +349,13 @@ export type DispatchWrapperUnwrapResult =
       | { kind: "blocked"; wrapper: string }
       | { kind: "unwrapped"; wrapper: string; argv: string[] };
     
    +export type DispatchWrapperExecutionPlan = {
    +  argv: string[];
    +  wrappers: string[];
    +  policyBlocked: boolean;
    +  blockedWrapper?: string;
    +};
    +
     function blockDispatchWrapper(wrapper: string): DispatchWrapperUnwrapResult {
       return { kind: "blocked", wrapper };
     }
    @@ -394,15 +402,48 @@ export function unwrapDispatchWrappersForResolution(
       argv: string[],
       maxDepth = MAX_DISPATCH_WRAPPER_DEPTH,
     ): string[] {
    +  const plan = resolveDispatchWrapperExecutionPlan(argv, maxDepth);
    +  return plan.argv;
    +}
    +
    +function isSemanticDispatchWrapperUsage(wrapper: string, argv: string[]): boolean {
    +  if (wrapper === "env") {
    +    return envInvocationUsesModifiers(argv);
    +  }
    +  return !TRANSPARENT_DISPATCH_WRAPPERS.has(wrapper);
    +}
    +
    +export function resolveDispatchWrapperExecutionPlan(
    +  argv: string[],
    +  maxDepth = MAX_DISPATCH_WRAPPER_DEPTH,
    +): DispatchWrapperExecutionPlan {
       let current = argv;
    +  const wrappers: string[] = [];
       for (let depth = 0; depth < maxDepth; depth += 1) {
         const unwrap = unwrapKnownDispatchWrapperInvocation(current);
    +    if (unwrap.kind === "blocked") {
    +      return {
    +        argv: current,
    +        wrappers,
    +        policyBlocked: true,
    +        blockedWrapper: unwrap.wrapper,
    +      };
    +    }
         if (unwrap.kind !== "unwrapped" || unwrap.argv.length === 0) {
           break;
         }
    +    wrappers.push(unwrap.wrapper);
    +    if (isSemanticDispatchWrapperUsage(unwrap.wrapper, current)) {
    +      return {
    +        argv: current,
    +        wrappers,
    +        policyBlocked: true,
    +        blockedWrapper: unwrap.wrapper,
    +      };
    +    }
         current = unwrap.argv;
       }
    -  return current;
    +  return { argv: current, wrappers, policyBlocked: false };
     }
     
     function hasEnvManipulationBeforeShellWrapperInternal(
    
  • src/node-host/invoke-system-run.test.ts+39 4 modified
    @@ -20,6 +20,10 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
       async function runSystemInvoke(params: {
         preferMacAppExecHost: boolean;
         runViaResponse?: ExecHostResponse | null;
    +    command?: string[];
    +    security?: "full" | "allowlist";
    +    ask?: "off" | "on-miss" | "always";
    +    approved?: boolean;
       }) {
         const runCommand = vi.fn(async () => ({
           success: true,
    @@ -37,17 +41,17 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
         await handleSystemRunInvoke({
           client: {} as never,
           params: {
    -        command: ["echo", "ok"],
    -        approved: true,
    +        command: params.command ?? ["echo", "ok"],
    +        approved: params.approved ?? false,
             sessionKey: "agent:main:main",
           },
           skillBins: {
             current: async () => new Set<string>(),
           },
           execHostEnforced: false,
           execHostFallbackAllowed: true,
    -      resolveExecSecurity: () => "full",
    -      resolveExecAsk: () => "off",
    +      resolveExecSecurity: () => params.security ?? "full",
    +      resolveExecAsk: () => params.ask ?? "off",
           isCmdExeInvocation: () => false,
           sanitizeEnv: () => undefined,
           runCommand,
    @@ -112,4 +116,35 @@ describe("handleSystemRunInvoke mac app exec host routing", () => {
           }),
         );
       });
    +
    +  it("runs canonical argv in allowlist mode for transparent env wrappers", async () => {
    +    const { runCommand, sendInvokeResult } = await runSystemInvoke({
    +      preferMacAppExecHost: false,
    +      security: "allowlist",
    +      command: ["env", "tr", "a", "b"],
    +    });
    +    expect(runCommand).toHaveBeenCalledWith(["tr", "a", "b"], undefined, undefined, undefined);
    +    expect(sendInvokeResult).toHaveBeenCalledWith(
    +      expect.objectContaining({
    +        ok: true,
    +      }),
    +    );
    +  });
    +
    +  it("denies semantic env wrappers in allowlist mode", async () => {
    +    const { runCommand, sendInvokeResult } = await runSystemInvoke({
    +      preferMacAppExecHost: false,
    +      security: "allowlist",
    +      command: ["env", "FOO=bar", "tr", "a", "b"],
    +    });
    +    expect(runCommand).not.toHaveBeenCalled();
    +    expect(sendInvokeResult).toHaveBeenCalledWith(
    +      expect.objectContaining({
    +        ok: false,
    +        error: expect.objectContaining({
    +          message: expect.stringContaining("allowlist miss"),
    +        }),
    +      }),
    +    );
    +  });
     });
    
  • src/node-host/invoke-system-run.ts+32 2 modified
    @@ -198,10 +198,40 @@ export async function handleSystemRunInvoke(opts: {
         return;
       }
     
    +  let plannedAllowlistArgv: string[] | undefined;
    +  if (
    +    security === "allowlist" &&
    +    !policy.approvedByAsk &&
    +    !shellCommand &&
    +    policy.analysisOk &&
    +    policy.allowlistSatisfied &&
    +    segments.length === 1
    +  ) {
    +    plannedAllowlistArgv = segments[0]?.resolution?.effectiveArgv;
    +    if (!plannedAllowlistArgv || plannedAllowlistArgv.length === 0) {
    +      await opts.sendNodeEvent(
    +        opts.client,
    +        "exec.denied",
    +        opts.buildExecEventPayload({
    +          sessionKey,
    +          runId,
    +          host: "node",
    +          command: cmdText,
    +          reason: "execution-plan-miss",
    +        }),
    +      );
    +      await opts.sendInvokeResult({
    +        ok: false,
    +        error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: execution plan mismatch" },
    +      });
    +      return;
    +    }
    +  }
    +
       const useMacAppExec = opts.preferMacAppExecHost;
       if (useMacAppExec) {
         const execRequest: ExecHostRequest = {
    -      command: argv,
    +      command: plannedAllowlistArgv ?? argv,
           rawCommand: rawCommand || shellCommand || null,
           cwd: opts.params.cwd ?? null,
           env: envOverrides ?? null,
    @@ -315,7 +345,7 @@ export async function handleSystemRunInvoke(opts: {
         return;
       }
     
    -  let execArgv = argv;
    +  let execArgv = plannedAllowlistArgv ?? argv;
       if (
         security === "allowlist" &&
         isWindows &&
    
  • test/fixtures/exec-wrapper-resolution-parity.json+4 4 modified
    @@ -8,22 +8,22 @@
         {
           "id": "env-assignment-prefix",
           "argv": ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"],
    -      "expectedRawExecutable": "/usr/bin/printf"
    +      "expectedRawExecutable": "/usr/bin/env"
         },
         {
           "id": "env-option-with-separate-value",
           "argv": ["/usr/bin/env", "-u", "HOME", "/usr/bin/printf", "ok"],
    -      "expectedRawExecutable": "/usr/bin/printf"
    +      "expectedRawExecutable": "/usr/bin/env"
         },
         {
           "id": "env-option-with-inline-value",
           "argv": ["/usr/bin/env", "-uHOME", "/usr/bin/printf", "ok"],
    -      "expectedRawExecutable": "/usr/bin/printf"
    +      "expectedRawExecutable": "/usr/bin/env"
         },
         {
           "id": "nested-env-wrappers",
           "argv": ["/usr/bin/env", "/usr/bin/env", "FOO=bar", "printf", "ok"],
    -      "expectedRawExecutable": "printf"
    +      "expectedRawExecutable": "/usr/bin/env"
         },
         {
           "id": "env-shell-wrapper-stops-at-shell",
    

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

6

News mentions

0

No linked articles in our index yet.