diff --git a/src/commands/preflight.test.ts b/src/commands/preflight.test.ts index de483b847..70b393ee6 100644 --- a/src/commands/preflight.test.ts +++ b/src/commands/preflight.test.ts @@ -135,6 +135,7 @@ describe('resolveAllowedDomains', () => { mockedOptionParsers.processLocalhostKeyword.mockReturnValue({ allowedDomains: [], localhostDetected: false, + hostGatewayDetected: false, shouldEnableHostAccess: false, }); mockedCopilotResolver.resolveCopilotApiRouting.mockReturnValue({ @@ -160,6 +161,7 @@ describe('resolveAllowedDomains', () => { mockedOptionParsers.processLocalhostKeyword.mockReturnValue({ allowedDomains: ['example.com'], localhostDetected: false, + hostGatewayDetected: false, shouldEnableHostAccess: false, }); @@ -173,6 +175,7 @@ describe('resolveAllowedDomains', () => { mockedOptionParsers.processLocalhostKeyword.mockReturnValue({ allowedDomains: ['file-domain.com'], localhostDetected: false, + hostGatewayDetected: false, shouldEnableHostAccess: false, }); @@ -195,6 +198,7 @@ describe('resolveAllowedDomains', () => { mockedOptionParsers.processLocalhostKeyword.mockReturnValue({ allowedDomains: ['ruleset-domain.com'], localhostDetected: false, + hostGatewayDetected: false, shouldEnableHostAccess: false, }); @@ -221,6 +225,7 @@ describe('resolveAllowedDomains', () => { mockedOptionParsers.processLocalhostKeyword.mockReturnValue({ allowedDomains: ['bad domain!'], localhostDetected: false, + hostGatewayDetected: false, shouldEnableHostAccess: false, }); mockedApiProxyConfig.resolveApiTargetsToAllowedDomains.mockReturnValue(['bad domain!']); @@ -238,6 +243,7 @@ describe('resolveAllowedDomains', () => { mockedOptionParsers.processLocalhostKeyword.mockReturnValue({ allowedDomains: ['host.docker.internal'], localhostDetected: true, + hostGatewayDetected: true, shouldEnableHostAccess: true, defaultPorts: '3000,8080', }); @@ -252,6 +258,26 @@ describe('resolveAllowedDomains', () => { expect(mockedLogger.warn).toHaveBeenCalledWith(expect.stringContaining('localhost keyword enables host access')); }); + it('enables host access for explicit host gateway domains without treating them as localhost', () => { + mockedOptionParsers.processLocalhostKeyword.mockReturnValue({ + allowedDomains: ['host.docker.internal'], + localhostDetected: false, + hostGatewayDetected: true, + shouldEnableHostAccess: true, + defaultPorts: undefined, + }); + mockedDomainUtils.parseDomains.mockReturnValue(['host.docker.internal']); + + const options: Record = { allowDomains: 'host.docker.internal', allowHostPorts: '11434' }; + const result = resolveAllowedDomains(options); + + expect(result.localhostResult.localhostDetected).toBe(false); + expect(result.localhostResult.hostGatewayDetected).toBe(true); + expect(options.enableHostAccess).toBe(true); + expect(options.allowHostPorts).toBe('11434'); + expect(mockedLogger.warn).not.toHaveBeenCalledWith(expect.stringContaining('localhost keyword enables host access')); + }); + it('returns resolved Copilot API target from resolver', () => { mockedCopilotResolver.resolveCopilotApiRouting.mockReturnValue({ copilotApiTarget: 'custom.copilot.com', @@ -267,6 +293,7 @@ describe('resolveAllowedDomains', () => { mockedOptionParsers.processLocalhostKeyword.mockReturnValue({ allowedDomains: ['localhost'], localhostDetected: true, + hostGatewayDetected: true, shouldEnableHostAccess: false, }); mockedDomainUtils.parseDomains.mockReturnValue(['localhost']); @@ -286,6 +313,7 @@ describe('resolveAllowedDomains', () => { mockedOptionParsers.processLocalhostKeyword.mockReturnValue({ allowedDomains: ['localhost'], localhostDetected: true, + hostGatewayDetected: true, shouldEnableHostAccess: false, defaultPorts: undefined, }); @@ -332,6 +360,7 @@ describe('resolveAllowedDomains', () => { mockedOptionParsers.processLocalhostKeyword.mockReturnValue({ allowedDomains: ['awmg-mcpg'], localhostDetected: false, + hostGatewayDetected: false, shouldEnableHostAccess: false, }); diff --git a/src/commands/preflight.ts b/src/commands/preflight.ts index c2e5880aa..1eeeefc08 100644 --- a/src/commands/preflight.ts +++ b/src/commands/preflight.ts @@ -158,14 +158,16 @@ export function resolveAllowedDomains(options: Record): Allowed options.allowHostPorts as string | undefined ); - if (localhostResult.localhostDetected) { + if (localhostResult.hostGatewayDetected) { allowedDomains = localhostResult.allowedDomains; // Auto-enable host access if (localhostResult.shouldEnableHostAccess) { options.enableHostAccess = true; - logger.warn('⚠️ Security warning: localhost keyword enables host access - agent can reach services on your machine'); - logger.info('ℹ️ localhost keyword detected - automatically enabling host access'); + if (localhostResult.localhostDetected) { + logger.warn('⚠️ Security warning: localhost keyword enables host access - agent can reach services on your machine'); + logger.info('ℹ️ localhost keyword detected - automatically enabling host access'); + } } // Auto-configure common dev ports if not already specified diff --git a/src/commands/validate-options.test.ts b/src/commands/validate-options.test.ts index d3c642b83..598b247d2 100644 --- a/src/commands/validate-options.test.ts +++ b/src/commands/validate-options.test.ts @@ -148,6 +148,7 @@ describe('validateOptions', () => { sensitiveAllowedDomains: [], localhostResult: { localhostDetected: false, + hostGatewayDetected: false, allowedDomains: ['github.com'], shouldEnableHostAccess: false, }, @@ -579,6 +580,7 @@ describe('validateOptions', () => { sensitiveAllowedDomains: [], localhostResult: { localhostDetected: false, + hostGatewayDetected: true, allowedDomains: ['host.docker.internal'], shouldEnableHostAccess: false, }, diff --git a/src/commands/validators/config-assembly.test-utils.ts b/src/commands/validators/config-assembly.test-utils.ts index 200972ad4..0b431207e 100644 --- a/src/commands/validators/config-assembly.test-utils.ts +++ b/src/commands/validators/config-assembly.test-utils.ts @@ -106,6 +106,7 @@ export const createMinimalNetworkOptions = (): NetworkOptionsResult => ({ localhostResult: { allowedDomains: ['example.com'], localhostDetected: false, + hostGatewayDetected: false, shouldEnableHostAccess: false, }, upstreamProxy: undefined, diff --git a/src/option-parsers-network.test.ts b/src/option-parsers-network.test.ts index 348262615..d23cc80f1 100644 --- a/src/option-parsers-network.test.ts +++ b/src/option-parsers-network.test.ts @@ -85,6 +85,7 @@ describe('processLocalhostKeyword', () => { ); expect(result.localhostDetected).toBe(false); + expect(result.hostGatewayDetected).toBe(false); expect(result.allowedDomains).toEqual(['github.com', 'example.com']); expect(result.shouldEnableHostAccess).toBe(false); expect(result.defaultPorts).toBeUndefined(); @@ -100,6 +101,7 @@ describe('processLocalhostKeyword', () => { ); expect(result.localhostDetected).toBe(true); + expect(result.hostGatewayDetected).toBe(true); expect(result.allowedDomains).toEqual(['github.com', 'host.docker.internal']); expect(result.shouldEnableHostAccess).toBe(true); expect(result.defaultPorts).toBe('3000,3001,4000,4200,5000,5173,8000,8080,8081,8888,9000,9090'); @@ -113,6 +115,7 @@ describe('processLocalhostKeyword', () => { ); expect(result.localhostDetected).toBe(true); + expect(result.hostGatewayDetected).toBe(true); expect(result.allowedDomains).toEqual(['host.docker.internal']); expect(result.shouldEnableHostAccess).toBe(true); }); @@ -127,6 +130,7 @@ describe('processLocalhostKeyword', () => { ); expect(result.localhostDetected).toBe(true); + expect(result.hostGatewayDetected).toBe(true); expect(result.allowedDomains).toEqual(['github.com', 'http://host.docker.internal']); expect(result.shouldEnableHostAccess).toBe(true); expect(result.defaultPorts).toBe('3000,3001,4000,4200,5000,5173,8000,8080,8081,8888,9000,9090'); @@ -142,6 +146,7 @@ describe('processLocalhostKeyword', () => { ); expect(result.localhostDetected).toBe(true); + expect(result.hostGatewayDetected).toBe(true); expect(result.allowedDomains).toEqual(['github.com', 'https://host.docker.internal']); expect(result.shouldEnableHostAccess).toBe(true); expect(result.defaultPorts).toBe('3000,3001,4000,4200,5000,5173,8000,8080,8081,8888,9000,9090'); @@ -222,6 +227,7 @@ describe('processLocalhostKeyword', () => { ); expect(result.localhostDetected).toBe(false); + expect(result.hostGatewayDetected).toBe(false); expect(result.allowedDomains).toEqual([]); }); }); diff --git a/src/parsers/dns-parsers.test.ts b/src/parsers/dns-parsers.test.ts index 282b72211..c9de2346b 100644 --- a/src/parsers/dns-parsers.test.ts +++ b/src/parsers/dns-parsers.test.ts @@ -81,6 +81,7 @@ describe('processLocalhostKeyword', () => { it('returns domains unchanged when localhost is not present', () => { const result = processLocalhostKeyword(['github.com', 'api.github.com'], false, undefined); expect(result.localhostDetected).toBe(false); + expect(result.hostGatewayDetected).toBe(false); expect(result.shouldEnableHostAccess).toBe(false); expect(result.allowedDomains).toEqual(['github.com', 'api.github.com']); expect(result.defaultPorts).toBeUndefined(); @@ -89,11 +90,33 @@ describe('processLocalhostKeyword', () => { it('replaces bare localhost with host.docker.internal', () => { const result = processLocalhostKeyword(['localhost', 'github.com'], false, undefined); expect(result.localhostDetected).toBe(true); + expect(result.hostGatewayDetected).toBe(true); expect(result.allowedDomains).toContain('host.docker.internal'); expect(result.allowedDomains).not.toContain('localhost'); expect(result.allowedDomains).toContain('github.com'); }); + it('enables host access when host.docker.internal is explicitly allowed', () => { + const result = processLocalhostKeyword(['host.docker.internal'], false, '11434'); + expect(result.localhostDetected).toBe(false); + expect(result.hostGatewayDetected).toBe(true); + expect(result.shouldEnableHostAccess).toBe(true); + expect(result.allowedDomains).toEqual(['host.docker.internal']); + expect(result.defaultPorts).toBeUndefined(); + }); + + it.each([ + ['http://host.docker.internal'], + ['https://host.docker.internal'], + ])('preserves explicit %s entries without detecting localhost', (domain) => { + const result = processLocalhostKeyword([domain], false, '11434'); + expect(result.localhostDetected).toBe(false); + expect(result.hostGatewayDetected).toBe(true); + expect(result.shouldEnableHostAccess).toBe(true); + expect(result.allowedDomains).toEqual([domain]); + expect(result.defaultPorts).toBeUndefined(); + }); + it('preserves http:// protocol when replacing localhost', () => { const result = processLocalhostKeyword(['http://localhost'], false, undefined); expect(result.allowedDomains).toContain('http://host.docker.internal'); diff --git a/src/parsers/dns-parsers.ts b/src/parsers/dns-parsers.ts index 84248fd74..e24a807c9 100644 --- a/src/parsers/dns-parsers.ts +++ b/src/parsers/dns-parsers.ts @@ -5,6 +5,7 @@ const DEFAULT_DOH_RESOLVER = 'https://dns.google/dns-query'; interface LocalhostProcessingResult { allowedDomains: string[]; localhostDetected: boolean; + hostGatewayDetected: boolean; shouldEnableHostAccess: boolean; defaultPorts?: string; } @@ -48,7 +49,7 @@ export function parseDnsOverHttps( } /** - * Processes the localhost keyword in the allowed domains list. + * Processes host-gateway keywords in the allowed domains list. */ export function processLocalhostKeyword( allowedDomains: string[], @@ -56,24 +57,37 @@ export function processLocalhostKeyword( allowHostPorts: string | undefined ): LocalhostProcessingResult { const localhostIndex = allowedDomains.findIndex(d => - d === 'localhost' || d === 'http://localhost' || d === 'https://localhost' + d === 'localhost' || + d === 'http://localhost' || + d === 'https://localhost' ); + const hostGatewayIndex = localhostIndex === -1 ? allowedDomains.findIndex(d => + d === 'host.docker.internal' || + d === 'http://host.docker.internal' || + d === 'https://host.docker.internal' + ) : localhostIndex; - if (localhostIndex === -1) { + if (hostGatewayIndex === -1) { return { allowedDomains, localhostDetected: false, + hostGatewayDetected: false, shouldEnableHostAccess: false, }; } - // Remove localhost and replace with host.docker.internal - const localhostValue = allowedDomains[localhostIndex]; + const localhostDetected = localhostIndex !== -1; + + // Normalize localhost to host.docker.internal. An explicit + // host.docker.internal entry is already normalized. + const localhostValue = allowedDomains[hostGatewayIndex]; const updatedDomains = [...allowedDomains]; - updatedDomains.splice(localhostIndex, 1); + updatedDomains.splice(hostGatewayIndex, 1); // Preserve protocol if specified - if (localhostValue.startsWith('http://')) { + if (localhostValue === 'host.docker.internal') { + updatedDomains.push(localhostValue); + } else if (localhostValue.startsWith('http://')) { updatedDomains.push('http://host.docker.internal'); } else if (localhostValue.startsWith('https://')) { updatedDomains.push('https://host.docker.internal'); @@ -83,8 +97,9 @@ export function processLocalhostKeyword( return { allowedDomains: updatedDomains, - localhostDetected: true, + localhostDetected, + hostGatewayDetected: true, shouldEnableHostAccess: !enableHostAccess, - defaultPorts: allowHostPorts ? undefined : '3000,3001,4000,4200,5000,5173,8000,8080,8081,8888,9000,9090', + defaultPorts: localhostDetected && !allowHostPorts ? '3000,3001,4000,4200,5000,5173,8000,8080,8081,8888,9000,9090' : undefined, }; }