diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 3c95e238f..beda2e8f4 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -563,6 +563,25 @@ Visual Studio". **Fix.** Fix the printed compiler error, then re-run `./setup`. + +### `Docker found at , but native Windows Docker transport is not supported yet; static assessment only.` / `docker.exe at is outside the trusted install locations (...)` + +**Meaning.** On Windows, /cso looks for `docker.exe` only under the install +folders Windows reports for Program Files, Program Files (x86) and the Windows +directory, by its real path, with no symlink or junction on the way. A +user-writable directory is untrusted, because the Docker child carries +registry credentials; that refusal cannot be overridden. + +Even a trusted `docker.exe` cannot run /cso's isolated containers yet: /cso +admits only a local Unix Docker socket, and Docker Desktop on Windows speaks +over a named pipe. /cso reports this and runs its static assessment only; no +container or runtime check runs. + +**Fix.** For runtime checks, run /cso from Linux or macOS (WSL2 counts as +Linux) with a local Docker socket. On Windows, static assessment is the +supported mode; if the refusal named a user directory, install Docker Desktop +under Program Files. + ### `removed the AskUserQuestion preference hook: it breaks Conductor's native AskUserQuestion (#2207). ...` diff --git a/lib/cso/process.ts b/lib/cso/process.ts index 9bd170312..bb953611f 100644 --- a/lib/cso/process.ts +++ b/lib/cso/process.ts @@ -15,6 +15,7 @@ import { import { basename, dirname, join, isAbsolute, delimiter, resolve } from 'node:path'; import { redactFindingSpans } from '../redact-engine'; import { CsoError, MAX_OUTPUT } from './contracts'; +import { windowsDockerUnavailable } from './windows-docker'; const SOURCE_RUNTIME = /^bun(?:\.exe)?$/i.test(basename(process.execPath)); const WINDOWS_GIT = @@ -45,6 +46,7 @@ export function executable(name: string): string { ); } } + if (process.platform === 'win32' && name.toLowerCase() === 'docker') windowsDockerUnavailable(); for (const directory of TRUSTED_DIRECTORIES) { const candidates = process.platform === 'win32' diff --git a/lib/cso/windows-docker.ts b/lib/cso/windows-docker.ts new file mode 100644 index 000000000..a461e0324 --- /dev/null +++ b/lib/cso/windows-docker.ts @@ -0,0 +1,112 @@ +import { spawnSync } from 'node:child_process'; +import { lstatSync, realpathSync } from 'node:fs'; +import { win32 } from 'node:path'; +import { CsoError } from './contracts'; + +export const WINDOWS_DOCKER_ANCHOR = + 'https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#cso-windows-docker'; + +export interface WindowsDockerProbe { + /** Known-folder roots (Program Files, Program Files (x86), Windows), never the process environment. */ + knownFolders(): string[]; + /** Candidate docker.exe paths: a PATH lookup and the Docker Desktop default under each root. */ + candidates(roots: string[]): string[]; + realpath(path: string): string; + isReparsePoint(path: string): boolean; +} + +function under(path: string, root: string): boolean { + const p = win32.resolve(path).toLowerCase(); + const r = win32.resolve(root).toLowerCase().replace(/\\+$/, ''); + return p.startsWith(`${r}\\`); +} + +/** Every existing component of `path` from its drive root down, the file included. */ +function components(path: string): string[] { + const resolved = win32.resolve(path); + const root = win32.parse(resolved).root; + const parts = resolved.slice(root.length).split('\\').filter(Boolean); + return parts.map((_, i) => win32.join(root, ...parts.slice(0, i + 1))); +} + +/** + * /cso on Windows: find docker.exe only under the known-folder install roots. + * Each candidate's real path must stay under a root and no component may be a + * reparse point (symlink or junction). A bound docker.exe still cannot carry + * /cso's isolation (lib/cso/docker.ts admits only unix:/// endpoints), so this + * always throws: the "not supported yet" outcome when Docker is trusted, the + * refusal when it is outside the trusted locations, or the not-installed error. + */ +export function windowsDockerUnavailable(probe: WindowsDockerProbe = systemProbe()): never { + let roots: string[] = []; + try { + roots = probe.knownFolders().filter((root) => win32.isAbsolute(root)); + } catch {} + const found = [...new Set(probe.candidates(roots))].filter((candidate) => { + try { + probe.realpath(candidate); + return true; + } catch { + return false; + } + }); + if (!found.length) + throw new CsoError( + 'TOOL_UNAVAILABLE', + 'docker is not installed in a trusted system executable directory', + ); + for (const candidate of found) { + let real = ''; + try { + real = probe.realpath(candidate); + if (components(candidate).some((part) => probe.isReparsePoint(part))) continue; + } catch { + continue; + } + if (win32.resolve(real).toLowerCase() !== win32.resolve(candidate).toLowerCase()) continue; + if (!roots.some((root) => under(real, root))) continue; + throw new CsoError( + 'TOOL_UNAVAILABLE', + `Docker found at ${real}, but native Windows Docker transport is not supported yet; static assessment only. ${WINDOWS_DOCKER_ANCHOR}`, + ); + } + throw new CsoError( + 'TOOL_UNAVAILABLE', + `docker.exe at ${found[0]} is outside the trusted install locations (${roots.join(', ') || 'none resolved'}), ` + + 'or reaches them through a symlink or junction. A user-writable directory is untrusted for a child that ' + + 'carries registry credentials. Fix: install Docker Desktop under Program Files, or run /cso static-only. ' + + `This is not overridable. ${WINDOWS_DOCKER_ANCHOR}`, + ); +} + +function systemProbe(): WindowsDockerProbe { + const windows = process.env.SystemRoot || 'C:\\Windows'; + return { + knownFolders() { + const r = spawnSync( + win32.join(windows, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), + [ + '-NoProfile', + '-NonInteractive', + '-Command', + "'ProgramFiles','ProgramFilesX86','Windows' | ForEach-Object { [Environment]::GetFolderPath($_) }", + ], + { encoding: 'utf8', timeout: 10_000, windowsHide: true }, + ); + if (r.status !== 0) return []; + return r.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + }, + candidates(roots) { + const onPath = Bun.which('docker'); + return [ + ...(onPath ? [onPath] : []), + ...roots.map((root) => win32.join(root, 'Docker', 'Docker', 'resources', 'bin', 'docker.exe')), + ]; + }, + realpath: (path) => realpathSync.native(path), + isReparsePoint: (path) => lstatSync(path).isSymbolicLink(), + }; +} diff --git a/test/cso-windows-docker.test.ts b/test/cso-windows-docker.test.ts new file mode 100644 index 000000000..f5b3ae858 --- /dev/null +++ b/test/cso-windows-docker.test.ts @@ -0,0 +1,94 @@ +/** + * /cso on Windows: docker.exe is discovered only under the known-folder + * install roots (resolved through [Environment]::GetFolderPath, not the + * process environment), by real path, with no symlink or junction on the way. + * lib/cso/docker.ts admits only unix:/// endpoints, so a trusted docker.exe + * yields the "native Windows Docker transport is not supported yet; static + * assessment only" outcome, never a run; anything else is refused. + */ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { CsoError } from '../lib/cso/contracts'; +import { WINDOWS_DOCKER_ANCHOR, windowsDockerUnavailable, type WindowsDockerProbe } from '../lib/cso/windows-docker'; + +const ROOTS = ['C:\\Program Files', 'C:\\Program Files (x86)', 'C:\\Windows']; +const DESKTOP = 'C:\\Program Files\\Docker\\Docker\\resources\\bin\\docker.exe'; + +function probe(files: Record, opts: { reparse?: string[]; roots?: string[]; onPath?: string } = {}): WindowsDockerProbe { + const real = new Map(Object.entries(files).map(([k, v]) => [k.toLowerCase(), v])); + return { + knownFolders: () => opts.roots ?? ROOTS, + candidates: (roots) => [...(opts.onPath ? [opts.onPath] : []), ...roots.map((r) => `${r}\\Docker\\Docker\\resources\\bin\\docker.exe`)], + realpath: (p) => { + const hit = real.get(p.toLowerCase()); + if (!hit) throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + return hit; + }, + isReparsePoint: (p) => (opts.reparse ?? []).some((r) => r.toLowerCase() === p.toLowerCase()), + }; +} + +function outcome(p: WindowsDockerProbe): string { + try { + windowsDockerUnavailable(p); + } catch (err) { + expect(err).toBeInstanceOf(CsoError); + return (err as Error).message; + } + throw new Error('windowsDockerUnavailable returned'); +} + +describe('/cso Windows Docker discovery', () => { + test('Docker Desktop under Program Files: not supported yet, static assessment only, with the anchor', () => { + const msg = outcome(probe({ [DESKTOP]: DESKTOP })); + expect(msg).toBe(`Docker found at ${DESKTOP}, but native Windows Docker transport is not supported yet; static assessment only. ${WINDOWS_DOCKER_ANCHOR}`); + expect(WINDOWS_DOCKER_ANCHOR).toBe('https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#cso-windows-docker'); + }); + + test('a docker.exe on PATH in a user-writable directory is refused as outside the trusted install locations', () => { + const user = 'C:\\Users\\dev\\AppData\\Local\\bin\\docker.exe'; + const msg = outcome(probe({ [user]: user }, { onPath: user })); + expect(msg).toContain(`docker.exe at ${user} is outside the trusted install locations (${ROOTS.join(', ')})`); + expect(msg).toContain('A user-writable directory is untrusted for a child that carries registry credentials'); + expect(msg).toContain('Fix: install Docker Desktop under Program Files, or run /cso static-only'); + expect(msg).toContain('This is not overridable'); + expect(msg).toContain(WINDOWS_DOCKER_ANCHOR); + }); + + test('a sibling directory that only shares the prefix is not under Program Files', () => { + const evil = 'C:\\Program Files Evil\\docker.exe'; + expect(outcome(probe({ [evil]: evil }, { onPath: evil }))).toContain('is outside the trusted install locations'); + }); + + test('the real path decides: a Program Files path that resolves into a user directory is refused', () => { + const escaped = 'C:\\Users\\dev\\docker.exe'; + expect(outcome(probe({ [DESKTOP]: escaped }))).toContain(`docker.exe at ${DESKTOP} is outside the trusted install locations`); + }); + + test('a junction anywhere on the path is refused even when it resolves in place', () => { + const msg = outcome(probe({ [DESKTOP]: DESKTOP }, { reparse: ['C:\\Program Files\\Docker'] })); + expect(msg).toContain('reaches them through a symlink or junction'); + }); + + test('roots come from the known-folder probe: an unresolved probe trusts nothing', () => { + expect(outcome(probe({ [DESKTOP]: DESKTOP }, { roots: [], onPath: DESKTOP }))).toContain('outside the trusted install locations (none resolved)'); + }); + + test('no docker.exe anywhere keeps the not-installed error', () => { + expect(outcome(probe({}))).toBe('docker is not installed in a trusted system executable directory'); + }); + + test('the system probe asks PowerShell for the known folders, never the environment', () => { + const src = fs.readFileSync(path.join(import.meta.dir, '..', 'lib', 'cso', 'windows-docker.ts'), 'utf8'); + expect(src).toContain('[Environment]::GetFolderPath($_)'); + expect(src).toContain("'ProgramFiles','ProgramFilesX86','Windows'"); + expect(src).not.toMatch(/process\.env\.(ProgramFiles|ProgramW6432|"ProgramFiles)/); + expect(src).toContain('realpathSync.native'); + }); + + test('executable("docker") routes Windows through the discovery', () => { + const src = fs.readFileSync(path.join(import.meta.dir, '..', 'lib', 'cso', 'process.ts'), 'utf8'); + expect(src).toContain("if (process.platform === 'win32' && name.toLowerCase() === 'docker') windowsDockerUnavailable();"); + }); +});