mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user