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();");
+ });
+});