From 1e3f99e3fa6cb824f057d84df1d7a6d809360ef7 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Tue, 7 Apr 2026 18:03:25 -0700 Subject: [PATCH 1/4] fix: bind mcpg to assigned IP + fail-close on missing GH_TOKEN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address security review findings from #1778: 1. Bind mcpg to its assigned IP (172.30.0.51) instead of 0.0.0.0 so the agent container cannot reach mcpg directly. Previously mcpg listened on all interfaces, making it reachable from any container on awf-net. 2. Add fail-close guard: generateDockerCompose now throws if enableCliProxy is set but githubToken is absent. mcpg requires a token to enforce DIFC policies — running without one would bypass integrity checks. 3. Use mcpg IP in healthcheck (not localhost) for TLS hostname consistency with how cli-proxy connects via GH_HOST. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- containers/cli-proxy/Dockerfile | 31 ++---- containers/cli-proxy/entrypoint.sh | 62 +++--------- scripts/ci/cleanup.sh | 2 +- src/cli.ts | 8 +- src/commands/predownload.test.ts | 23 ++++- src/commands/predownload.ts | 7 +- src/docker-manager.test.ts | 121 +++++++++++++++++------- src/docker-manager.ts | 146 +++++++++++++++++++++++------ src/types.ts | 12 +-- tests/fixtures/cleanup.ts | 2 +- 10 files changed, 265 insertions(+), 149 deletions(-) diff --git a/containers/cli-proxy/Dockerfile b/containers/cli-proxy/Dockerfile index b87e78212..52b4bc018 100644 --- a/containers/cli-proxy/Dockerfile +++ b/containers/cli-proxy/Dockerfile @@ -1,21 +1,10 @@ -# CLI Proxy sidecar for AWF - provides gh CLI access with mcpg DIFC proxy +# CLI Proxy sidecar for AWF - provides gh CLI access via mcpg DIFC proxy # -# Multi-stage build: -# Stage 1: Extract mcpg binary from ghcr.io/github/gh-aw-mcpg image -# Stage 2: Assemble final image with gh CLI, Node.js, and mcpg -# -# This container runs two processes: -# 1. mcpg proxy (TLS, port 18443) - holds GH_TOKEN, enforces guard policies -# 2. HTTP server (port 11000) - receives gh invocations from the agent container - -# Stage 1: Extract the mcpg binary from the official gh-aw-mcpg image. -# MCPG_IMAGE is configurable via --cli-proxy-mcpg-image so the AWF compiler -# can control which mcpg version is pulled and run (e.g. for version pinning -# or testing a new mcpg release before it is bundled in the GHCR cli-proxy image). -ARG MCPG_IMAGE=ghcr.io/github/gh-aw-mcpg:v0.2.15 -FROM ${MCPG_IMAGE} AS mcpg-source - -# Stage 2: Build the CLI proxy image +# This container runs the HTTP exec server (port 11000) that receives gh +# invocations from the agent container. The mcpg DIFC proxy runs as a +# separate docker-compose service (awf-cli-proxy-mcpg) using the official +# gh-aw-mcpg image directly — no binary extraction needed. GH_HOST is +# set to the mcpg container so all gh CLI traffic flows through the proxy. FROM node:22-alpine # Install gh CLI and curl for healthchecks/wrapper @@ -26,10 +15,6 @@ RUN apk add --no-cache \ ca-certificates \ bash -# Copy the mcpg binary from the mcpg-source stage -COPY --from=mcpg-source /usr/local/bin/mcpg /usr/local/bin/mcpg -RUN chmod +x /usr/local/bin/mcpg - # Create app directory WORKDIR /app @@ -50,10 +35,10 @@ RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/healthcheck.sh RUN addgroup -S cliproxy && adduser -S cliproxy -G cliproxy # Create log directory owned by cliproxy (so non-root process can write) -RUN mkdir -p /var/log/cli-proxy/mcpg && \ +RUN mkdir -p /var/log/cli-proxy && \ chown -R cliproxy:cliproxy /var/log/cli-proxy -# Create /tmp/proxy-tls directory owned by cliproxy for mcpg TLS cert generation +# Create /tmp/proxy-tls directory owned by cliproxy for shared mcpg TLS certs RUN mkdir -p /tmp/proxy-tls && chown cliproxy:cliproxy /tmp/proxy-tls # Switch to non-root user diff --git a/containers/cli-proxy/entrypoint.sh b/containers/cli-proxy/entrypoint.sh index 1f4202422..408ddce65 100644 --- a/containers/cli-proxy/entrypoint.sh +++ b/containers/cli-proxy/entrypoint.sh @@ -1,51 +1,24 @@ #!/bin/bash # CLI Proxy sidecar entrypoint -# Starts the mcpg DIFC proxy (GH_TOKEN required), then starts the Node.js HTTP server -# under a supervisor loop so signals are properly handled and mcpg is cleaned up. +# +# The mcpg DIFC proxy runs as a separate docker-compose service +# (awf-cli-proxy-mcpg). This entrypoint waits for the mcpg TLS cert +# (written to a shared volume at /tmp/proxy-tls), configures gh CLI to +# route through the mcpg container, then starts the Node.js HTTP server. set -e echo "[cli-proxy] Starting CLI proxy sidecar..." -MCPG_PID="" NODE_PID="" -# GH_TOKEN is required: without it, mcpg cannot authenticate and DIFC guard policies -# cannot be enforced. Fail closed rather than starting an unenforced server. -if [ -z "$GH_TOKEN" ]; then - echo "[cli-proxy] ERROR: GH_TOKEN not set - refusing to start without mcpg DIFC enforcement" - exit 1 -fi - -echo "[cli-proxy] GH_TOKEN present - starting mcpg DIFC proxy..." +# AWF_MCPG_HOST is set by docker-manager.ts to the mcpg container's IP. +# Fall back to localhost for backward-compatible local testing. +MCPG_HOST="${AWF_MCPG_HOST:-localhost}" +MCPG_PORT="${AWF_MCPG_PORT:-18443}" -mkdir -p /tmp/proxy-tls /var/log/cli-proxy/mcpg +echo "[cli-proxy] mcpg proxy at ${MCPG_HOST}:${MCPG_PORT}" -# Build the guard policy JSON if not explicitly provided -if [ -z "$AWF_GH_GUARD_POLICY" ]; then - if [ -n "$GITHUB_REPOSITORY" ]; then - AWF_GH_GUARD_POLICY="{\"repos\":[\"${GITHUB_REPOSITORY}\"],\"min-integrity\":\"public\"}" - else - AWF_GH_GUARD_POLICY="{\"min-integrity\":\"public\"}" - fi - echo "[cli-proxy] Using default guard policy: ${AWF_GH_GUARD_POLICY}" -else - echo "[cli-proxy] Using provided guard policy" -fi - -# Start mcpg proxy in background -# mcpg proxy holds GH_TOKEN and applies DIFC guard policies before forwarding -mcpg proxy \ - --policy "${AWF_GH_GUARD_POLICY}" \ - --listen 127.0.0.1:18443 \ - --tls \ - --tls-dir /tmp/proxy-tls \ - --guards-mode filter \ - --trusted-bots "github-actions[bot],github-actions,dependabot[bot],copilot" \ - --log-dir /var/log/cli-proxy/mcpg & -MCPG_PID=$! -echo "[cli-proxy] mcpg proxy started (PID: ${MCPG_PID})" - -# Wait for TLS cert to be generated (max 30s) +# Wait for TLS cert to appear in the shared volume (max 30s) echo "[cli-proxy] Waiting for mcpg TLS certificate..." i=0 while [ $i -lt 30 ]; do @@ -58,31 +31,24 @@ while [ $i -lt 30 ]; do done if [ ! -f /tmp/proxy-tls/ca.crt ]; then - echo "[cli-proxy] ERROR: mcpg TLS certificate not generated within 30s" - kill "$MCPG_PID" 2>/dev/null || true + echo "[cli-proxy] ERROR: mcpg TLS certificate not found within 30s" exit 1 fi # Configure gh CLI to route through the mcpg proxy (TLS, self-signed CA) -export GH_HOST="localhost:18443" +export GH_HOST="${MCPG_HOST}:${MCPG_PORT}" export NODE_EXTRA_CA_CERTS="/tmp/proxy-tls/ca.crt" export GH_REPO="${GH_REPO:-$GITHUB_REPOSITORY}" echo "[cli-proxy] gh CLI configured to route through mcpg proxy at ${GH_HOST}" -# Cleanup handler: stop both the Node HTTP server and mcpg when we receive a signal -# or when the server exits. This runs correctly because we do NOT exec Node — we -# start it in the background and wait, so the shell (and its traps) remain active. +# Cleanup handler: stop the Node HTTP server on signal cleanup() { echo "[cli-proxy] Shutting down..." if [ -n "$NODE_PID" ]; then kill "$NODE_PID" 2>/dev/null || true wait "$NODE_PID" 2>/dev/null || true fi - if [ -n "$MCPG_PID" ]; then - kill "$MCPG_PID" 2>/dev/null || true - wait "$MCPG_PID" 2>/dev/null || true - fi } trap 'cleanup; exit 0' INT TERM diff --git a/scripts/ci/cleanup.sh b/scripts/ci/cleanup.sh index cd2d6b2e0..718fdccfe 100755 --- a/scripts/ci/cleanup.sh +++ b/scripts/ci/cleanup.sh @@ -12,7 +12,7 @@ echo "===========================================" # First, explicitly remove containers by name (handles orphaned containers) echo "Removing awf containers by name..." -docker rm -f awf-squid awf-agent awf-iptables-init awf-api-proxy awf-cli-proxy 2>/dev/null || true +docker rm -f awf-squid awf-agent awf-iptables-init awf-api-proxy awf-cli-proxy awf-cli-proxy-mcpg 2>/dev/null || true # Cleanup diagnostic test containers echo "Stopping docker compose services..." diff --git a/src/cli.ts b/src/cli.ts index 04feca9c9..9f78f1ffc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1454,9 +1454,8 @@ program ) .option( '--cli-proxy-mcpg-image ', - 'Docker image for the mcpg DIFC proxy used inside the CLI proxy sidecar\n' + - ' (only used with --build-local; ignored when pulling pre-built GHCR images)\n' + - ' Set by the AWF compiler to control which mcpg version is pulled and run', + 'Docker image for the mcpg DIFC proxy container (runs as a separate service alongside cli-proxy)\n' + + ' Set by the AWF compiler to control which mcpg version is used', 'ghcr.io/github/gh-aw-mcpg:v0.2.15' ) // -- Logging & Debug -- @@ -2058,6 +2057,7 @@ export async function handlePredownloadAction(options: { agentImage: string; enableApiProxy: boolean; enableCliProxy?: boolean; + cliProxyMcpgImage?: string; }): Promise { const { predownloadCommand } = await import('./commands/predownload'); try { @@ -2067,6 +2067,7 @@ export async function handlePredownloadAction(options: { agentImage: options.agentImage, enableApiProxy: options.enableApiProxy, enableCliProxy: options.enableCliProxy, + cliProxyMcpgImage: options.cliProxyMcpgImage, }); } catch (error) { const exitCode = (error as Error & { exitCode?: number }).exitCode ?? 1; @@ -2091,6 +2092,7 @@ program ) .option('--enable-api-proxy', 'Also download the API proxy image', false) .option('--enable-cli-proxy', 'Also download the CLI proxy image', false) + .option('--cli-proxy-mcpg-image ', 'Docker image for the mcpg DIFC proxy container', 'ghcr.io/github/gh-aw-mcpg:v0.2.15') .action(handlePredownloadAction); // Logs subcommand - view Squid proxy logs diff --git a/src/commands/predownload.test.ts b/src/commands/predownload.test.ts index b0a946a7e..cce677059 100644 --- a/src/commands/predownload.test.ts +++ b/src/commands/predownload.test.ts @@ -43,12 +43,13 @@ describe('predownload', () => { ]); }); - it('should include cli-proxy when enabled', () => { + it('should include cli-proxy and mcpg when enabled', () => { const images = resolveImages({ ...defaults, enableCliProxy: true }); expect(images).toEqual([ 'ghcr.io/github/gh-aw-firewall/squid:latest', 'ghcr.io/github/gh-aw-firewall/agent:latest', 'ghcr.io/github/gh-aw-firewall/cli-proxy:latest', + 'ghcr.io/github/gh-aw-mcpg:v0.2.15', ]); }); @@ -59,6 +60,17 @@ describe('predownload', () => { 'ghcr.io/github/gh-aw-firewall/agent:latest', 'ghcr.io/github/gh-aw-firewall/api-proxy:latest', 'ghcr.io/github/gh-aw-firewall/cli-proxy:latest', + 'ghcr.io/github/gh-aw-mcpg:v0.2.15', + ]); + }); + + it('should use custom mcpg image when specified', () => { + const images = resolveImages({ ...defaults, enableCliProxy: true, cliProxyMcpgImage: 'ghcr.io/github/gh-aw-mcpg:v0.3.0' }); + expect(images).toEqual([ + 'ghcr.io/github/gh-aw-firewall/squid:latest', + 'ghcr.io/github/gh-aw-firewall/agent:latest', + 'ghcr.io/github/gh-aw-firewall/cli-proxy:latest', + 'ghcr.io/github/gh-aw-mcpg:v0.3.0', ]); }); @@ -135,15 +147,20 @@ describe('predownload', () => { ); }); - it('should pull cli-proxy when enabled', async () => { + it('should pull cli-proxy and mcpg when enabled', async () => { await predownloadCommand({ ...defaults, enableCliProxy: true }); - expect(execa).toHaveBeenCalledTimes(3); + expect(execa).toHaveBeenCalledTimes(4); expect(execa).toHaveBeenCalledWith( 'docker', ['pull', 'ghcr.io/github/gh-aw-firewall/cli-proxy:latest'], { stdio: 'inherit' }, ); + expect(execa).toHaveBeenCalledWith( + 'docker', + ['pull', 'ghcr.io/github/gh-aw-mcpg:v0.2.15'], + { stdio: 'inherit' }, + ); }); it('should throw with exitCode 1 when a pull fails', async () => { diff --git a/src/commands/predownload.ts b/src/commands/predownload.ts index ffdeea03d..a89717cc8 100644 --- a/src/commands/predownload.ts +++ b/src/commands/predownload.ts @@ -7,6 +7,7 @@ export interface PredownloadOptions { agentImage: string; enableApiProxy: boolean; enableCliProxy?: boolean; + cliProxyMcpgImage?: string; } /** @@ -48,9 +49,13 @@ export function resolveImages(options: PredownloadOptions): string[] { images.push(`${imageRegistry}/api-proxy:${imageTag}`); } - // Optionally pull cli-proxy + // Optionally pull cli-proxy and its mcpg sidecar if (options.enableCliProxy) { images.push(`${imageRegistry}/cli-proxy:${imageTag}`); + // mcpg runs as a separate container; default or user-specified image + const mcpgImage = options.cliProxyMcpgImage || 'ghcr.io/github/gh-aw-mcpg:v0.2.15'; + validateImageReference(mcpgImage); + images.push(mcpgImage); } return images; diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index 0135e8286..144020a0a 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -2637,11 +2637,13 @@ describe('docker-manager', () => { const mockNetworkConfigWithCliProxy = { ...mockNetworkConfig, cliProxyIp: '172.30.0.50', + cliProxyMcpgIp: '172.30.0.51', }; it('should not include cli-proxy service when enableCliProxy is false', () => { const result = generateDockerCompose(mockConfig, mockNetworkConfigWithCliProxy); expect(result.services['cli-proxy']).toBeUndefined(); + expect(result.services['cli-proxy-mcpg']).toBeUndefined(); }); it('should not include cli-proxy service when enableCliProxy is true but no cliProxyIp', () => { @@ -2650,6 +2652,12 @@ describe('docker-manager', () => { expect(result.services['cli-proxy']).toBeUndefined(); }); + it('should throw when enableCliProxy is true but githubToken is missing', () => { + const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: '' }; + expect(() => generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy)) + .toThrow('--enable-cli-proxy requires a GitHub token'); + }); + it('should include cli-proxy service when enableCliProxy is true with cliProxyIp', () => { const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); @@ -2657,23 +2665,56 @@ describe('docker-manager', () => { const proxy = result.services['cli-proxy']; expect(proxy.container_name).toBe('awf-cli-proxy'); expect((proxy.networks as any)['awf-net'].ipv4_address).toBe('172.30.0.50'); + // Also verify the separate mcpg container + expect(result.services['cli-proxy-mcpg']).toBeDefined(); + const mcpg = result.services['cli-proxy-mcpg']; + expect(mcpg.container_name).toBe('awf-cli-proxy-mcpg'); + expect((mcpg.networks as any)['awf-net'].ipv4_address).toBe('172.30.0.51'); }); - it('should pass GH_TOKEN to cli-proxy environment', () => { + it('should pass GH_TOKEN to cli-proxy-mcpg environment (not cli-proxy)', () => { const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); + // Token goes to the mcpg container, not the HTTP server + const mcpg = result.services['cli-proxy-mcpg']; + const mcpgEnv = mcpg.environment as Record; + expect(mcpgEnv.GH_TOKEN).toBe('ghp_test_token'); + // CLI proxy HTTP server should NOT have the token const proxy = result.services['cli-proxy']; - const env = proxy.environment as Record; - expect(env.GH_TOKEN).toBe('ghp_test_token'); + const proxyEnv = proxy.environment as Record; + expect(proxyEnv.GH_TOKEN).toBeUndefined(); }); - it('should route cli-proxy traffic through Squid', () => { + it('should route cli-proxy-mcpg traffic through Squid', () => { const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); - const proxy = result.services['cli-proxy']; - const env = proxy.environment as Record; - expect(env.HTTP_PROXY).toContain('172.30.0.10:3128'); - expect(env.HTTPS_PROXY).toContain('172.30.0.10:3128'); + // The mcpg container routes through Squid + const mcpg = result.services['cli-proxy-mcpg']; + const mcpgEnv = mcpg.environment as Record; + expect(mcpgEnv.HTTP_PROXY).toContain('172.30.0.10:3128'); + expect(mcpgEnv.HTTPS_PROXY).toContain('172.30.0.10:3128'); + }); + + it('should bind mcpg to its assigned IP (not 0.0.0.0)', () => { + const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; + const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); + const mcpg = result.services['cli-proxy-mcpg']; + const cmd = mcpg.command as string[]; + const listenIdx = cmd.indexOf('--listen'); + expect(listenIdx).toBeGreaterThan(-1); + // Must bind to the specific IP, not 0.0.0.0 + expect(cmd[listenIdx + 1]).toBe('172.30.0.51:18443'); + expect(cmd[listenIdx + 1]).not.toContain('0.0.0.0'); + }); + + it('should use mcpg IP in healthcheck (not localhost)', () => { + const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; + const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); + const mcpg = result.services['cli-proxy-mcpg']; + const healthcheck = (mcpg.healthcheck as any).test as string[]; + // Healthcheck URL must use the mcpg IP for TLS hostname consistency + expect(healthcheck.join(' ')).toContain('https://172.30.0.51:18443'); + expect(healthcheck.join(' ')).not.toContain('localhost'); }); it('should configure healthcheck for cli-proxy', () => { @@ -2739,21 +2780,28 @@ describe('docker-manager', () => { expect(env.AWF_CLI_PROXY_WRITABLE).toBe('true'); }); - it('should pass guard policy JSON when cliProxyPolicy is set', () => { + it('should pass guard policy JSON to mcpg command args when cliProxyPolicy is set', () => { const policy = '{"repos":["owner/repo"],"min-integrity":"public"}'; const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token', cliProxyPolicy: policy }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); - const proxy = result.services['cli-proxy']; - const env = proxy.environment as Record; - expect(env.AWF_GH_GUARD_POLICY).toBe(policy); + const mcpg = result.services['cli-proxy-mcpg']; + // Policy is passed as part of the command array, not an env var + expect(mcpg.command).toBeDefined(); + const cmd = mcpg.command as string[]; + const policyIdx = cmd.indexOf('--policy'); + expect(policyIdx).toBeGreaterThan(-1); + expect(cmd[policyIdx + 1]).toBe(policy); }); - it('should not set AWF_GH_GUARD_POLICY when cliProxyPolicy is not set', () => { + it('should use default guard policy when cliProxyPolicy is not set', () => { const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); - const proxy = result.services['cli-proxy']; - const env = proxy.environment as Record; - expect(env.AWF_GH_GUARD_POLICY).toBeUndefined(); + const mcpg = result.services['cli-proxy-mcpg']; + const cmd = mcpg.command as string[]; + const policyIdx = cmd.indexOf('--policy'); + expect(policyIdx).toBeGreaterThan(-1); + // Default policy should contain min-integrity + expect(cmd[policyIdx + 1]).toContain('min-integrity'); }); it('should use GHCR image by default', () => { @@ -2772,45 +2820,50 @@ describe('docker-manager', () => { expect(proxy.image).toBeUndefined(); }); - it('should not pass MCPG_IMAGE build arg when cliProxyMcpgImage is not set', () => { - const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token', buildLocal: true }; - const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); - const proxy = result.services['cli-proxy']; - expect((proxy.build as any).args).toBeUndefined(); - }); - - it('should pass MCPG_IMAGE build arg when cliProxyMcpgImage is set with --build-local', () => { + it('should use mcpg image for cli-proxy-mcpg service', () => { const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token', - buildLocal: true, cliProxyMcpgImage: 'ghcr.io/github/gh-aw-mcpg:v0.3.0', }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); - const proxy = result.services['cli-proxy']; - expect((proxy.build as any).args).toEqual({ MCPG_IMAGE: 'ghcr.io/github/gh-aw-mcpg:v0.3.0' }); + const mcpg = result.services['cli-proxy-mcpg']; + expect(mcpg.image).toBe('ghcr.io/github/gh-aw-mcpg:v0.3.0'); }); - it('should ignore cliProxyMcpgImage when not using --build-local (pre-built GHCR image)', () => { + it('should use default mcpg image when cliProxyMcpgImage is not set', () => { const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token', - buildLocal: false, - cliProxyMcpgImage: 'ghcr.io/github/gh-aw-mcpg:v0.3.0', }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); - const proxy = result.services['cli-proxy']; - // Pre-built GHCR image already contains mcpg; build arg has no effect - expect(proxy.image).toContain('cli-proxy'); - expect(proxy.build).toBeUndefined(); + const mcpg = result.services['cli-proxy-mcpg']; + expect(mcpg.image).toContain('gh-aw-mcpg'); }); it('should not include cli-proxy when cliProxyIp is missing from networkConfig', () => { const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfig); expect(result.services['cli-proxy']).toBeUndefined(); + expect(result.services['cli-proxy-mcpg']).toBeUndefined(); + }); + + it('should add cli-proxy-tls named volume when cli-proxy is enabled', () => { + const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; + const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); + expect(result.volumes).toBeDefined(); + expect(result.volumes!['cli-proxy-tls']).toBeDefined(); + }); + + it('should configure cli-proxy to connect to mcpg container', () => { + const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; + const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); + const proxy = result.services['cli-proxy']; + const env = proxy.environment as Record; + expect(env.AWF_MCPG_HOST).toBe('172.30.0.51'); + expect(env.AWF_MCPG_PORT).toBe('18443'); }); }); }); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index f62f33018..bfe7deb80 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -22,6 +22,7 @@ const IPTABLES_INIT_CONTAINER_NAME = 'awf-iptables-init'; const API_PROXY_CONTAINER_NAME = 'awf-api-proxy'; const DOH_PROXY_CONTAINER_NAME = 'awf-doh-proxy'; const CLI_PROXY_CONTAINER_NAME = 'awf-cli-proxy'; +const CLI_PROXY_MCPG_CONTAINER_NAME = 'awf-cli-proxy-mcpg'; /** * Flag set by fastKillAgentContainer() to signal runAgentCommand() that @@ -377,7 +378,7 @@ export interface SslConfig { */ export function generateDockerCompose( config: WrapperConfig, - networkConfig: { subnet: string; squidIp: string; agentIp: string; proxyIp?: string; dohProxyIp?: string; cliProxyIp?: string }, + networkConfig: { subnet: string; squidIp: string; agentIp: string; proxyIp?: string; dohProxyIp?: string; cliProxyIp?: string; cliProxyMcpgIp?: string }, sslConfig?: SslConfig, squidConfigContent?: string ): DockerComposeConfig { @@ -1624,6 +1625,99 @@ export function generateDockerCompose( // Add CLI proxy sidecar if enabled if (config.enableCliProxy && networkConfig.cliProxyIp) { + const mcpgIp = networkConfig.cliProxyMcpgIp || '172.30.0.51'; + const mcpgPort = 18443; + const DEFAULT_MCPG_IMAGE = 'ghcr.io/github/gh-aw-mcpg:v0.2.15'; + const mcpgImage = config.cliProxyMcpgImage || DEFAULT_MCPG_IMAGE; + + // Fail-close: refuse to generate cli-proxy-mcpg without a GH_TOKEN. + // mcpg needs a token to authenticate upstream API calls; running without + // one would bypass DIFC enforcement. + if (!config.githubToken) { + throw new Error( + '--enable-cli-proxy requires a GitHub token (GH_TOKEN or --github-token). ' + + 'The mcpg DIFC proxy cannot enforce integrity policies without authentication.' + ); + } + + // Build the guard policy for the mcpg proxy + let guardPolicy = config.cliProxyPolicy || ''; + if (!guardPolicy) { + const repo = process.env.GITHUB_REPOSITORY; + guardPolicy = repo + ? `{"repos":["${repo}"],"min-integrity":"public"}` + : '{"min-integrity":"public"}'; + } + + // --- mcpg DIFC proxy service (runs the official gh-aw-mcpg image directly) --- + const mcpgService: any = { + container_name: CLI_PROXY_MCPG_CONTAINER_NAME, + image: mcpgImage, + // Override entrypoint+command to run in proxy mode (matches gh-aw start_difc_proxy.sh) + entrypoint: ['/app/run.sh'], + command: [ + 'proxy', + '--policy', guardPolicy, + // Bind to the container's assigned IP only — not 0.0.0.0 — so that + // only containers that know the IP can reach it. The agent container + // should never contact mcpg directly; it goes through cli-proxy. + '--listen', `${mcpgIp}:${mcpgPort}`, + '--tls', + '--tls-dir', '/tmp/proxy-tls', + '--guards-mode', 'filter', + '--trusted-bots', 'github-actions[bot],github-actions,dependabot[bot],copilot', + '--log-dir', '/var/log/cli-proxy/mcpg', + ], + networks: { + 'awf-net': { + ipv4_address: mcpgIp, + }, + }, + volumes: [ + // Shared TLS cert volume — mcpg writes certs, cli-proxy reads them + 'cli-proxy-tls:/tmp/proxy-tls:rw', + // Log directory for mcpg audit logs + `${cliProxyLogsPath}/mcpg:/var/log/cli-proxy/mcpg:rw`, + ], + environment: { + // GH_TOKEN held by mcpg — never exposed to agent + GH_TOKEN: config.githubToken, + ...(process.env.GITHUB_REPOSITORY && { GITHUB_REPOSITORY: process.env.GITHUB_REPOSITORY }), + ...(process.env.GITHUB_SERVER_URL && { GITHUB_SERVER_URL: process.env.GITHUB_SERVER_URL }), + // Route upstream API calls through Squid + HTTP_PROXY: `http://${networkConfig.squidIp}:${SQUID_PORT}`, + HTTPS_PROXY: `http://${networkConfig.squidIp}:${SQUID_PORT}`, + https_proxy: `http://${networkConfig.squidIp}:${SQUID_PORT}`, + NO_PROXY: `localhost,127.0.0.1,::1,${mcpgIp}`, + no_proxy: `localhost,127.0.0.1,::1,${mcpgIp}`, + }, + healthcheck: { + // Use the mcpg IP (not localhost) so TLS hostname verification is + // consistent with how cli-proxy connects via GH_HOST. + test: ['CMD', 'curl', '-sf', '--cacert', '/tmp/proxy-tls/ca.crt', `https://${mcpgIp}:${mcpgPort}/api/v3/health`], + interval: '5s', + timeout: '3s', + retries: 5, + start_period: '30s', + }, + depends_on: { + 'squid-proxy': { + condition: 'service_healthy', + }, + }, + cap_drop: ['ALL'], + security_opt: ['no-new-privileges:true'], + mem_limit: '256m', + memswap_limit: '256m', + pids_limit: 50, + cpu_shares: 256, + stop_grace_period: '2s', + }; + + // Add shared TLS volume to the volumes block (added to return below) + services['cli-proxy-mcpg'] = mcpgService; + + // --- CLI proxy HTTP server (Node.js + gh CLI) --- const cliProxyService: any = { container_name: CLI_PROXY_CONTAINER_NAME, networks: { @@ -1632,23 +1726,19 @@ export function generateDockerCompose( }, }, volumes: [ - // Mount log directory for mcpg DIFC proxy audit logs + // Shared TLS cert volume — read certs written by mcpg + 'cli-proxy-tls:/tmp/proxy-tls:ro', + // Log directory for HTTP server logs `${cliProxyLogsPath}:/var/log/cli-proxy:rw`, ], environment: { - // Pass GH_TOKEN to the mcpg DIFC proxy (never exposed to agent) - ...(config.githubToken && { GH_TOKEN: config.githubToken }), - // Pass GITHUB_REPOSITORY so the default guard policy restricts to the current repo + // Tell entrypoint where the mcpg proxy is running + AWF_MCPG_HOST: mcpgIp, + AWF_MCPG_PORT: String(mcpgPort), + // Pass GITHUB_REPOSITORY for GH_REPO default in entrypoint ...(process.env.GITHUB_REPOSITORY && { GITHUB_REPOSITORY: process.env.GITHUB_REPOSITORY }), - ...(process.env.GITHUB_SERVER_URL && { GITHUB_SERVER_URL: process.env.GITHUB_SERVER_URL }), - // Guard policy JSON for mcpg proxy (optional; default generated from GITHUB_REPOSITORY) - ...(config.cliProxyPolicy && { AWF_GH_GUARD_POLICY: config.cliProxyPolicy }), // Enable write mode when --cli-proxy-writable is passed AWF_CLI_PROXY_WRITABLE: String(!!config.cliProxyWritable), - // Route through Squid to respect domain whitelisting - HTTP_PROXY: `http://${networkConfig.squidIp}:${SQUID_PORT}`, - HTTPS_PROXY: `http://${networkConfig.squidIp}:${SQUID_PORT}`, - https_proxy: `http://${networkConfig.squidIp}:${SQUID_PORT}`, // Prevent curl health check from routing localhost through Squid NO_PROXY: `localhost,127.0.0.1,::1`, no_proxy: `localhost,127.0.0.1,::1`, @@ -1658,20 +1748,16 @@ export function generateDockerCompose( interval: '5s', timeout: '3s', retries: 5, - start_period: '30s', // Extra time for mcpg TLS cert generation + start_period: '30s', }, - // Depend on Squid for routing outbound API traffic + // Depend on mcpg for TLS cert and API routing depends_on: { - 'squid-proxy': { + 'cli-proxy-mcpg': { condition: 'service_healthy', }, }, - // Security hardening: Drop all capabilities cap_drop: ['ALL'], - security_opt: [ - 'no-new-privileges:true', - ], - // Resource limits to prevent DoS attacks + security_opt: ['no-new-privileges:true'], mem_limit: '256m', memswap_limit: '256m', pids_limit: 50, @@ -1679,20 +1765,13 @@ export function generateDockerCompose( stop_grace_period: '2s', }; - // Use GHCR image or build locally + // Use GHCR image or build locally for the Node.js HTTP server container if (useGHCR) { cliProxyService.image = `${registry}/cli-proxy:${tag}`; } else { - // When building locally, pass MCPG_IMAGE as a build arg so the compiler - // can control which mcpg version is pulled (mirrors the Dockerfile's ARG default). - const buildArgs: Record = {}; - if (config.cliProxyMcpgImage) { - buildArgs.MCPG_IMAGE = config.cliProxyMcpgImage; - } cliProxyService.build = { context: path.join(projectRoot, 'containers/cli-proxy'), dockerfile: 'Dockerfile', - ...(Object.keys(buildArgs).length > 0 && { args: buildArgs }), }; } @@ -1711,13 +1790,14 @@ export function generateDockerCompose( // Install the gh wrapper in the agent's PATH by symlinking to the pre-installed wrapper // The agent entrypoint uses AWF_CLI_PROXY_URL to know it should activate the wrapper logger.info('CLI proxy sidecar enabled - gh CLI will route through mcpg DIFC proxy'); + logger.info(`CLI proxy mcpg image: ${mcpgImage}`); logger.info('CLI proxy will route through Squid to respect domain whitelisting'); if (config.cliProxyWritable) { logger.info('CLI proxy running in writable mode - write operations permitted'); } } - return { + const composeResult: DockerComposeConfig = { services, networks: { 'awf-net': { @@ -1725,6 +1805,13 @@ export function generateDockerCompose( }, }, }; + + // Add named volumes when cli-proxy-mcpg shares TLS certs with cli-proxy + if (config.enableCliProxy && networkConfig.cliProxyIp) { + composeResult.volumes = { 'cli-proxy-tls': {} }; + } + + return composeResult; } /** @@ -1898,6 +1985,7 @@ export async function writeConfigs(config: WrapperConfig): Promise { proxyIp: '172.30.0.30', // Envoy API proxy sidecar dohProxyIp: '172.30.0.40', // DoH proxy sidecar cliProxyIp: '172.30.0.50', // CLI proxy sidecar + cliProxyMcpgIp: '172.30.0.51', // CLI proxy mcpg DIFC proxy }; logger.debug(`Using network config: ${networkConfig.subnet} (squid: ${networkConfig.squidIp}, agent: ${networkConfig.agentIp}, api-proxy: ${networkConfig.proxyIp})`); diff --git a/src/types.ts b/src/types.ts index a07f64fe0..1ce277e7e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -840,14 +840,14 @@ export interface WrapperConfig { githubToken?: string; /** - * Docker image reference for the mcpg DIFC proxy used inside the CLI proxy sidecar + * Docker image reference for the mcpg DIFC proxy container. * - * Passed as the `MCPG_IMAGE` build argument when building the cli-proxy image - * locally with `--build-local`. Has no effect when using the pre-built GHCR - * cli-proxy image (mcpg is already bundled in that image). + * When `--enable-cli-proxy` is active, the mcpg proxy runs as a separate + * docker-compose service (awf-cli-proxy-mcpg) using this image directly. + * The CLI proxy HTTP server connects to it via the Docker network. * - * The AWF compiler (gh-aw) sets this to control which mcpg version is pulled - * and run, enabling version pinning and testing of new mcpg releases. + * The AWF compiler (gh-aw) sets this to control which mcpg version is used, + * enabling version pinning and testing of new mcpg releases. * * @default 'ghcr.io/github/gh-aw-mcpg:v0.2.15' * @example 'ghcr.io/github/gh-aw-mcpg:v0.3.0' diff --git a/tests/fixtures/cleanup.ts b/tests/fixtures/cleanup.ts index 3ba621d04..c26ab8581 100644 --- a/tests/fixtures/cleanup.ts +++ b/tests/fixtures/cleanup.ts @@ -25,7 +25,7 @@ export class Cleanup { async removeContainers(): Promise { this.log('Removing awf containers by name...'); try { - await execa('docker', ['rm', '-f', 'awf-squid', 'awf-agent', 'awf-api-proxy', 'awf-cli-proxy', 'awf-iptables-init']); + await execa('docker', ['rm', '-f', 'awf-squid', 'awf-agent', 'awf-api-proxy', 'awf-cli-proxy', 'awf-cli-proxy-mcpg', 'awf-iptables-init']); } catch (error) { // Ignore errors (containers may not exist) } From 3e748db404dc18e4ac72e11b92ffedbc6c4aa484 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 02:14:23 +0000 Subject: [PATCH 2/4] fix: align TLS hostname by sharing mcpg network namespace Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/b1a5ac57-6103-45c6-b689-67924f7df25b --- containers/cli-proxy/entrypoint.sh | 18 ++++++++------ src/docker-manager.test.ts | 35 +++++++++++++++++---------- src/docker-manager.ts | 39 ++++++++++++++++-------------- src/types.ts | 15 +++++++++++- 4 files changed, 67 insertions(+), 40 deletions(-) diff --git a/containers/cli-proxy/entrypoint.sh b/containers/cli-proxy/entrypoint.sh index 408ddce65..227f6ff5f 100644 --- a/containers/cli-proxy/entrypoint.sh +++ b/containers/cli-proxy/entrypoint.sh @@ -2,21 +2,21 @@ # CLI Proxy sidecar entrypoint # # The mcpg DIFC proxy runs as a separate docker-compose service -# (awf-cli-proxy-mcpg). This entrypoint waits for the mcpg TLS cert -# (written to a shared volume at /tmp/proxy-tls), configures gh CLI to -# route through the mcpg container, then starts the Node.js HTTP server. +# (awf-cli-proxy-mcpg). This container shares mcpg's network namespace +# (network_mode: service:cli-proxy-mcpg), so localhost resolves to mcpg. +# This ensures the TLS cert's SAN (localhost + 127.0.0.1) matches the +# hostname used by the gh CLI, avoiding TLS hostname verification failures. set -e echo "[cli-proxy] Starting CLI proxy sidecar..." NODE_PID="" -# AWF_MCPG_HOST is set by docker-manager.ts to the mcpg container's IP. -# Fall back to localhost for backward-compatible local testing. -MCPG_HOST="${AWF_MCPG_HOST:-localhost}" +# cli-proxy shares mcpg's network namespace, so mcpg is always at localhost. +# AWF_MCPG_PORT is set by docker-manager.ts. MCPG_PORT="${AWF_MCPG_PORT:-18443}" -echo "[cli-proxy] mcpg proxy at ${MCPG_HOST}:${MCPG_PORT}" +echo "[cli-proxy] mcpg proxy at localhost:${MCPG_PORT}" # Wait for TLS cert to appear in the shared volume (max 30s) echo "[cli-proxy] Waiting for mcpg TLS certificate..." @@ -36,7 +36,9 @@ if [ ! -f /tmp/proxy-tls/ca.crt ]; then fi # Configure gh CLI to route through the mcpg proxy (TLS, self-signed CA) -export GH_HOST="${MCPG_HOST}:${MCPG_PORT}" +# Uses localhost because cli-proxy shares mcpg's network namespace — the +# self-signed cert's SAN covers localhost, so TLS hostname verification passes. +export GH_HOST="localhost:${MCPG_PORT}" export NODE_EXTRA_CA_CERTS="/tmp/proxy-tls/ca.crt" export GH_REPO="${GH_REPO:-$GITHUB_REPOSITORY}" diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index 144020a0a..29b7284e6 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -2664,7 +2664,9 @@ describe('docker-manager', () => { expect(result.services['cli-proxy']).toBeDefined(); const proxy = result.services['cli-proxy']; expect(proxy.container_name).toBe('awf-cli-proxy'); - expect((proxy.networks as any)['awf-net'].ipv4_address).toBe('172.30.0.50'); + // cli-proxy shares mcpg's network namespace — no separate networks config + expect(proxy.network_mode).toBe('service:cli-proxy-mcpg'); + expect(proxy.networks).toBeUndefined(); // Also verify the separate mcpg container expect(result.services['cli-proxy-mcpg']).toBeDefined(); const mcpg = result.services['cli-proxy-mcpg']; @@ -2695,26 +2697,27 @@ describe('docker-manager', () => { expect(mcpgEnv.HTTPS_PROXY).toContain('172.30.0.10:3128'); }); - it('should bind mcpg to its assigned IP (not 0.0.0.0)', () => { + it('should bind mcpg to localhost (only accessible from shared namespace)', () => { const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); const mcpg = result.services['cli-proxy-mcpg']; const cmd = mcpg.command as string[]; const listenIdx = cmd.indexOf('--listen'); expect(listenIdx).toBeGreaterThan(-1); - // Must bind to the specific IP, not 0.0.0.0 - expect(cmd[listenIdx + 1]).toBe('172.30.0.51:18443'); + // Must bind to localhost — cli-proxy shares the namespace and the + // self-signed TLS cert only covers localhost/127.0.0.1 + expect(cmd[listenIdx + 1]).toBe('127.0.0.1:18443'); expect(cmd[listenIdx + 1]).not.toContain('0.0.0.0'); }); - it('should use mcpg IP in healthcheck (not localhost)', () => { + it('should use localhost in mcpg healthcheck (matches TLS cert SAN)', () => { const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); const mcpg = result.services['cli-proxy-mcpg']; const healthcheck = (mcpg.healthcheck as any).test as string[]; - // Healthcheck URL must use the mcpg IP for TLS hostname consistency - expect(healthcheck.join(' ')).toContain('https://172.30.0.51:18443'); - expect(healthcheck.join(' ')).not.toContain('localhost'); + // Healthcheck runs inside mcpg container — must use localhost to match + // the self-signed TLS cert's SAN + expect(healthcheck.join(' ')).toContain('https://localhost:18443'); }); it('should configure healthcheck for cli-proxy', () => { @@ -2746,7 +2749,8 @@ describe('docker-manager', () => { const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); const agent = result.services['agent']; const env = agent.environment as Record; - expect(env.AWF_CLI_PROXY_URL).toBe('http://172.30.0.50:11000'); + // cli-proxy shares mcpg's network namespace, so use mcpg's IP + expect(env.AWF_CLI_PROXY_URL).toBe('http://172.30.0.51:11000'); }); it('should set AWF_CLI_PROXY_IP in agent environment', () => { @@ -2754,14 +2758,16 @@ describe('docker-manager', () => { const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); const agent = result.services['agent']; const env = agent.environment as Record; - expect(env.AWF_CLI_PROXY_IP).toBe('172.30.0.50'); + // cli-proxy shares mcpg's network namespace, so use mcpg's IP + expect(env.AWF_CLI_PROXY_IP).toBe('172.30.0.51'); }); it('should pass AWF_CLI_PROXY_IP to iptables-init environment', () => { const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); const initEnv = result.services['iptables-init'].environment as Record; - expect(initEnv.AWF_CLI_PROXY_IP).toBe('172.30.0.50'); + // cli-proxy shares mcpg's network namespace, so use mcpg's IP + expect(initEnv.AWF_CLI_PROXY_IP).toBe('172.30.0.51'); }); it('should set AWF_CLI_PROXY_WRITABLE=false by default', () => { @@ -2857,13 +2863,16 @@ describe('docker-manager', () => { expect(result.volumes!['cli-proxy-tls']).toBeDefined(); }); - it('should configure cli-proxy to connect to mcpg container', () => { + it('should configure cli-proxy to connect to mcpg via shared network namespace', () => { const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); const proxy = result.services['cli-proxy']; const env = proxy.environment as Record; - expect(env.AWF_MCPG_HOST).toBe('172.30.0.51'); + // AWF_MCPG_HOST should not be set — cli-proxy uses localhost via shared network namespace + expect(env.AWF_MCPG_HOST).toBeUndefined(); expect(env.AWF_MCPG_PORT).toBe('18443'); + // Verify network_mode is used instead of networks + expect(proxy.network_mode).toBe('service:cli-proxy-mcpg'); }); }); }); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index bfe7deb80..b2ae66b92 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -1366,8 +1366,11 @@ export function generateDockerCompose( // Pre-set CLI proxy IP in environment before the init container definition // for the same reason as AWF_API_PROXY_IP above. + // Pre-set CLI proxy IP in environment before the init container definition + // for the same reason as AWF_API_PROXY_IP above. + // cli-proxy shares mcpg's network namespace, so use the mcpg IP. if (config.enableCliProxy && networkConfig.cliProxyIp) { - environment.AWF_CLI_PROXY_IP = networkConfig.cliProxyIp; + environment.AWF_CLI_PROXY_IP = networkConfig.cliProxyMcpgIp || '172.30.0.51'; } // SECURITY: iptables init container - sets up NAT rules in a separate container @@ -1658,10 +1661,10 @@ export function generateDockerCompose( command: [ 'proxy', '--policy', guardPolicy, - // Bind to the container's assigned IP only — not 0.0.0.0 — so that - // only containers that know the IP can reach it. The agent container - // should never contact mcpg directly; it goes through cli-proxy. - '--listen', `${mcpgIp}:${mcpgPort}`, + // Bind to localhost only — cli-proxy shares this container's network + // namespace (network_mode: service:cli-proxy-mcpg), so it can reach + // mcpg via 127.0.0.1. No other container on awf-net can connect. + '--listen', `127.0.0.1:${mcpgPort}`, '--tls', '--tls-dir', '/tmp/proxy-tls', '--guards-mode', 'filter', @@ -1692,9 +1695,9 @@ export function generateDockerCompose( no_proxy: `localhost,127.0.0.1,::1,${mcpgIp}`, }, healthcheck: { - // Use the mcpg IP (not localhost) so TLS hostname verification is - // consistent with how cli-proxy connects via GH_HOST. - test: ['CMD', 'curl', '-sf', '--cacert', '/tmp/proxy-tls/ca.crt', `https://${mcpgIp}:${mcpgPort}/api/v3/health`], + // Use localhost — healthcheck runs inside the mcpg container where + // localhost matches the self-signed TLS cert's SAN. + test: ['CMD', 'curl', '-sf', '--cacert', '/tmp/proxy-tls/ca.crt', `https://localhost:${mcpgPort}/api/v3/health`], interval: '5s', timeout: '3s', retries: 5, @@ -1718,13 +1721,13 @@ export function generateDockerCompose( services['cli-proxy-mcpg'] = mcpgService; // --- CLI proxy HTTP server (Node.js + gh CLI) --- + // Uses network_mode: service:cli-proxy-mcpg to share mcpg's network namespace. + // This allows cli-proxy to connect to mcpg via localhost, matching the TLS + // cert's SAN (localhost + 127.0.0.1) and avoiding hostname mismatch errors. const cliProxyService: any = { container_name: CLI_PROXY_CONTAINER_NAME, - networks: { - 'awf-net': { - ipv4_address: networkConfig.cliProxyIp, - }, - }, + // Share mcpg's network namespace — localhost resolves to mcpg + network_mode: 'service:cli-proxy-mcpg', volumes: [ // Shared TLS cert volume — read certs written by mcpg 'cli-proxy-tls:/tmp/proxy-tls:ro', @@ -1732,8 +1735,8 @@ export function generateDockerCompose( `${cliProxyLogsPath}:/var/log/cli-proxy:rw`, ], environment: { - // Tell entrypoint where the mcpg proxy is running - AWF_MCPG_HOST: mcpgIp, + // mcpg port for the entrypoint to set GH_HOST=localhost:${port} + // AWF_MCPG_HOST is not needed — cli-proxy shares mcpg's network namespace AWF_MCPG_PORT: String(mcpgPort), // Pass GITHUB_REPOSITORY for GH_REPO default in entrypoint ...(process.env.GITHUB_REPOSITORY && { GITHUB_REPOSITORY: process.env.GITHUB_REPOSITORY }), @@ -1783,9 +1786,9 @@ export function generateDockerCompose( }; // Tell the agent how to reach the CLI proxy - // Use IP address instead of hostname since Docker DNS may not resolve in chroot mode - environment.AWF_CLI_PROXY_URL = `http://${networkConfig.cliProxyIp}:${CLI_PROXY_PORT}`; - environment.AWF_CLI_PROXY_IP = networkConfig.cliProxyIp; + // cli-proxy shares mcpg's network namespace, so use mcpg's IP + environment.AWF_CLI_PROXY_URL = `http://${mcpgIp}:${CLI_PROXY_PORT}`; + environment.AWF_CLI_PROXY_IP = mcpgIp; // Install the gh wrapper in the agent's PATH by symlinking to the pre-installed wrapper // The agent entrypoint uses AWF_CLI_PROXY_URL to know it should activate the wrapper diff --git a/src/types.ts b/src/types.ts index 1ce277e7e..b4b4298f2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1207,11 +1207,24 @@ export interface DockerService { * - Object with IPs: { 'awf-net': { ipv4_address: '172.30.0.10' } } - Static IPs * * Static IPs are used to ensure predictable addressing for iptables rules. + * Mutually exclusive with network_mode. * * @example ['awf-net'] * @example { 'awf-net': { ipv4_address: '172.30.0.10' } } */ - networks: string[] | { [key: string]: { ipv4_address?: string } }; + networks?: string[] | { [key: string]: { ipv4_address?: string } }; + + /** + * Network mode for the container + * + * When set to 'service:', the container shares the named service's + * network namespace. This is used when two containers need to communicate + * via localhost (e.g., for TLS cert hostname matching). + * Mutually exclusive with networks. + * + * @example 'service:cli-proxy-mcpg' + */ + network_mode?: string; /** * Custom DNS servers for the container From f7d47cbeceb3344bb8f595aac4e7be703c8d43f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 02:16:05 +0000 Subject: [PATCH 3/4] fix: remove duplicate comment block in docker-manager.ts Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/b1a5ac57-6103-45c6-b689-67924f7df25b --- src/docker-manager.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/docker-manager.ts b/src/docker-manager.ts index b2ae66b92..837e71909 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -1364,8 +1364,6 @@ export function generateDockerCompose( environment.AWF_API_PROXY_IP = networkConfig.proxyIp; } - // Pre-set CLI proxy IP in environment before the init container definition - // for the same reason as AWF_API_PROXY_IP above. // Pre-set CLI proxy IP in environment before the init container definition // for the same reason as AWF_API_PROXY_IP above. // cli-proxy shares mcpg's network namespace, so use the mcpg IP. From b808f24a8aa04d19d4c779bdf689b46340d3a247 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 19:35:35 -0700 Subject: [PATCH 4/4] fix: add retry logic to apt-get upgrade in agent Dockerfile (#1781) Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/1831b666-eb93-4772-9455-4604a64bfd24 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- containers/agent/Dockerfile | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/containers/agent/Dockerfile b/containers/agent/Dockerfile index 8b6ab2251..f3e09c514 100644 --- a/containers/agent/Dockerfile +++ b/containers/agent/Dockerfile @@ -50,7 +50,11 @@ RUN set -eux; \ # Upgrade all packages to pick up security patches # Addresses CVE-2023-44487 (HTTP/2 Rapid Reset) and other known vulnerabilities -RUN apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/* +# Retry logic handles transient mirror sync failures during apt-get update +RUN apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/* || \ + (echo "apt-get upgrade failed, retrying with fresh package index..." && \ + rm -rf /var/lib/apt/lists/* && \ + apt-get update && apt-get upgrade -y && rm -rf /var/lib/apt/lists/*) # Create non-root user with UID/GID matching host user # This allows the user command to run with appropriate permissions