From 5784e146deb1e6fdc0852eb0fcb777841f453ae8 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:19:13 -0700 Subject: [PATCH] feat(wsl): internal distro allowlist that keeps WSL execs out of other distros on shared test boxes (#34539) * feat(wsl): internal distro allowlist for WSL execs on shared test boxes ORCA_INTERNAL_WSL_DISTRO_ALLOWLIST (comma-separated distro names) keeps every wsl.exe call out of distros not on the list: process-host's resolveSpawn, the main and daemon node-pty spawns, and the two codex-accounts calls that bypass process-host. Pure list/status/version calls stay allowed; any other form must name an allowlisted distro, and --shutdown or a default-distro exec is refused. A malformed value refuses every distro and logs once. Unset, the check reads the variable and returns, so behavior is identical to today. * fix(wsl): cover every direct wsl.exe exec with the internal distro allowlist - Restore #34525's worktree-create payload and captured-host test, reverted by accident in the first commit. - Check the allowlist in execFileCapture, the choke point for WSL Git, gh and worker-thread captures, and before the WSL git-read probe, the Codex hook path canonicalization and the CLI login spawn. The WSL watcher now spawns through process-host. - Stop scanning wsl.exe argv at the first positional argument, which wsl.exe runs in the default distro. - check-process-host-imports fails on any direct wsl.exe exec outside process-host that does not call assertWslDistroAllowed. * fix(wsl): check the distro allowlist where resolveCommand produces the wsl.exe form Every Git, gh and glab path into a distro resolves through resolveCommand, so gitExecFileSync, gitSpawn, git stream reads and wslAwareSpawn are covered too; the execFileCapture check is now redundant and removed. The import check also treats buildWslExecArgs as a wsl.exe exec and accepts resolveCommand as covered. --- .../process-host-direct-import-baseline.txt | 1 - config/scripts/check-process-host-imports.mjs | 13 ++ .../check-process-host-imports.test.mjs | 34 +++++ .../scripts/orcad-windows-prebuild-cache.mjs | 3 +- src/cli/handlers/account.ts | 7 + src/main/codex-accounts/service.ts | 27 ++-- src/main/codex/codex-wsl-hook-install-plan.ts | 61 ++++---- .../native-pty-spawn-windows.test.ts | 35 +++++ .../daemon/pty-subprocess/native-pty-spawn.ts | 2 + .../wsl-command-resolution-allowlist.test.ts | 85 +++++++++++ .../command-runner/wsl-command-resolution.ts | 3 + src/main/git/wsl-git-read-environment.ts | 10 +- .../watchers/filesystem-watcher-wsl.test.ts | 21 ++- .../watchers/filesystem-watcher-wsl.ts | 13 +- .../filesystem/watchers/wsl-watcher-spawn.ts | 14 ++ ...al-pty-runtime-spawn-wsl-allowlist.test.ts | 55 +++++++ src/main/providers/local-pty-runtime-spawn.ts | 7 +- src/main/wsl.test.ts | 25 ++++ src/packages/process-host/package.json | 5 + .../process-host/src/spawn-resolution.ts | 2 + .../src/wsl-distro-allowlist.test.ts | 140 ++++++++++++++++++ .../process-host/src/wsl-distro-allowlist.ts | 130 ++++++++++++++++ 22 files changed, 632 insertions(+), 61 deletions(-) create mode 100644 src/main/git/command-runner/wsl-command-resolution-allowlist.test.ts create mode 100644 src/main/ipc/filesystem/watchers/wsl-watcher-spawn.ts create mode 100644 src/main/providers/local-pty-runtime-spawn-wsl-allowlist.test.ts create mode 100644 src/packages/process-host/src/wsl-distro-allowlist.test.ts create mode 100644 src/packages/process-host/src/wsl-distro-allowlist.ts diff --git a/config/process-host-direct-import-baseline.txt b/config/process-host-direct-import-baseline.txt index a26390615aa..4e2f7d8c225 100644 --- a/config/process-host-direct-import-baseline.txt +++ b/config/process-host-direct-import-baseline.txt @@ -65,7 +65,6 @@ src/main/ipc/filesystem/watcher-process/parcel-watcher-process-supervisor.ts src/main/ipc/filesystem/watcher-process/parcel-watcher-supervisor-disposal.ts src/main/ipc/filesystem/watcher-process/parcel-watcher-supervisor-message.ts src/main/ipc/filesystem/watcher-process/parcel-watcher-supervisor-subscribe.ts -src/main/ipc/filesystem/watchers/filesystem-watcher-wsl.ts src/main/ipc/filesystem/watchers/wsl-watcher-process-exit.ts src/main/ipc/local-network-connection-test.ts src/main/ipc/macos-keyboard-input-source.ts diff --git a/config/scripts/check-process-host-imports.mjs b/config/scripts/check-process-host-imports.mjs index 968647554fc..47010e37106 100644 --- a/config/scripts/check-process-host-imports.mjs +++ b/config/scripts/check-process-host-imports.mjs @@ -11,6 +11,11 @@ const PACKAGE_NAME = '@orca/process-host' const PACKAGE_DIRECTORY = 'src/packages/process-host' const PACKAGE_SOURCE = `${PACKAGE_DIRECTORY}/src/` const BASELINE_PATH = 'config/process-host-direct-import-baseline.txt' +// A direct wsl.exe exec bypasses process-host's internal distro allowlist unless it is checked: +// by calling the check itself, or by taking its program from resolveCommand, which checks centrally. +const WSL_EXE_PROGRAM = /['"`]wsl\.exe['"`]|resolveWslExecutablePath\(|buildWslExecArgs\(/ +const WSL_ALLOWLIST_CHECK = + /assertWslDistroAllowed\(|\bresolve(Git|DefaultWsl)?Command\(|resolveDefaultWslCli\(/ const SOURCE_PATTERNS = [ '*.{ts,tsx,mts,cts,js,mjs,cjs}', ...['src', 'config', 'tests', 'mobile/src', 'mobile/app', 'mobile/scripts'].map( @@ -103,6 +108,14 @@ export function assessProcessHostImports( } } } + for (const file of direct) { + const contents = sources.get(file) ?? '' + if (WSL_EXE_PROGRAM.test(contents) && !WSL_ALLOWLIST_CHECK.test(contents)) { + violations.push( + `${file}: direct wsl.exe exec must call assertWslDistroAllowed or resolve through resolveCommand` + ) + } + } return { direct: [...direct].sort(), ...diffEntries(direct, baseline), diff --git a/config/scripts/check-process-host-imports.test.mjs b/config/scripts/check-process-host-imports.test.mjs index 1ae8607ee7b..0b9818b6e68 100644 --- a/config/scripts/check-process-host-imports.test.mjs +++ b/config/scripts/check-process-host-imports.test.mjs @@ -191,6 +191,40 @@ describe('process-host import boundary', () => { }) }) + it('requires the distro allowlist check, or resolveCommand, before any direct wsl.exe exec outside process-host', () => { + const exec = "import { execFile } from 'node:child_process'\n" + expect( + assess( + { + 'src/main/unguarded.ts': `${exec}execFile('wsl.exe', ['-d', distro])`, + 'src/main/resolved.ts': `${exec}execFile(resolveWslExecutablePath(), args)`, + 'src/main/guarded.ts': `${exec}assertWslDistroAllowed('wsl.exe', args)\nexecFile('wsl.exe', args)`, + 'src/main/unrelated.ts': `${exec}execFile('git', ['status'])`, + 'src/main/built.ts': `${exec}execFile(program, buildWslExecArgs(distro, ['sh']))`, + 'src/main/resolved-git.ts': `${exec}const r = resolveGitCommand(args, o)\nspawn(r.binary, r.args)`, + 'src/main/comment-only.ts': + "import type { ChildProcess } from 'node:child_process'\n// wsl.exe pads output", + 'src/main/caller.test.ts': `${exec}execFile('wsl.exe', [])`, + 'config/scripts/tool.mjs': `${exec}execFile('wsl.exe', [])` + }, + [ + 'src/main/unguarded.ts', + 'src/main/resolved.ts', + 'src/main/guarded.ts', + 'src/main/unrelated.ts', + 'src/main/built.ts', + 'src/main/resolved-git.ts', + 'src/main/comment-only.ts' + ] + ).violations + ).toEqual( + ['src/main/built.ts', 'src/main/resolved.ts', 'src/main/unguarded.ts'].map( + (file) => + `${file}: direct wsl.exe exec must call assertWslDistroAllowed or resolve through resolveCommand` + ) + ) + }) + it('scans nested package, consumer, test, config, and mobile source without generated checkouts', () => { const files = [ `${PACKAGE_SOURCE}nested/runner.ts`, diff --git a/config/scripts/orcad-windows-prebuild-cache.mjs b/config/scripts/orcad-windows-prebuild-cache.mjs index 478dd14dab1..88f9cd5df0f 100644 --- a/config/scripts/orcad-windows-prebuild-cache.mjs +++ b/config/scripts/orcad-windows-prebuild-cache.mjs @@ -50,7 +50,8 @@ export const WINDOWS_PREBUILD_CACHE_INPUTS = [ 'src/packages/process-host/src/windows-command-line.ts', 'src/packages/process-host/src/windows-cmd-shim-resolution.ts', 'src/packages/process-host/src/windows-system-binary.ts', - 'src/packages/process-host/src/windows-tree-kill.ts' + 'src/packages/process-host/src/windows-tree-kill.ts', + 'src/packages/process-host/src/wsl-distro-allowlist.ts' ] function windowsSlot(platform, arch) { diff --git a/src/cli/handlers/account.ts b/src/cli/handlers/account.ts index 9ed4bfd164b..7c487853883 100644 --- a/src/cli/handlers/account.ts +++ b/src/cli/handlers/account.ts @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process' +import { assertWslDistroAllowed } from '@orca/process-host/wsl-distro-allowlist' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' @@ -95,6 +96,12 @@ async function runAgentLoginInTerminal( resolvedCommand, addAgentNodePaths({ ...stripElectronRunAsNode(process.env), ...extraEnv }) ) + try { + assertWslDistroAllowed(spawnCmd, spawnArgs) + } catch (error) { + rejectPromise(error) + return + } const consoleStdio = stdioForWindowsInteractiveChild(json) let child: ReturnType try { diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 2800fdbf5df..02a4efcedda 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -1,4 +1,5 @@ import { execFileSync, spawn } from 'node:child_process' +import { assertWslDistroAllowed } from '@orca/process-host/wsl-distro-allowlist' import type { WindowsHostInteractiveLoginSpawn } from '../../shared/windows-interactive-login-spawn' import type { CodexManagedAccount, @@ -97,13 +98,17 @@ export class CodexAccountService { private readonly runtimeHome: CodexRuntimeHomeService, lifecycle: CodexAccountServiceLifecycle = {} ) { - this.managedHomePaths = new CodexManagedHomePath((distro, script) => - execFileSync( - 'wsl.exe', - ['-d', distro, '--exec', 'bash', '-lc', buildEncodedWslBashCommand(script)], - { windowsHide: true, encoding: 'utf-8', timeout: 5000, cwd: resolveWslInteropSpawnCwd() } - ) - ) + this.managedHomePaths = new CodexManagedHomePath((distro, script) => { + const args = ['-d', distro, '--exec', 'bash', '-lc', buildEncodedWslBashCommand(script)] + // Bypasses process-host, so check the internal distro allowlist here too. + assertWslDistroAllowed('wsl.exe', args) + return execFileSync('wsl.exe', args, { + windowsHide: true, + encoding: 'utf-8', + timeout: 5000, + cwd: resolveWslInteropSpawnCwd() + }) + }) this.managedHomes = new CodexManagedHomeLifecycle(this.managedHomePaths) this.identity = new CodexAccountIdentity((path, accountId) => this.managedHomePaths.assert(path, accountId) @@ -299,13 +304,15 @@ export class CodexAccountService { try { await runCodexLoginSession(managedHomePath, { wslCommand: 'wsl.exe', - spawn: ({ command, args, env, stdio }) => - spawn(command, args, { + spawn: ({ command, args, env, stdio }) => { + assertWslDistroAllowed(command, args) + return spawn(command, args, { stdio, // Hide the outer wrapper; the dedicated login console remains visible. windowsHide: true, env - }), + }) + }, killProcessTree: killLoginProcessTree, setCancel: (cancel) => { this.cancelPendingCodexLogin = cancel diff --git a/src/main/codex/codex-wsl-hook-install-plan.ts b/src/main/codex/codex-wsl-hook-install-plan.ts index a80b044baff..d6082ae3f2f 100644 --- a/src/main/codex/codex-wsl-hook-install-plan.ts +++ b/src/main/codex/codex-wsl-hook-install-plan.ts @@ -1,4 +1,5 @@ import { execFile } from 'node:child_process' +import { assertWslDistroAllowed } from '@orca/process-host/wsl-distro-allowlist' import { posix as pathPosix, win32 as pathWin32 } from 'node:path' import { parseWslUncPath } from '../../shared/wsl-paths' import { resolveWslInteropSpawnCwd } from '../wsl-interop-spawn-directory' @@ -111,6 +112,39 @@ function scheduleWslLinuxPathCanonicalization( 'sh', linuxPath ] + const settle = (error: Error | null, stdout: string): void => { + const canonicalPath = stdout.trim() + const resolvedPath = !error && canonicalPath.startsWith('/') ? canonicalPath : null + const pathMissing = !error && canonicalPath === WSL_PATH_MISSING_OUTPUT + const settlement: WslCanonicalPathSettlement = resolvedPath + ? { status: 'resolved', canonicalPath: resolvedPath } + : pathMissing + ? { status: 'missing' } + : { status: 'unavailable' } + if (settlement.status === 'resolved') { + rememberCanonicalWslPath(key, canonicalPath) + } else if (settlement.status === 'missing') { + // Confirmed absence invalidates cached trust identity. + canonicalWslPathCache.delete(key) + } + // Retain known-good identity on transport errors; /mnt guesses can rewrite valid custom-root trust. + const settledListeners = inFlightWslCanonicalizations.get(key) ?? new Set() + inFlightWslCanonicalizations.delete(key) + for (const listener of settledListeners) { + try { + listener(settlement) + } catch (listenerError) { + console.warn('[codex-wsl-hook-path] failed to reconcile canonical path', listenerError) + } + } + } + try { + assertWslDistroAllowed('wsl.exe', args) + } catch (error) { + // Settle asynchronously, as a failed exec would. + queueMicrotask(() => settle(error instanceof Error ? error : new Error(String(error)), '')) + return + } execFile( 'wsl.exe', args, @@ -120,32 +154,7 @@ function scheduleWslLinuxPathCanonicalization( windowsHide: true, cwd: resolveWslInteropSpawnCwd() }, - (error, stdout) => { - const canonicalPath = stdout.trim() - const resolvedPath = !error && canonicalPath.startsWith('/') ? canonicalPath : null - const pathMissing = !error && canonicalPath === WSL_PATH_MISSING_OUTPUT - const settlement: WslCanonicalPathSettlement = resolvedPath - ? { status: 'resolved', canonicalPath: resolvedPath } - : pathMissing - ? { status: 'missing' } - : { status: 'unavailable' } - if (settlement.status === 'resolved') { - rememberCanonicalWslPath(key, canonicalPath) - } else if (settlement.status === 'missing') { - // Confirmed absence invalidates cached trust identity. - canonicalWslPathCache.delete(key) - } - // Retain known-good identity on transport errors; /mnt guesses can rewrite valid custom-root trust. - const settledListeners = inFlightWslCanonicalizations.get(key) ?? new Set() - inFlightWslCanonicalizations.delete(key) - for (const listener of settledListeners) { - try { - listener(settlement) - } catch (listenerError) { - console.warn('[codex-wsl-hook-path] failed to reconcile canonical path', listenerError) - } - } - } + settle ) } diff --git a/src/main/daemon/pty-subprocess/native-pty-spawn-windows.test.ts b/src/main/daemon/pty-subprocess/native-pty-spawn-windows.test.ts index ac2d021b270..59bededfa92 100644 --- a/src/main/daemon/pty-subprocess/native-pty-spawn-windows.test.ts +++ b/src/main/daemon/pty-subprocess/native-pty-spawn-windows.test.ts @@ -86,6 +86,41 @@ describe('Windows shell fallback over node-pty ConPTY', () => { vi.restoreAllMocks() }) + it('keeps a WSL pane out of a distro the internal allowlist excludes, falling back past it', async () => { + vi.stubEnv('ORCA_INTERNAL_WSL_DISTRO_ALLOWLIST', 'orca-test-a') + try { + const wslAttempts = ['Ubuntu', 'orca-test-a'].map((distro) => ({ + ...attempts[0]!, + shellPath: 'C:\\Windows\\System32\\wsl.exe', + shellArgs: ['-d', distro, '--cd', '~'] + })) + nodePty.spawn.mockReturnValue(healthyPane(4242)) + const result = await spawnNativeDaemonPty({ + ...args, + shellPath: wslAttempts[0]!.shellPath, + shellArgs: wslAttempts[0]!.shellArgs, + windowsFallbackAttempts: wslAttempts + }) + expect(nodePty.spawn.mock.calls.map(([, argv]) => argv)).toEqual([ + ['-d', 'orca-test-a', '--cd', '~'] + ]) + expect(result.shellPath).toBe(wslAttempts[1]!.shellPath) + + nodePty.spawn.mockClear() + await expect( + spawnNativeDaemonPty({ + ...args, + shellPath: wslAttempts[0]!.shellPath, + shellArgs: wslAttempts[0]!.shellArgs, + windowsFallbackAttempts: [wslAttempts[0]!] + }) + ).rejects.toMatchObject({ code: 'ORCA_WSL_DISTRO_NOT_ALLOWLISTED' }) + expect(nodePty.spawn).not.toHaveBeenCalled() + } finally { + vi.unstubAllEnvs() + } + }) + it('walks both fallback shells when ConPTY rejects the primary ones', async () => { const proc = fakePane(9876, [CONPTY_PRELUDE]) nodePty.spawn.mockImplementation((file: string) => { diff --git a/src/main/daemon/pty-subprocess/native-pty-spawn.ts b/src/main/daemon/pty-subprocess/native-pty-spawn.ts index 22c6edd779d..8dd1a6ecfc6 100644 --- a/src/main/daemon/pty-subprocess/native-pty-spawn.ts +++ b/src/main/daemon/pty-subprocess/native-pty-spawn.ts @@ -1,3 +1,4 @@ +import { assertWslDistroAllowed } from '@orca/process-host/wsl-distro-allowlist' import type * as pty from 'node-pty' import { hostReportsChildExitStatus, @@ -44,6 +45,7 @@ export async function spawnNativeDaemonPty(args: { cwd: string ): Promise => { args.signal?.throwIfAborted() + assertWslDistroAllowed(shellPath, shellArgs) const tcc = wrapShellSpawnForMacosTccAttribution(shellPath, shellArgs, args.env) const wrapped = wrapPtySpawnInTerminalScope(tcc.file, tcc.args, args.env) const nodePty = await loadNodePty() diff --git a/src/main/git/command-runner/wsl-command-resolution-allowlist.test.ts b/src/main/git/command-runner/wsl-command-resolution-allowlist.test.ts new file mode 100644 index 00000000000..a9c39cf026b --- /dev/null +++ b/src/main/git/command-runner/wsl-command-resolution-allowlist.test.ts @@ -0,0 +1,85 @@ +import type * as ChildProcess from 'node:child_process' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { spawnMock, execFileSyncMock } = vi.hoisted(() => ({ + spawnMock: vi.fn(), + execFileSyncMock: vi.fn() +})) +vi.mock('node:child_process', async (original) => ({ + ...(await original()), + spawn: spawnMock, + execFileSync: execFileSyncMock +})) +vi.mock('../../wsl-background-distro-gate', () => ({ noteWslDistroLaunched: vi.fn() })) + +import { resolveCommand } from './wsl-command-resolution' +import { gitExecFileSync } from './git-exec-file' +import { gitSpawn } from './git-spawn' +import { wslAwareSpawn } from './wsl-aware-spawn' + +const ALLOWLIST = 'ORCA_INTERNAL_WSL_DISTRO_ALLOWLIST' +const REFUSED = { code: 'ORCA_WSL_DISTRO_NOT_ALLOWLISTED' } +const UBUNTU_REPO = '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo' +const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + +beforeEach(() => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) +}) + +afterEach(() => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + vi.unstubAllEnvs() + spawnMock.mockReset() + execFileSyncMock.mockReset() +}) + +describe('resolveCommand under the internal WSL distro allowlist', () => { + it('unset: resolves exactly as with the distro allowlisted, without throwing', () => { + vi.stubEnv(ALLOWLIST, undefined) + const unset = [ + resolveCommand('git', ['status'], UBUNTU_REPO), + resolveCommand('gh', ['auth', 'status'], undefined, 'Ubuntu', { useWslLoginShell: true }), + resolveCommand('git', ['status'], 'C:\\repo') + ] + vi.stubEnv(ALLOWLIST, 'ubuntu') + const allowlisted = [ + resolveCommand('git', ['status'], UBUNTU_REPO), + resolveCommand('gh', ['auth', 'status'], undefined, 'Ubuntu', { useWslLoginShell: true }), + resolveCommand('git', ['status'], 'C:\\repo') + ] + + expect(unset).toEqual(allowlisted) + expect(unset[0]).toMatchObject({ binary: 'wsl.exe', wslMode: 'non-login-shell' }) + expect(unset[2]).toMatchObject({ binary: 'git', wsl: null }) + }) + + it('refuses a WSL resolution into another distro, but never a Windows-host one', () => { + vi.stubEnv(ALLOWLIST, 'orca-test-a') + + expect(() => resolveCommand('git', ['status'], UBUNTU_REPO)).toThrow( + expect.objectContaining(REFUSED) + ) + expect(() => resolveCommand('gh', ['auth', 'status'], undefined, 'Ubuntu')).toThrow( + expect.objectContaining(REFUSED) + ) + expect(resolveCommand('git', ['status'], 'C:\\repo')).toMatchObject({ binary: 'git' }) + }) + + it('refuses through every direct child_process git spawn path before a child starts', () => { + vi.stubEnv(ALLOWLIST, 'orca-test-a') + + expect(() => gitExecFileSync(['status'], { cwd: UBUNTU_REPO })).toThrow( + expect.objectContaining(REFUSED) + ) + expect(() => gitSpawn(['status'], { cwd: UBUNTU_REPO })).toThrow( + expect.objectContaining(REFUSED) + ) + expect(() => wslAwareSpawn('rg', ['--files'], { cwd: UBUNTU_REPO })).toThrow( + expect.objectContaining(REFUSED) + ) + expect(execFileSyncMock).not.toHaveBeenCalled() + expect(spawnMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/git/command-runner/wsl-command-resolution.ts b/src/main/git/command-runner/wsl-command-resolution.ts index 3df11d6c9b3..d1149790508 100644 --- a/src/main/git/command-runner/wsl-command-resolution.ts +++ b/src/main/git/command-runner/wsl-command-resolution.ts @@ -1,3 +1,4 @@ +import { assertWslDistroAllowed } from '@orca/process-host/wsl-distro-allowlist' import { getDefaultWslDistro, parseWslPath, type WslPathInfo } from '../../wsl' import { buildWslCapturedLoginShellCommand, @@ -96,6 +97,8 @@ export function resolveCommand( if (!wsl) { return { binary: command, args, cwd, wsl: null, wslMode: null } } + // Every Git/gh/glab path into a distro resolves here, whichever spawn API then runs it. + assertWslDistroAllowed('wsl.exe', ['-d', wsl.distro]) // Every WSL Git/CLI run is about to start the distro; gated polls stop trusting a 'stopped' read. noteWslDistroLaunched(wsl.distro) diff --git a/src/main/git/wsl-git-read-environment.ts b/src/main/git/wsl-git-read-environment.ts index f46b6975238..8b450759aea 100644 --- a/src/main/git/wsl-git-read-environment.ts +++ b/src/main/git/wsl-git-read-environment.ts @@ -1,4 +1,5 @@ import { execFile } from 'node:child_process' +import { assertWslDistroAllowed } from '@orca/process-host/wsl-distro-allowlist' import { buildWslCapturedLoginShellCommand, buildWslExecArgs @@ -80,10 +81,17 @@ function probeWslGitReadEnvironment(distro: string): Promise { `printf '%s\\0%s\\0%s' "$PATH" "$_orca_git_path" "$HOME"` ].join('\n') const captured = buildWslCapturedLoginShellCommand(probeCommand) + const args = buildWslExecArgs(distro, ['sh', '-lc', captured.command]) return new Promise((resolve) => { + try { + assertWslDistroAllowed('wsl.exe', args) + } catch { + resolve({ kind: 'rejected' }) + return + } execFile( 'wsl.exe', - buildWslExecArgs(distro, ['sh', '-lc', captured.command]), + args, { encoding: 'utf8', maxBuffer: PROBE_MAX_BUFFER, diff --git a/src/main/ipc/filesystem/watchers/filesystem-watcher-wsl.test.ts b/src/main/ipc/filesystem/watchers/filesystem-watcher-wsl.test.ts index be2b7c4d144..bc533eaeb21 100644 --- a/src/main/ipc/filesystem/watchers/filesystem-watcher-wsl.test.ts +++ b/src/main/ipc/filesystem/watchers/filesystem-watcher-wsl.test.ts @@ -6,8 +6,8 @@ const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })) -vi.mock('child_process', () => ({ - spawn: spawnMock +vi.mock('@orca/process-host', () => ({ + spawnProcess: spawnMock })) import { createWslWatcher } from './filesystem-watcher-wsl' @@ -90,16 +90,13 @@ describe('createWslWatcher', () => { await promise - expect(spawnMock).toHaveBeenCalledWith( - 'wsl.exe', - ['-d', 'Ubuntu', '--exec', 'sh', '-s', '--', '/home/me/repo'], - expect.objectContaining({ - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - // Explicit cwd survives deletion of Orca's inherited worktree (#16463). - cwd: expect.any(String) - }) - ) + expect(spawnMock).toHaveBeenCalledWith({ + program: 'wsl.exe', + args: ['-d', 'Ubuntu', '--exec', 'sh', '-s', '--', '/home/me/repo'], + stdio: 'pipe', + // Explicit cwd survives deletion of Orca's inherited worktree (#16463). + cwd: expect.any(String) + }) }) it('counts WSL watcher processes against the global physical child cap', async () => { diff --git a/src/main/ipc/filesystem/watchers/filesystem-watcher-wsl.ts b/src/main/ipc/filesystem/watchers/filesystem-watcher-wsl.ts index 14262d6610c..7850d83d2b9 100644 --- a/src/main/ipc/filesystem/watchers/filesystem-watcher-wsl.ts +++ b/src/main/ipc/filesystem/watchers/filesystem-watcher-wsl.ts @@ -1,5 +1,5 @@ /** Poll inside WSL so distro shutdown kills the watcher instead of Windows UNC polling repeatedly waking it. */ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import type { PipedChildProcess } from '@orca/process-host/process-spec' import { StringDecoder } from 'node:string_decoder' import type { WebContents } from 'electron' import type { Event as WatcherEvent } from '@parcel/watcher' @@ -8,7 +8,7 @@ import { parseWslUncPath } from '../../../../shared/wsl-paths' import { createWslWatcherProcessExit, createWslWatcherStartup } from './wsl-watcher-process-exit' import * as watcherChildRegistry from '../watcher-process/parcel-watcher-child-registry' import { createDebouncedBatch, type DebouncedBatch } from './filesystem-watcher-batch-control' -import { resolveWslInteropSpawnCwd } from '../../../wsl-interop-spawn-directory' +import { spawnWslWatcherProcess } from './wsl-watcher-spawn' export type WatcherSubscription = { unsubscribe(): Promise @@ -232,18 +232,13 @@ export async function createWslWatcher( } } - let child: ChildProcessWithoutNullStreams + let child: PipedChildProcess const releaseChildReservation = watcherChildRegistry.reserveWatcherChild() if (!releaseChildReservation) { throw new watcherChildRegistry.WatcherChildCapacityError() } try { - child = spawn('wsl.exe', ['-d', distro, '--exec', 'sh', '-s', '--', linuxPath], { - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - // Inherited cwd may be a deleted worktree, causing spawn wsl.exe ENOENT (#16463). - cwd: resolveWslInteropSpawnCwd() - }) + child = spawnWslWatcherProcess(distro, linuxPath) } catch (error) { releaseChildReservation() throw error instanceof Error ? error : new Error(String(error)) diff --git a/src/main/ipc/filesystem/watchers/wsl-watcher-spawn.ts b/src/main/ipc/filesystem/watchers/wsl-watcher-spawn.ts new file mode 100644 index 00000000000..65bbad60800 --- /dev/null +++ b/src/main/ipc/filesystem/watchers/wsl-watcher-spawn.ts @@ -0,0 +1,14 @@ +import { spawnProcess } from '@orca/process-host' +import type { PipedChildProcess } from '@orca/process-host/process-spec' +import { resolveWslInteropSpawnCwd } from '../../../wsl-interop-spawn-directory' + +/** Start the in-distro poll script; process-host applies the internal distro allowlist. */ +export function spawnWslWatcherProcess(distro: string, linuxPath: string): PipedChildProcess { + return spawnProcess({ + program: 'wsl.exe', + args: ['-d', distro, '--exec', 'sh', '-s', '--', linuxPath], + stdio: 'pipe', + // Inherited cwd may be a deleted worktree, causing spawn wsl.exe ENOENT (#16463). + cwd: resolveWslInteropSpawnCwd() + }) +} diff --git a/src/main/providers/local-pty-runtime-spawn-wsl-allowlist.test.ts b/src/main/providers/local-pty-runtime-spawn-wsl-allowlist.test.ts new file mode 100644 index 00000000000..50809e790c6 --- /dev/null +++ b/src/main/providers/local-pty-runtime-spawn-wsl-allowlist.test.ts @@ -0,0 +1,55 @@ +import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const nodePty = vi.hoisted(() => ({ spawn: vi.fn() })) +vi.mock('node-pty', () => nodePty) + +import { loadLocalPtyRuntimeSpawn } from './local-pty-runtime-spawn' + +// A real file named wsl: the spawn path validates the shell before handing it to node-pty. +const WSL = join(mkdtempSync(join(tmpdir(), 'orca-wsl-allowlist-')), 'wsl') +writeFileSync(WSL, '#!/bin/sh\n') +chmodSync(WSL, 0o755) + +function params(shellArgs: string[]) { + return { shellPath: WSL, shellArgs, cols: 80, rows: 24, cwd: '/tmp', env: {} } +} + +describe.skipIf(process.platform === 'win32')( + 'degraded local PTY spawn under the internal WSL distro allowlist', + () => { + afterEach(() => { + vi.unstubAllEnvs() + nodePty.spawn.mockReset() + }) + + it('never starts a pane in an excluded distro, and still starts one in an allowed distro', async () => { + vi.stubEnv('ORCA_INTERNAL_WSL_DISTRO_ALLOWLIST', 'orca-test-a') + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const pane = { pid: 1 } + nodePty.spawn.mockReturnValue(pane) + const spawn = await loadLocalPtyRuntimeSpawn() + + // The refused primary takes the existing shell fallback path; wsl.exe never reaches node-pty. + try { + await spawn(params(['-d', 'Ubuntu'])) + } catch { + // No fallback shell on this host is also a refusal. + } + expect(nodePty.spawn.mock.calls.filter(([file]) => file === WSL)).toEqual([]) + + expect(await spawn(params(['-d', 'orca-test-a']))).toMatchObject({ process: pane }) + expect(nodePty.spawn).toHaveBeenLastCalledWith(WSL, ['-d', 'orca-test-a'], expect.anything()) + }) + + it('starts any distro when the allowlist is unset', async () => { + const pane = { pid: 2 } + nodePty.spawn.mockReturnValue(pane) + const spawn = await loadLocalPtyRuntimeSpawn() + expect(await spawn(params(['-d', 'Ubuntu']))).toMatchObject({ process: pane }) + expect(nodePty.spawn).toHaveBeenCalledWith(WSL, ['-d', 'Ubuntu'], expect.anything()) + }) + } +) diff --git a/src/main/providers/local-pty-runtime-spawn.ts b/src/main/providers/local-pty-runtime-spawn.ts index 0989bd46a02..e53eb0d2ad0 100644 --- a/src/main/providers/local-pty-runtime-spawn.ts +++ b/src/main/providers/local-pty-runtime-spawn.ts @@ -1,3 +1,4 @@ +import { assertWslDistroAllowed } from '@orca/process-host/wsl-distro-allowlist' import { spawnShellWithFallback, type ShellSpawnParams, @@ -11,5 +12,9 @@ type LocalPtySpawn = ( /** Degraded daemon routing loads node-pty lazily, as the daemon does. */ export async function loadLocalPtyRuntimeSpawn(): Promise { const pty = await import('node-pty') - return (params) => spawnShellWithFallback({ ...params, ptySpawn: pty.spawn }) + const ptySpawn: typeof pty.spawn = (file, args, options) => { + assertWslDistroAllowed(file, args) + return pty.spawn(file, args, options) + } + return (params) => spawnShellWithFallback({ ...params, ptySpawn }) } diff --git a/src/main/wsl.test.ts b/src/main/wsl.test.ts index 3cc8fd723f0..10b9b8604c7 100644 --- a/src/main/wsl.test.ts +++ b/src/main/wsl.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { ProcessResult } from '@orca/process-host/process-spec' +import { assertWslDistroAllowed } from '@orca/process-host/wsl-distro-allowlist' const { runProcessMock, runProcessSyncMock } = vi.hoisted(() => ({ runProcessMock: vi.fn(), @@ -310,6 +311,30 @@ describe('WSL home cache', () => { }) }) +describe('WSL probes under the internal distro allowlist', () => { + afterEach(() => { + vi.unstubAllEnvs() + runProcessMock.mockReset() + _resetWslCachesForTests() + }) + + it('still lists distros, and an excluded distro home probe fails like any other probe', async () => { + vi.stubEnv('ORCA_INTERNAL_WSL_DISTRO_ALLOWLIST', 'orca-test-a') + // Stands in for process-host: the real check runs before a child would start. + runProcessMock.mockImplementation(async (spec: { program: string; args: string[] }) => { + assertWslDistroAllowed(spec.program, spec.args) + return spec.args.includes('--list') + ? exited('Ubuntu\norca-test-a\n') + : exited('/home/alice\n') + }) + await withPlatformAsync('win32', async () => { + await expect(listWslDistrosAsync()).resolves.toEqual(['Ubuntu', 'orca-test-a']) + await expect(getWslHomeAsync('Ubuntu')).resolves.toBeNull() + await expect(getWslHomeAsync('orca-test-a')).resolves.toContain('alice') + }) + }) +}) + describe('WSL home probe on a booting distro', () => { afterEach(() => { runProcessMock.mockReset() diff --git a/src/packages/process-host/package.json b/src/packages/process-host/package.json index 045f2bafc1e..31af0ed7103 100644 --- a/src/packages/process-host/package.json +++ b/src/packages/process-host/package.json @@ -94,6 +94,11 @@ "orca-source": "./src/child-termination-reporter.ts", "types": "./dist/child-termination-reporter.d.ts", "default": "./dist/child-termination-reporter.js" + }, + "./wsl-distro-allowlist": { + "orca-source": "./src/wsl-distro-allowlist.ts", + "types": "./dist/wsl-distro-allowlist.d.ts", + "default": "./dist/wsl-distro-allowlist.js" } }, "scripts": { diff --git a/src/packages/process-host/src/spawn-resolution.ts b/src/packages/process-host/src/spawn-resolution.ts index c0665fcb0e9..410e45ccfae 100644 --- a/src/packages/process-host/src/spawn-resolution.ts +++ b/src/packages/process-host/src/spawn-resolution.ts @@ -2,6 +2,7 @@ import type { SpawnOptions as NodeSpawnOptions } from 'node:child_process' import { buildWindowsCmdShimCommandLine, isCmdInterpretedProgram } from './windows-command-line' import { resolveWindowsCmdShim } from './windows-cmd-shim-resolution' import type { ProcessSpec } from './process-spec' +import { assertWslDistroAllowed } from './wsl-distro-allowlist' export type ResolvedSpawn = { file: string @@ -12,6 +13,7 @@ export type ResolvedSpawn = { /** Host-independent for Windows tests; recognized shim I/O failures fall back to cmd.exe. */ export function resolveSpawn(spec: ProcessSpec, platform: NodeJS.Platform): ResolvedSpawn { const args = spec.args ?? [] + assertWslDistroAllowed(spec.program, args) const base: NodeSpawnOptions = { cwd: spec.cwd, env: spec.env, diff --git a/src/packages/process-host/src/wsl-distro-allowlist.test.ts b/src/packages/process-host/src/wsl-distro-allowlist.test.ts new file mode 100644 index 00000000000..3a841805ac2 --- /dev/null +++ b/src/packages/process-host/src/wsl-distro-allowlist.test.ts @@ -0,0 +1,140 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcess, runProcessSync, spawnProcess } from './run-process' +import { resolveSpawn } from './spawn-resolution' +import { + WSL_DISTRO_ALLOWLIST_ENV, + WSL_DISTRO_NOT_ALLOWLISTED_CODE, + assertWslDistroAllowed, + parseWslDistroAllowlist +} from './wsl-distro-allowlist' + +const WSL = 'C:\\Windows\\System32\\wsl.exe' + +function refusal(program: string, args: readonly string[]): unknown { + try { + assertWslDistroAllowed(program, args) + return null + } catch (error) { + return error + } +} + +describe('internal WSL distro allowlist', () => { + beforeEach(() => { + vi.unstubAllEnvs() + }) + afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() + }) + + it('unset: resolveSpawn output is unchanged for wsl.exe and any other program', () => { + vi.stubEnv(WSL_DISTRO_ALLOWLIST_ENV, undefined) + for (const platform of ['win32', 'linux'] as const) { + for (const spec of [ + { program: WSL, args: ['--shutdown'] }, + { program: 'wsl', args: ['ls'] }, + { program: 'git', args: ['status'] } + ]) { + const resolved = resolveSpawn(spec, platform) + expect(resolved.file).toBe(spec.program) + expect(resolved.args).toEqual(spec.args) + expect(resolved.options).toEqual( + resolveSpawn({ ...spec, program: 'git' }, platform).options + ) + } + } + }) + + it('unset: refuses nothing, including a default-distro exec and --shutdown', () => { + vi.stubEnv(WSL_DISTRO_ALLOWLIST_ENV, undefined) + expect(refusal(WSL, ['--shutdown'])).toBeNull() + expect(refusal(WSL, ['--exec', 'sh'])).toBeNull() + }) + + it('allowlisted: lets every distro-targeting form through, case-insensitively', () => { + vi.stubEnv(WSL_DISTRO_ALLOWLIST_ENV, 'orca-test-a, Orca-Test-B') + for (const args of [ + ['-d', 'orca-test-a', '--exec', 'sh', '-c', 'echo -d Ubuntu'], + ['--distribution', 'ORCA-TEST-B', '-u', 'root', '--cd', '~', '--exec', 'id'], + ['-t', 'orca-test-a'], + ['--terminate', 'orca-test-b'], + ['--unregister', 'orca-test-a'] + ]) { + expect(refusal(WSL, args), args.join(' ')).toBeNull() + } + expect(refusal('wsl', ['-d', 'orca-test-a'])).toBeNull() + }) + + it('keeps pure list, status and version calls allowed so discovery can filter', () => { + vi.stubEnv(WSL_DISTRO_ALLOWLIST_ENV, 'orca-test-a') + for (const args of [['-l', '-v'], ['--list', '--quiet'], ['--status'], ['--version']]) { + expect(refusal(WSL, args), args.join(' ')).toBeNull() + } + }) + + it('not allowlisted: refuses other distros, no-distro forms, ids and --shutdown', () => { + vi.stubEnv(WSL_DISTRO_ALLOWLIST_ENV, 'orca-test-a') + for (const args of [ + ['-d', 'Ubuntu', '--exec', 'sh'], + ['-d', 'orca-test-a', '-d', 'Ubuntu'], + ['-t', 'Ubuntu'], + ['--unregister', 'Ubuntu'], + ['--distribution-id', '{11111111-2222-3333-4444-555555555555}'], + ['--exec', 'sh'], + ['ls'], + ['-l', '-d'], + ['--shutdown'], + ['-d', 'orca-test-a', '--shutdown'] + ]) { + expect(refusal(WSL, args), args.join(' ')).toMatchObject({ + code: WSL_DISTRO_NOT_ALLOWLISTED_CODE + }) + } + // Matched by basename, case-insensitively. + expect(refusal('C:\\WINDOWS\\SYSTEM32\\WSL.EXE', ['-d', 'Ubuntu'])).not.toBeNull() + expect(refusal('/usr/bin/wsl', ['-d', 'Ubuntu'])).not.toBeNull() + expect(refusal('wslconfig.exe', ['/l'])).toBeNull() + expect(refusal('git', ['-d', 'Ubuntu'])).toBeNull() + }) + + it('stops at the first positional argument, which wsl.exe runs in the default distro', () => { + vi.stubEnv(WSL_DISTRO_ALLOWLIST_ENV, 'orca-test-a') + for (const args of [ + ['ls', '-d', 'orca-test-a'], + ['-u', 'root', 'ls', '-d', 'orca-test-a'], + ['--cd', '/tmp', 'sh', '-c', 'true', '-d', 'orca-test-a'] + ]) { + expect(refusal(WSL, args), args.join(' ')).toMatchObject({ + code: WSL_DISTRO_NOT_ALLOWLISTED_CODE + }) + } + expect(refusal(WSL, ['-d', 'orca-test-a', 'ls', '-d', 'Ubuntu'])).toBeNull() + }) + + it('malformed: fails closed for every distro and logs once', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + for (const value of ['', ' ', 'orca-test-a,', 'orca test', 'a;b']) { + expect(parseWslDistroAllowlist(value), JSON.stringify(value)).toBeNull() + } + vi.stubEnv(WSL_DISTRO_ALLOWLIST_ENV, 'orca-test-a,,') + expect(refusal(WSL, ['-d', 'orca-test-a'])).toMatchObject({ + code: WSL_DISTRO_NOT_ALLOWLISTED_CODE + }) + expect(refusal(WSL, ['-d', 'Ubuntu'])).not.toBeNull() + expect(refusal(WSL, ['-l', '-v'])).toBeNull() + vi.stubEnv(WSL_DISTRO_ALLOWLIST_ENV, '') + expect(refusal(WSL, ['-d', 'orca-test-a'])).not.toBeNull() + expect(warn).toHaveBeenCalledTimes(1) + }) + + it('refuses before any child starts, through every process-host entry point', async () => { + vi.stubEnv(WSL_DISTRO_ALLOWLIST_ENV, 'orca-test-a') + const spec = { program: WSL, args: ['-d', 'Ubuntu', '--exec', 'true'] } + expect(() => resolveSpawn(spec, 'win32')).toThrow(/not on the allowlist/) + expect(() => spawnProcess(spec)).toThrow(/not on the allowlist/) + expect(() => runProcessSync(spec)).toThrow(/not on the allowlist/) + // runProcess rejects, so async callers' existing failure, retry and fallback paths take over. + await expect(runProcess(spec)).rejects.toMatchObject({ code: WSL_DISTRO_NOT_ALLOWLISTED_CODE }) + }) +}) diff --git a/src/packages/process-host/src/wsl-distro-allowlist.ts b/src/packages/process-host/src/wsl-distro-allowlist.ts new file mode 100644 index 00000000000..457ee9026ee --- /dev/null +++ b/src/packages/process-host/src/wsl-distro-allowlist.ts @@ -0,0 +1,130 @@ +// Internal test-box switch: keep every wsl.exe exec out of distros not on the list. Unset changes nothing. +export const WSL_DISTRO_ALLOWLIST_ENV = 'ORCA_INTERNAL_WSL_DISTRO_ALLOWLIST' + +export const WSL_DISTRO_NOT_ALLOWLISTED_CODE = 'ORCA_WSL_DISTRO_NOT_ALLOWLISTED' + +// Only pure list/status/version calls may run without naming a distro. +const EXEMPT_FLAGS = new Set([ + '-l', + '--list', + '-v', + '--verbose', + '-q', + '--quiet', + '--running', + '--all', + '-o', + '--online', + '--status', + '--version', + '--help' +]) +const DISTRO_FLAGS = new Set([ + '-d', + '--distribution', + '--distribution-id', + '-t', + '--terminate', + '--unregister' +]) +const VALUE_FLAGS = new Set(['-u', '--user', '--cd', '--shell-type']) +// Everything after these belongs to the guest command, not to wsl.exe. +const COMMAND_START = new Set(['--exec', '-e', '--']) +const DISTRO_NAME = /^[A-Za-z0-9._-]+$/ + +let parsedFor: string | undefined +let parsedAllowlist: ReadonlySet | null = null +let warnedMalformed = false + +/** A comma-separated list of distro names; any malformed value allows no distro at all. */ +export function parseWslDistroAllowlist(value: string): ReadonlySet | null { + const names = value.split(',').map((name) => name.trim()) + if (names.some((name) => !DISTRO_NAME.test(name))) { + return null + } + return new Set(names.map((name) => name.toLowerCase())) +} + +function isWslProgram(program: string): boolean { + const base = program.slice(Math.max(program.lastIndexOf('/'), program.lastIndexOf('\\')) + 1) + const lower = base.toLowerCase() + return lower === 'wsl' || lower === 'wsl.exe' +} + +/** Why wsl.exe may not run this argv under the allowlist, or null when it may. */ +function refusalReason( + args: readonly string[], + allowlist: ReadonlySet | null +): string | null { + const targets: string[] = [] + let exemptOnly = true + for (let index = 0; index < args.length; index++) { + const arg = args[index] + // wsl.exe runs its first positional argument as a command in the current target distro. + if (COMMAND_START.has(arg) || !arg.startsWith('-')) { + exemptOnly = false + break + } + if (DISTRO_FLAGS.has(arg)) { + exemptOnly = false + const name = args[index + 1] + // A distro id is not a name, so it cannot be on the list. + targets.push(arg === '--distribution-id' ? '' : (name ?? '')) + index++ + continue + } + if (arg === '--shutdown') { + return 'wsl --shutdown stops every distro' + } + if (VALUE_FLAGS.has(arg)) { + index++ + } + if (!EXEMPT_FLAGS.has(arg)) { + exemptOnly = false + } + } + if (exemptOnly) { + return null + } + if (allowlist === null) { + return `${WSL_DISTRO_ALLOWLIST_ENV} is malformed, so no distro is allowed` + } + if (targets.length === 0) { + return 'the call does not name a distro' + } + const refused = targets.find((name) => !allowlist.has(name.toLowerCase())) + return refused === undefined ? null : `distro "${refused}" is not on the allowlist` +} + +function readAllowlist(value: string): ReadonlySet | null { + if (parsedFor !== value) { + parsedFor = value + parsedAllowlist = parseWslDistroAllowlist(value) + if (parsedAllowlist === null && !warnedMalformed) { + warnedMalformed = true + console.warn( + `[process-host] ${WSL_DISTRO_ALLOWLIST_ENV} is malformed; refusing every WSL distro exec` + ) + } + } + return parsedAllowlist +} + +/** Throw before a wsl.exe call reaches a distro the allowlist excludes. Unset: returns at once. */ +export function assertWslDistroAllowed(program: string, args: readonly string[] | string): void { + const value = process.env[WSL_DISTRO_ALLOWLIST_ENV] + if (value === undefined) { + return + } + if (!isWslProgram(program)) { + return + } + // node-pty also accepts a Windows command line; distro names never contain spaces. + const argv = typeof args === 'string' ? args.split(/\s+/).filter(Boolean) : args + const reason = refusalReason(argv, readAllowlist(value)) + if (reason !== null) { + throw Object.assign(new Error(`WSL exec refused: ${reason}`), { + code: WSL_DISTRO_NOT_ALLOWLISTED_CODE + }) + } +}