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.
This commit is contained in:
Neil
2026-10-27 16:19:13 -07:00
committed by GitHub
parent 2f6c84c5f4
commit 5784e146de
22 changed files with 632 additions and 61 deletions
@@ -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
@@ -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),
@@ -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`,
@@ -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) {
+7
View File
@@ -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<typeof spawn>
try {
+17 -10
View File
@@ -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
+35 -26
View File
@@ -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
)
}
@@ -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) => {
@@ -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<pty.IPty> => {
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()
@@ -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<typeof ChildProcess>()),
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()
})
})
@@ -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)
+9 -1
View File
@@ -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<ProbeOutcome> {
`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,
@@ -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 () => {
@@ -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<void>
@@ -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))
@@ -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()
})
}
@@ -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())
})
}
)
@@ -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<LocalPtySpawn> {
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 })
}
+25
View File
@@ -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()
+5
View File
@@ -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": {
@@ -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,
@@ -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 })
})
})
@@ -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<string> | 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<string> | 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<string> | 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<string> | 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
})
}
}