diff --git a/packages/core/src/sandbox/windows/WindowsSandboxManager.ts b/packages/core/src/sandbox/windows/WindowsSandboxManager.ts index fcc9b7543b..e9bc2b7f8f 100644 --- a/packages/core/src/sandbox/windows/WindowsSandboxManager.ts +++ b/packages/core/src/sandbox/windows/WindowsSandboxManager.ts @@ -34,6 +34,7 @@ import { isStrictlyApproved, } from './commandSafety.js'; import { verifySandboxOverrides } from '../utils/commandUtils.js'; +import { parseWindowsSandboxDenials } from './windowsSandboxDenialUtils.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -66,8 +67,8 @@ export class WindowsSandboxManager implements SandboxManager { return isDangerousCommand(args); } - parseDenials(_result: ShellExecutionResult): ParsedSandboxDenial | undefined { - return undefined; // TODO: Implement Windows-specific denial parsing + parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined { + return parseWindowsSandboxDenials(result); } /** diff --git a/packages/core/src/sandbox/windows/windowsSandboxDenialUtils.test.ts b/packages/core/src/sandbox/windows/windowsSandboxDenialUtils.test.ts new file mode 100644 index 0000000000..93e479a11d --- /dev/null +++ b/packages/core/src/sandbox/windows/windowsSandboxDenialUtils.test.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { parseWindowsSandboxDenials } from './windowsSandboxDenialUtils.js'; +import type { ShellExecutionResult } from '../../services/shellExecutionService.js'; + +describe('parseWindowsSandboxDenials', () => { + it('should detect CMD "Access is denied" and extract paths', () => { + const parsed = parseWindowsSandboxDenials({ + output: 'Access is denied.\r\n', + error: new Error('Command failed: dir C:\\Windows\\System32\\config'), + } as unknown as ShellExecutionResult); + + expect(parsed).toBeDefined(); + expect(parsed?.filePaths).toContain('C:\\Windows\\System32\\config'); + }); + + it('should detect PowerShell "Access to the path is denied"', () => { + const parsed = parseWindowsSandboxDenials({ + output: + "Set-Content : Access to the path 'C:\\test.txt' is denied.\r\nAt line:1 char:1\r\n", + } as unknown as ShellExecutionResult); + + expect(parsed).toBeDefined(); + expect(parsed?.filePaths).toContain('C:\\test.txt'); + }); + + it('should detect Node.js EPERM on Windows', () => { + const parsed = parseWindowsSandboxDenials({ + error: { + message: + "Error: EPERM: operation not permitted, open 'D:\\project\\file.ts'", + }, + } as unknown as ShellExecutionResult); + + expect(parsed).toBeDefined(); + expect(parsed?.filePaths).toContain('D:\\project\\file.ts'); + }); + + it('should detect network denial (EACCES)', () => { + const parsed = parseWindowsSandboxDenials({ + output: 'Error: listen EACCES: permission denied 0.0.0.0:3000', + } as unknown as ShellExecutionResult); + + expect(parsed).toBeDefined(); + expect(parsed?.network).toBe(true); + }); + + it('should detect native Windows error code 0x80070005', () => { + const parsed = parseWindowsSandboxDenials({ + output: 'HRESULT: 0x80070005', + } as unknown as ShellExecutionResult); + + expect(parsed).toBeDefined(); + // No path in output, but recognized as denial + }); + + it('should handle extended-length paths', () => { + const parsed = parseWindowsSandboxDenials({ + output: 'Access is denied to \\\\?\\C:\\Very\\Long\\Path\\file.txt', + } as unknown as ShellExecutionResult); + + expect(parsed).toBeDefined(); + expect(parsed?.filePaths).toContain( + '\\\\?\\C:\\Very\\Long\\Path\\file.txt', + ); + }); + + it('should detect Windows paths with forward slashes', () => { + const parsed = parseWindowsSandboxDenials({ + output: + "Error: EPERM: operation not permitted, open 'C:/project/file.ts'", + } as unknown as ShellExecutionResult); + + expect(parsed).toBeDefined(); + expect(parsed?.filePaths).toContain('C:/project/file.ts'); + }); + + it('should return undefined if no denial detected', () => { + const parsed = parseWindowsSandboxDenials({ + output: + 'Directory of C:\\Users\r\n03/26/2026 11:40 AM .', + } as unknown as ShellExecutionResult); + + expect(parsed).toBeUndefined(); + }); +}); diff --git a/packages/core/src/sandbox/windows/windowsSandboxDenialUtils.ts b/packages/core/src/sandbox/windows/windowsSandboxDenialUtils.ts new file mode 100644 index 0000000000..a2b12b0336 --- /dev/null +++ b/packages/core/src/sandbox/windows/windowsSandboxDenialUtils.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type ParsedSandboxDenial } from '../../services/sandboxManager.js'; +import type { ShellExecutionResult } from '../../services/shellExecutionService.js'; + +/** + * Windows-specific sandbox denial detection. + * Extracts paths from "Access is denied" and related errors. + */ +export function parseWindowsSandboxDenials( + result: ShellExecutionResult, +): ParsedSandboxDenial | undefined { + const output = result.output || ''; + const errorOutput = result.error?.message; + const combined = (output + ' ' + (errorOutput || '')).toLowerCase(); + + const isFileDenial = [ + 'access is denied', + 'access to the path', + 'unauthorizedaccessexception', + '0x80070005', + 'eperm: operation not permitted', + ].some((keyword) => combined.includes(keyword)); + + const isNetworkDenial = [ + 'eacces: permission denied', + 'an attempt was made to access a socket in a way forbidden by its access permissions', + // 10013 is WSAEACCES + '10013', + ].some((keyword) => combined.includes(keyword)); + + if (!isFileDenial && !isNetworkDenial) { + return undefined; + } + + const filePaths = new Set(); + + // Regex for Windows absolute paths (e.g., C:\Path or \\?\C:\Path) + // Handles drive letters and potentially quoted paths. + // We use two passes: one for quoted paths (which can contain spaces) + // and one for unquoted paths (which end at common separators). + + // 1. Quoted paths: 'C:\Foo Bar' or "C:\Foo Bar" + const quotedRegex = /['"]((?:\\\\(?:\?|\.)\\)?[a-zA-Z]:[\\/][^'"]+)['"]/g; + for (const match of output.matchAll(quotedRegex)) { + filePaths.add(match[1]); + } + if (errorOutput) { + for (const match of errorOutput.matchAll(quotedRegex)) { + filePaths.add(match[1]); + } + } + + // 2. Unquoted paths or paths in PowerShell error format: PermissionDenied: (C:\path:String) + const generalRegex = + /(?:^|[\s(])((?:\\\\(?:\?|\.)\\)?[a-zA-Z]:[\\/][^"'\s()<>|?*]+)/g; + for (const match of output.matchAll(generalRegex)) { + // Clean up trailing colon which might be part of the error message rather than the path + let p = match[1]; + if (p.endsWith(':')) p = p.slice(0, -1); + filePaths.add(p); + } + if (errorOutput) { + for (const match of errorOutput.matchAll(generalRegex)) { + let p = match[1]; + if (p.endsWith(':')) p = p.slice(0, -1); + filePaths.add(p); + } + } + + return { + network: isNetworkDenial || undefined, + filePaths: filePaths.size > 0 ? Array.from(filePaths) : undefined, + }; +} diff --git a/packages/core/src/services/sandboxManager.test.ts b/packages/core/src/services/sandboxManager.test.ts index 9d82a3d87f..02e16fd5e9 100644 --- a/packages/core/src/services/sandboxManager.test.ts +++ b/packages/core/src/services/sandboxManager.test.ts @@ -10,7 +10,6 @@ import fsPromises from 'node:fs/promises'; import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest'; import { NoopSandboxManager, - LocalSandboxManager, sanitizePaths, findSecretFiles, isSecretFile, @@ -374,6 +373,7 @@ describe('SandboxManager', () => { it.each([ { platform: 'linux', expected: LinuxSandboxManager }, { platform: 'darwin', expected: MacOsSandboxManager }, + { platform: 'win32', expected: WindowsSandboxManager }, ] as const)( 'should return $expected.name if sandboxing is enabled and platform is $platform', ({ platform, expected }) => { @@ -385,23 +385,5 @@ describe('SandboxManager', () => { expect(manager).toBeInstanceOf(expected); }, ); - - it("should return WindowsSandboxManager if sandboxing is enabled with 'windows-native' command on win32", () => { - vi.spyOn(os, 'platform').mockReturnValue('win32'); - const manager = createSandboxManager( - { enabled: true, command: 'windows-native' }, - { workspace: '/workspace' }, - ); - expect(manager).toBeInstanceOf(WindowsSandboxManager); - }); - - it('should return LocalSandboxManager on win32 if command is not windows-native', () => { - vi.spyOn(os, 'platform').mockReturnValue('win32'); - const manager = createSandboxManager( - { enabled: true, command: 'docker' as unknown as 'windows-native' }, - { workspace: '/workspace' }, - ); - expect(manager).toBeInstanceOf(LocalSandboxManager); - }); }); }); diff --git a/packages/core/src/services/sandboxManagerFactory.ts b/packages/core/src/services/sandboxManagerFactory.ts index 29c89cc722..cb70f796d1 100644 --- a/packages/core/src/services/sandboxManagerFactory.ts +++ b/packages/core/src/services/sandboxManagerFactory.ts @@ -33,7 +33,7 @@ export function createSandboxManager( } if (sandbox?.enabled) { - if (os.platform() === 'win32' && sandbox?.command === 'windows-native') { + if (os.platform() === 'win32') { return new WindowsSandboxManager(options); } else if (os.platform() === 'linux') { return new LinuxSandboxManager(options);