diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bd89866034..6ce2a78cf43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/agents/bash-tools.exec-host-gateway.ts b/src/agents/bash-tools.exec-host-gateway.ts index e212a266933..be81e703e13 100644 --- a/src/agents/bash-tools.exec-host-gateway.ts +++ b/src/agents/bash-tools.exec-host-gateway.ts @@ -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 }; } diff --git a/src/infra/exec-approvals-allowlist.ts b/src/infra/exec-approvals-allowlist.ts index 3fd4c628b8c..ba321b609c7 100644 --- a/src/infra/exec-approvals-allowlist.ts +++ b/src/infra/exec-approvals-allowlist.ts @@ -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, diff --git a/src/infra/exec-approvals-analysis.ts b/src/infra/exec-approvals-analysis.ts index 9b187977c4e..8d2fe38c973 100644 --- a/src/infra/exec-approvals-analysis.ts +++ b/src/infra/exec-approvals-analysis.ts @@ -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) { diff --git a/src/infra/exec-approvals-safe-bins.test.ts b/src/infra/exec-approvals-safe-bins.test.ts index 7f1b3dec746..b24b24a81f8 100644 --- a/src/infra/exec-approvals-safe-bins.test.ts +++ b/src/infra/exec-approvals-safe-bins.test.ts @@ -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); + }); }); diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index aafe0a4774b..60337d2c098 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -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,7 +234,7 @@ 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), ); @@ -220,6 +242,18 @@ describe("exec approvals command resolution", () => { 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"); diff --git a/src/infra/exec-command-resolution.ts b/src/infra/exec-command-resolution.ts index 5a6b3fc7563..3dceb0fc598 100644 --- a/src/infra/exec-command-resolution.ts +++ b/src/infra/exec-command-resolution.ts @@ -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,7 +97,14 @@ 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( @@ -101,14 +112,23 @@ export function resolveCommandResolutionFromArgv( 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 { diff --git a/src/infra/exec-safe-bin-policy.ts b/src/infra/exec-safe-bin-policy.ts index 35ba2e1723a..d726bb55a10 100644 --- a/src/infra/exec-safe-bin-policy.ts +++ b/src/infra/exec-safe-bin-policy.ts @@ -363,7 +363,7 @@ function consumeLongOptionToken( function consumeShortOptionClusterToken( args: string[], index: number, - raw: string, + _raw: string, cluster: string, flags: string[], allowedValueFlags: ReadonlySet, @@ -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 { diff --git a/src/infra/exec-wrapper-resolution.ts b/src/infra/exec-wrapper-resolution.ts index 1c31d3713d4..58fc18b0015 100644 --- a/src/infra/exec-wrapper-resolution.ts +++ b/src/infra/exec-wrapper-resolution.ts @@ -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( diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index a3d20c792f3..16c5a46ea5d 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -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,8 +41,8 @@ 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: { @@ -46,8 +50,8 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { }, 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"), + }), + }), + ); + }); }); diff --git a/src/node-host/invoke-system-run.ts b/src/node-host/invoke-system-run.ts index eca2af4ecae..8ce09c8ec68 100644 --- a/src/node-host/invoke-system-run.ts +++ b/src/node-host/invoke-system-run.ts @@ -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 && diff --git a/test/fixtures/exec-wrapper-resolution-parity.json b/test/fixtures/exec-wrapper-resolution-parity.json index 096f91763b1..ef4e2174785 100644 --- a/test/fixtures/exec-wrapper-resolution-parity.json +++ b/test/fixtures/exec-wrapper-resolution-parity.json @@ -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",