diff --git a/containers/agent/gh-cli-proxy-wrapper.sh b/containers/agent/gh-cli-proxy-wrapper.sh index 3d120b71..92dd9a71 100644 --- a/containers/agent/gh-cli-proxy-wrapper.sh +++ b/containers/agent/gh-cli-proxy-wrapper.sh @@ -2,7 +2,7 @@ # /usr/local/bin/gh-cli-proxy-wrapper # Forwards gh CLI invocations to the CLI proxy sidecar over HTTP. # This wrapper is installed at /usr/local/bin/gh in the agent container -# when --enable-cli-proxy is active, so it takes precedence over any +# when --difc-proxy-host is active, so it takes precedence over any # host-mounted gh binary at /host/usr/bin/gh. # # Dependencies: curl, jq (both available in the agent container) diff --git a/containers/agent/setup-iptables.sh b/containers/agent/setup-iptables.sh index 3fa0f568..a87130ce 100644 --- a/containers/agent/setup-iptables.sh +++ b/containers/agent/setup-iptables.sh @@ -174,7 +174,7 @@ if [ -n "$AWF_API_PROXY_IP" ]; then fi # Allow traffic to CLI proxy sidecar (when enabled) -# AWF_CLI_PROXY_IP is set by docker-manager.ts when --enable-cli-proxy is used +# AWF_CLI_PROXY_IP is set by docker-manager.ts when --difc-proxy-host is used if [ -n "$AWF_CLI_PROXY_IP" ]; then echo "[iptables] Allow traffic to CLI proxy sidecar (${AWF_CLI_PROXY_IP})..." iptables -t nat -A OUTPUT -d "$AWF_CLI_PROXY_IP" -j RETURN diff --git a/containers/cli-proxy/Dockerfile b/containers/cli-proxy/Dockerfile index 52b4bc01..7abe7394 100644 --- a/containers/cli-proxy/Dockerfile +++ b/containers/cli-proxy/Dockerfile @@ -1,10 +1,9 @@ -# CLI Proxy sidecar for AWF - provides gh CLI access via mcpg DIFC proxy +# CLI Proxy sidecar for AWF - provides gh CLI access via external DIFC proxy # # 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. +# invocations from the agent container. The DIFC proxy (mcpg) runs +# externally on the host, started by the gh-aw compiler. A TCP tunnel +# forwards localhost traffic to the external proxy for TLS hostname matching. FROM node:22-alpine # Install gh CLI and curl for healthchecks/wrapper @@ -25,7 +24,7 @@ COPY package*.json ./ RUN npm ci --omit=dev # Copy application files -COPY server.js ./ +COPY server.js tcp-tunnel.js ./ COPY entrypoint.sh /usr/local/bin/entrypoint.sh COPY healthcheck.sh /usr/local/bin/healthcheck.sh @@ -38,7 +37,7 @@ RUN addgroup -S cliproxy && adduser -S cliproxy -G cliproxy RUN mkdir -p /var/log/cli-proxy && \ chown -R cliproxy:cliproxy /var/log/cli-proxy -# Create /tmp/proxy-tls directory owned by cliproxy for shared mcpg TLS certs +# Create /tmp/proxy-tls directory owned by cliproxy for mounted DIFC proxy CA cert 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 227f6ff5..209c8cd7 100644 --- a/containers/cli-proxy/entrypoint.sh +++ b/containers/cli-proxy/entrypoint.sh @@ -1,56 +1,61 @@ #!/bin/bash # CLI Proxy sidecar entrypoint # -# The mcpg DIFC proxy runs as a separate docker-compose service -# (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. +# Connects to an external DIFC proxy (mcpg) started by the gh-aw compiler +# on the host. Uses a TCP tunnel to forward localhost:${DIFC_PORT} to +# ${DIFC_HOST}:${DIFC_PORT}, so the gh CLI can connect via localhost +# (matching the DIFC proxy's TLS cert SAN for localhost/127.0.0.1). set -e echo "[cli-proxy] Starting CLI proxy sidecar..." NODE_PID="" +TUNNEL_PID="" -# 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}" +# External DIFC proxy host and port, set by docker-manager.ts +DIFC_HOST="${AWF_DIFC_PROXY_HOST:-host.docker.internal}" +DIFC_PORT="${AWF_DIFC_PROXY_PORT:-18443}" -echo "[cli-proxy] mcpg proxy at localhost:${MCPG_PORT}" +echo "[cli-proxy] External DIFC proxy at ${DIFC_HOST}:${DIFC_PORT}" -# 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 - if [ -f /tmp/proxy-tls/ca.crt ]; then - echo "[cli-proxy] TLS certificate available" - break - fi - sleep 1 - i=$((i + 1)) -done +# Start the TCP tunnel: localhost:${DIFC_PORT} → ${DIFC_HOST}:${DIFC_PORT} +# This allows the gh CLI to connect via localhost, matching the cert's SAN. +echo "[cli-proxy] Starting TCP tunnel: localhost:${DIFC_PORT} → ${DIFC_HOST}:${DIFC_PORT}" +node /app/tcp-tunnel.js "${DIFC_PORT}" "${DIFC_HOST}" "${DIFC_PORT}" & +TUNNEL_PID=$! +# Verify CA cert is available (bind-mounted from host by docker-manager.ts). +# Unlike the old architecture where mcpg generated the cert at runtime, the +# external DIFC proxy has already created the cert before AWF starts, so the +# bind mount makes it immediately available — no polling needed. if [ ! -f /tmp/proxy-tls/ca.crt ]; then - echo "[cli-proxy] ERROR: mcpg TLS certificate not found within 30s" + echo "[cli-proxy] ERROR: DIFC proxy TLS certificate not found at /tmp/proxy-tls/ca.crt" + echo "[cli-proxy] Ensure --difc-proxy-ca-cert points to a valid CA cert file on the host" exit 1 fi +echo "[cli-proxy] TLS certificate available" -# Configure gh CLI to route through the mcpg proxy (TLS, self-signed CA) -# 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" +# Configure gh CLI to route through the DIFC proxy via the TCP tunnel +# Uses localhost because the tunnel makes the DIFC proxy appear on localhost, +# matching the self-signed cert's SAN. +export GH_HOST="localhost:${DIFC_PORT}" export GH_REPO="${GH_REPO:-$GITHUB_REPOSITORY}" +# The CA cert is guaranteed to exist at this point (we exit above if missing) +export NODE_EXTRA_CA_CERTS="/tmp/proxy-tls/ca.crt" -echo "[cli-proxy] gh CLI configured to route through mcpg proxy at ${GH_HOST}" +echo "[cli-proxy] gh CLI configured to route through DIFC proxy at ${GH_HOST}" -# Cleanup handler: stop the Node HTTP server on signal +# Cleanup handler: stop the Node HTTP server and TCP tunnel 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 "$TUNNEL_PID" ]; then + kill "$TUNNEL_PID" 2>/dev/null || true + wait "$TUNNEL_PID" 2>/dev/null || true + fi } trap 'cleanup; exit 0' INT TERM diff --git a/containers/cli-proxy/server.js b/containers/cli-proxy/server.js index 71505cbf..2f82333a 100644 --- a/containers/cli-proxy/server.js +++ b/containers/cli-proxy/server.js @@ -7,13 +7,14 @@ * POST /exec - Execute a gh CLI command and return stdout/stderr/exitCode * * Security: - * - Subcommand allowlist enforced (read-only mode by default) * - Args are exec'd directly via execFile (no shell, no injection) * - Per-command timeout (default 30s) * - Max output size limit to prevent memory exhaustion + * - Meta-commands (auth, config, extension) are always denied * - * The gh CLI running inside this container has GH_HOST set to the mcpg proxy - * (localhost:18443), so it never sees GH_TOKEN directly. + * The gh CLI running inside this container has GH_HOST set to the DIFC proxy + * (localhost:18443 via TCP tunnel), so it never sees GH_TOKEN directly. + * Write control is handled by the DIFC guard policy, not by this server. */ const http = require('http'); @@ -23,77 +24,26 @@ const CLI_PROXY_PORT = parseInt(process.env.AWF_CLI_PROXY_PORT || '11000', 10); const COMMAND_TIMEOUT_MS = parseInt(process.env.AWF_CLI_PROXY_TIMEOUT_MS || '30000', 10); const MAX_OUTPUT_BYTES = parseInt(process.env.AWF_CLI_PROXY_MAX_OUTPUT_BYTES || String(10 * 1024 * 1024), 10); -// When AWF_CLI_PROXY_WRITABLE=true, allow write operations -const WRITABLE_MODE = process.env.AWF_CLI_PROXY_WRITABLE === 'true'; - -/** - * Subcommands allowed in read-only mode. - * These commands only retrieve data and do not modify any GitHub resources. - * - * Note: 'api' is intentionally excluded even in read-only mode because it is a raw - * HTTP passthrough that can perform arbitrary POST/PUT/DELETE mutations via -X/--method. - * Agents should use typed subcommands (gh issue list, gh pr view, etc.) instead. - * In writable mode, 'api' is permitted since the operator has explicitly opted in. - */ -const ALLOWED_SUBCOMMANDS_READONLY = new Set([ - 'browse', - 'cache', - 'codespace', - 'gist', - 'issue', - 'label', - 'org', - 'pr', - 'release', - 'repo', - 'run', - 'search', - 'secret', - 'variable', - 'workflow', -]); - -/** - * Actions that are blocked within their parent subcommand in read-only mode. - * Maps subcommand -> Set of blocked action verbs. - */ -const BLOCKED_ACTIONS_READONLY = new Map([ - // cache: delete is a write operation - ['cache', new Set(['delete'])], - // codespace: create, delete, edit, stop, ports forward are write operations - ['codespace', new Set(['create', 'delete', 'edit', 'stop', 'ports'])], - ['gist', new Set(['create', 'delete', 'edit'])], - ['issue', new Set(['create', 'close', 'delete', 'edit', 'lock', 'pin', 'reopen', 'transfer', 'unpin'])], - ['label', new Set(['create', 'delete', 'edit'])], - // org: invite changes org membership - ['org', new Set(['invite'])], - ['pr', new Set(['checkout', 'close', 'create', 'edit', 'lock', 'merge', 'ready', 'reopen', 'review', 'update-branch'])], - ['release', new Set(['create', 'delete', 'delete-asset', 'edit', 'upload'])], - ['repo', new Set(['archive', 'create', 'delete', 'edit', 'fork', 'rename', 'set-default', 'sync', 'unarchive'])], - ['run', new Set(['cancel', 'delete', 'download', 'rerun'])], - ['secret', new Set(['delete', 'set'])], - ['variable', new Set(['delete', 'set'])], - ['workflow', new Set(['disable', 'enable', 'run'])], -]); - /** - * Meta-commands that are always denied, even in write mode. + * Meta-commands that are always denied. * These modify gh itself rather than GitHub resources. */ const ALWAYS_DENIED_SUBCOMMANDS = new Set([ + 'alias', 'auth', 'config', 'extension', ]); /** - * Validates the gh CLI arguments against the subcommand allowlist. + * Validates the gh CLI arguments. + * Write control is handled by the DIFC guard policy — this server only + * blocks meta-commands that modify gh CLI itself. * * @param {string[]} args - The argument array (excluding 'gh' itself) - * @param {boolean} writable - Whether write operations are permitted * @returns {{ valid: boolean, error?: string }} */ -function validateArgs(args, writable) { +function validateArgs(args) { if (!Array.isArray(args)) { return { valid: false, error: 'args must be an array' }; } @@ -105,13 +55,7 @@ function validateArgs(args, writable) { } // Find the subcommand by scanning through args, skipping flags and their values. - // Handles patterns like: gh --repo owner/repo pr list - // Strategy: when we see --flag (without =), assume the next non-flag-like arg is its value. - // We also track the subcommand's index so that subsequent action detection doesn't - // accidentally pick up a flag value that happens to equal the subcommand string - // (e.g. gh --repo pr pr merge 1 would be wrongly parsed by indexOf). let subcommand = null; - let subcommandIndex = -1; let i = 0; while (i < args.length) { const arg = args[i]; @@ -125,7 +69,6 @@ function validateArgs(args, writable) { } } else { subcommand = arg; - subcommandIndex = i; break; } } @@ -140,28 +83,6 @@ function validateArgs(args, writable) { return { valid: false, error: `Subcommand '${subcommand}' is not permitted` }; } - if (!writable) { - // Read-only mode: check allowlist - if (!ALLOWED_SUBCOMMANDS_READONLY.has(subcommand)) { - return { valid: false, error: `Subcommand '${subcommand}' is not allowed in read-only mode. Enable write mode with --cli-proxy-writable.` }; - } - - // Check action-level blocklist - const blockedActions = BLOCKED_ACTIONS_READONLY.get(subcommand); - if (blockedActions) { - // The action is the first non-flag argument after the subcommand. - // Use the tracked subcommandIndex (not indexOf) to avoid false matches when - // the subcommand string also appears as a flag value earlier in the args array. - const action = args.slice(subcommandIndex + 1).find(a => !a.startsWith('-')); - if (action && blockedActions.has(action)) { - return { - valid: false, - error: `Action '${subcommand} ${action}' is not allowed in read-only mode. Enable write mode with --cli-proxy-writable.`, - }; - } - } - } - return { valid: true }; } @@ -221,7 +142,7 @@ function sendError(res, statusCode, message) { * Handle GET /health */ function handleHealth(res) { - const body = JSON.stringify({ status: 'ok', service: 'cli-proxy', writable: WRITABLE_MODE }); + const body = JSON.stringify({ status: 'ok', service: 'cli-proxy' }); res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), @@ -261,7 +182,7 @@ async function handleExec(req, res) { const { args, cwd, stdin, env: extraEnv } = body; // Validate args - const validation = validateArgs(args, WRITABLE_MODE); + const validation = validateArgs(args); if (!validation.valid) { return sendError(res, 403, validation.error); } @@ -364,7 +285,7 @@ if (require.main === module) { }); server.listen(CLI_PROXY_PORT, '0.0.0.0', () => { - console.log(`[cli-proxy] HTTP server listening on port ${CLI_PROXY_PORT} (writable=${WRITABLE_MODE})`); + console.log(`[cli-proxy] HTTP server listening on port ${CLI_PROXY_PORT}`); }); server.on('error', err => { @@ -373,4 +294,4 @@ if (require.main === module) { }); } -module.exports = { validateArgs, ALLOWED_SUBCOMMANDS_READONLY, BLOCKED_ACTIONS_READONLY, ALWAYS_DENIED_SUBCOMMANDS }; +module.exports = { validateArgs, ALWAYS_DENIED_SUBCOMMANDS }; diff --git a/containers/cli-proxy/server.test.js b/containers/cli-proxy/server.test.js index 64782cbf..115b4087 100644 --- a/containers/cli-proxy/server.test.js +++ b/containers/cli-proxy/server.test.js @@ -1,326 +1,166 @@ 'use strict'; /** * Tests for cli-proxy server.js + * + * Write control is now handled by the external DIFC guard policy. + * The server only enforces meta-command denial (auth, config, extension). */ -const { validateArgs, ALLOWED_SUBCOMMANDS_READONLY, BLOCKED_ACTIONS_READONLY, ALWAYS_DENIED_SUBCOMMANDS } = require('./server'); +const { validateArgs, ALWAYS_DENIED_SUBCOMMANDS } = require('./server'); describe('validateArgs', () => { describe('input validation', () => { it('should reject non-array args', () => { - const result = validateArgs('pr list', false); + const result = validateArgs('pr list'); expect(result.valid).toBe(false); expect(result.error).toContain('array'); }); it('should reject args with non-string elements', () => { - const result = validateArgs(['pr', 42], false); + const result = validateArgs(['pr', 42]); expect(result.valid).toBe(false); expect(result.error).toContain('strings'); }); it('should allow empty args array', () => { - const result = validateArgs([], false); + const result = validateArgs([]); expect(result.valid).toBe(true); }); it('should allow flags-only args (e.g. --version)', () => { - const result = validateArgs(['--version'], false); + const result = validateArgs(['--version']); expect(result.valid).toBe(true); }); it('should allow --help flag', () => { - const result = validateArgs(['--help'], false); + const result = validateArgs(['--help']); expect(result.valid).toBe(true); }); }); describe('always-denied subcommands', () => { for (const cmd of ALWAYS_DENIED_SUBCOMMANDS) { - it(`should deny '${cmd}' even in writable mode`, () => { - const result = validateArgs([cmd], true); + it(`should deny '${cmd}'`, () => { + const result = validateArgs([cmd]); expect(result.valid).toBe(false); expect(result.error).toContain(cmd); }); - - it(`should deny '${cmd}' in read-only mode`, () => { - const result = validateArgs([cmd], false); - expect(result.valid).toBe(false); - }); } }); - describe('read-only mode', () => { - it('should allow all subcommands in the allowlist', () => { - for (const cmd of ALLOWED_SUBCOMMANDS_READONLY) { - // Use 'list' as the action (safe for all) - const result = validateArgs([cmd, 'list'], false); - expect(result.valid).toBe(true); - } - }); - - it('should deny unknown subcommands', () => { - const result = validateArgs(['unknown-subcommand'], false); - expect(result.valid).toBe(false); - expect(result.error).toContain('read-only mode'); - }); - - it('should deny pr create', () => { - const result = validateArgs(['pr', 'create', '--title', 'My PR'], false); - expect(result.valid).toBe(false); - expect(result.error).toContain('pr create'); - }); - - it('should deny pr merge', () => { - const result = validateArgs(['pr', 'merge', '42'], false); - expect(result.valid).toBe(false); - }); - + describe('allowed subcommands (DIFC guard policy handles write control)', () => { it('should allow pr list', () => { - const result = validateArgs(['pr', 'list', '--json', 'number,title'], false); + const result = validateArgs(['pr', 'list', '--json', 'number,title']); expect(result.valid).toBe(true); }); it('should allow pr view', () => { - const result = validateArgs(['pr', 'view', '42'], false); + const result = validateArgs(['pr', 'view', '42']); expect(result.valid).toBe(true); }); - it('should deny issue create', () => { - const result = validateArgs(['issue', 'create', '--title', 'Bug'], false); - expect(result.valid).toBe(false); + it('should allow pr create (guard policy handles write control)', () => { + const result = validateArgs(['pr', 'create', '--title', 'My PR']); + expect(result.valid).toBe(true); }); - it('should allow issue list', () => { - const result = validateArgs(['issue', 'list'], false); + it('should allow pr merge (guard policy handles write control)', () => { + const result = validateArgs(['pr', 'merge', '42']); expect(result.valid).toBe(true); }); - it('should allow issue view', () => { - const result = validateArgs(['issue', 'view', '1'], false); + it('should allow issue list', () => { + const result = validateArgs(['issue', 'list']); expect(result.valid).toBe(true); }); - it('should deny repo create', () => { - const result = validateArgs(['repo', 'create'], false); - expect(result.valid).toBe(false); + it('should allow issue create (guard policy handles write control)', () => { + const result = validateArgs(['issue', 'create', '--title', 'Bug']); + expect(result.valid).toBe(true); }); it('should allow repo view', () => { - const result = validateArgs(['repo', 'view', 'owner/repo'], false); + const result = validateArgs(['repo', 'view', 'owner/repo']); expect(result.valid).toBe(true); }); - it('should deny api in read-only mode (raw passthrough can mutate via -X POST)', () => { - const result = validateArgs(['api', 'repos/owner/repo'], false); - expect(result.valid).toBe(false); + it('should allow api subcommand (guard policy handles write control)', () => { + const result = validateArgs(['api', 'repos/owner/repo']); + expect(result.valid).toBe(true); }); - it('should deny api POST in read-only mode', () => { - const result = validateArgs(['api', '-X', 'POST', '/repos/owner/repo/issues', '-f', 'title=Test'], false); - expect(result.valid).toBe(false); + it('should allow api POST (guard policy handles write control)', () => { + const result = validateArgs(['api', '-X', 'POST', '/repos/owner/repo/issues', '-f', 'title=Test']); + expect(result.valid).toBe(true); }); it('should allow search', () => { - const result = validateArgs(['search', 'issues', '--query', 'bug'], false); + const result = validateArgs(['search', 'issues', '--query', 'bug']); expect(result.valid).toBe(true); }); it('should allow workflow list', () => { - const result = validateArgs(['workflow', 'list'], false); + const result = validateArgs(['workflow', 'list']); expect(result.valid).toBe(true); }); - it('should deny workflow run', () => { - const result = validateArgs(['workflow', 'run', 'ci.yml'], false); - expect(result.valid).toBe(false); - }); - - it('should deny workflow enable', () => { - const result = validateArgs(['workflow', 'enable', 'ci.yml'], false); - expect(result.valid).toBe(false); - }); - - it('should deny secret set', () => { - const result = validateArgs(['secret', 'set', 'MY_SECRET'], false); - expect(result.valid).toBe(false); + it('should allow workflow run (guard policy handles write control)', () => { + const result = validateArgs(['workflow', 'run', 'ci.yml']); + expect(result.valid).toBe(true); }); it('should allow secret list', () => { - const result = validateArgs(['secret', 'list'], false); + const result = validateArgs(['secret', 'list']); expect(result.valid).toBe(true); }); it('should allow run list', () => { - const result = validateArgs(['run', 'list'], false); + const result = validateArgs(['run', 'list']); expect(result.valid).toBe(true); }); - it('should deny run cancel', () => { - const result = validateArgs(['run', 'cancel', '123'], false); - expect(result.valid).toBe(false); - }); - it('should allow release list', () => { - const result = validateArgs(['release', 'list'], false); + const result = validateArgs(['release', 'list']); expect(result.valid).toBe(true); }); - it('should deny release create', () => { - const result = validateArgs(['release', 'create', 'v1.0.0'], false); - expect(result.valid).toBe(false); - }); - - it('should deny gist create', () => { - const result = validateArgs(['gist', 'create', 'file.txt'], false); - expect(result.valid).toBe(false); - }); - it('should allow gist view', () => { - const result = validateArgs(['gist', 'view', 'abc123'], false); + const result = validateArgs(['gist', 'view', 'abc123']); expect(result.valid).toBe(true); }); it('should handle flags before subcommand gracefully', () => { // e.g.: gh --repo owner/repo pr list - const result = validateArgs(['--repo', 'owner/repo', 'pr', 'list'], false); - expect(result.valid).toBe(true); - }); - - it('should handle flags before action gracefully', () => { - // e.g.: gh pr --json number list - const result = validateArgs(['pr', '--json', 'number', 'list'], false); - expect(result.valid).toBe(true); - }); - - it('should deny cache delete', () => { - const result = validateArgs(['cache', 'delete', 'some-key'], false); - expect(result.valid).toBe(false); - }); - - it('should allow cache list', () => { - const result = validateArgs(['cache', 'list'], false); - expect(result.valid).toBe(true); - }); - - it('should deny codespace create', () => { - const result = validateArgs(['codespace', 'create'], false); - expect(result.valid).toBe(false); - }); - - it('should deny codespace delete', () => { - const result = validateArgs(['codespace', 'delete', '--all'], false); - expect(result.valid).toBe(false); - }); - - it('should allow codespace list', () => { - const result = validateArgs(['codespace', 'list'], false); - expect(result.valid).toBe(true); - }); - - it('should deny org invite', () => { - const result = validateArgs(['org', 'invite', '--org', 'myorg', 'user'], false); - expect(result.valid).toBe(false); - }); - - it('should allow org list', () => { - const result = validateArgs(['org', 'list'], false); - expect(result.valid).toBe(true); - }); - - it('should not bypass blocked action when subcommand appears as a flag value (indexOf bypass)', () => { - // Without the subcommandIndex fix, 'args.indexOf("pr")' would return index 1 (the flag value), - // and args.slice(2) = ['pr', 'merge', '1'], finding 'merge' as the action → blocked. - // With the correct index (3), slice(4) = ['merge', '1'] → still blocked. But if the - // subcommand were a read-only action, the old code would use the wrong index. - // Here we verify that gh --repo pr pr list is still allowed (subcommand is at index 3). - const result = validateArgs(['--repo', 'pr', 'pr', 'list'], false); + const result = validateArgs(['--repo', 'owner/repo', 'pr', 'list']); expect(result.valid).toBe(true); }); - - it('should correctly detect blocked action even when subcommand appears earlier as flag value', () => { - // gh --repo pr pr merge 1: subcommand 'pr' is at index 2 (flag value 'pr' at index 1 is skipped) - const result = validateArgs(['--repo', 'pr', 'pr', 'merge', '1'], false); - expect(result.valid).toBe(false); - }); }); - describe('writable mode', () => { - it('should allow pr create in writable mode', () => { - const result = validateArgs(['pr', 'create', '--title', 'My PR'], true); - expect(result.valid).toBe(true); - }); - - it('should allow issue create in writable mode', () => { - const result = validateArgs(['issue', 'create', '--title', 'Bug'], true); - expect(result.valid).toBe(true); - }); - - it('should allow repo create in writable mode', () => { - const result = validateArgs(['repo', 'create', 'new-repo'], true); - expect(result.valid).toBe(true); - }); - - it('should allow secret set in writable mode', () => { - const result = validateArgs(['secret', 'set', 'MY_SECRET'], true); - expect(result.valid).toBe(true); - }); - - it('should still deny auth in writable mode', () => { - const result = validateArgs(['auth', 'login'], true); + describe('meta-command denial', () => { + it('should deny alias set (shell exec bypass)', () => { + const result = validateArgs(['alias', 'set', 'myalias', '!echo pwned']); expect(result.valid).toBe(false); }); - it('should still deny config in writable mode', () => { - const result = validateArgs(['config', 'set', 'editor', 'vim'], true); + it('should deny auth login', () => { + const result = validateArgs(['auth', 'login']); expect(result.valid).toBe(false); }); - it('should still deny extension in writable mode', () => { - const result = validateArgs(['extension', 'install', 'owner/ext'], true); + it('should deny config set', () => { + const result = validateArgs(['config', 'set', 'editor', 'vim']); expect(result.valid).toBe(false); }); - it('should allow api in writable mode (operator opted in to writes)', () => { - const result = validateArgs(['api', 'repos/owner/repo'], true); - expect(result.valid).toBe(true); - }); - - it('should allow api POST in writable mode', () => { - const result = validateArgs(['api', '-X', 'POST', '/repos/owner/repo/issues'], true); - expect(result.valid).toBe(true); - }); - - it('should allow all read-only subcommands in writable mode', () => { - for (const cmd of ALLOWED_SUBCOMMANDS_READONLY) { - const result = validateArgs([cmd, 'list'], true); - expect(result.valid).toBe(true); - } - }); - - it('should allow previously blocked actions in writable mode', () => { - for (const [subcommand, blockedActions] of BLOCKED_ACTIONS_READONLY) { - for (const action of blockedActions) { - const result = validateArgs([subcommand, action], true); - expect(result.valid).toBe(true); - } - } + it('should deny extension install', () => { + const result = validateArgs(['extension', 'install', 'owner/ext']); + expect(result.valid).toBe(false); }); }); describe('allowlist completeness', () => { - it('should have ALLOWED_SUBCOMMANDS_READONLY as a non-empty Set', () => { - expect(ALLOWED_SUBCOMMANDS_READONLY.size).toBeGreaterThan(0); - }); - it('should have ALWAYS_DENIED_SUBCOMMANDS as a non-empty Set', () => { expect(ALWAYS_DENIED_SUBCOMMANDS.size).toBeGreaterThan(0); }); - - it('should have no overlap between ALLOWED_SUBCOMMANDS_READONLY and ALWAYS_DENIED_SUBCOMMANDS', () => { - for (const cmd of ALWAYS_DENIED_SUBCOMMANDS) { - expect(ALLOWED_SUBCOMMANDS_READONLY.has(cmd)).toBe(false); - } - }); }); }); diff --git a/containers/cli-proxy/tcp-tunnel.js b/containers/cli-proxy/tcp-tunnel.js new file mode 100644 index 00000000..71af6ec4 --- /dev/null +++ b/containers/cli-proxy/tcp-tunnel.js @@ -0,0 +1,56 @@ +'use strict'; +/** + * TCP tunnel for TLS hostname matching. + * + * The external DIFC proxy's self-signed TLS cert has SANs for localhost + * and 127.0.0.1, but not host.docker.internal. This tunnel forwards + * localhost:localPort → remoteHost:remotePort so that the gh CLI can + * connect to localhost (matching the cert's SAN) while the actual + * traffic goes to the external DIFC proxy on the host. + * + * Usage: node tcp-tunnel.js + */ + +const net = require('net'); + +function sanitizeForLog(value) { + return String(value).replace(/[\r\n]/g, ''); +} + +const localPortStr = process.argv[2]; +const remoteHost = process.argv[3]; +const remotePortStr = process.argv[4]; + +if (!localPortStr || !remoteHost || !remotePortStr) { + console.error('[tcp-tunnel] Usage: node tcp-tunnel.js '); + process.exit(1); +} + +const localPort = parseInt(localPortStr, 10); +const remotePort = parseInt(remotePortStr, 10); + +if (isNaN(localPort) || localPort < 1 || localPort > 65535) { + console.error(`[tcp-tunnel] Invalid localPort: ${localPortStr}`); + process.exit(1); +} +if (isNaN(remotePort) || remotePort < 1 || remotePort > 65535) { + console.error(`[tcp-tunnel] Invalid remotePort: ${remotePortStr}`); + process.exit(1); +} + +const server = net.createServer(client => { + const upstream = net.connect(remotePort, remoteHost); + client.pipe(upstream); + upstream.pipe(client); + client.on('error', (err) => { console.error('[tcp-tunnel] Client error:', sanitizeForLog(err.message)); upstream.destroy(); }); + upstream.on('error', (err) => { console.error('[tcp-tunnel] Upstream error:', sanitizeForLog(err.message)); client.destroy(); }); +}); + +server.on('error', (err) => { + console.error('[tcp-tunnel] Server error:', sanitizeForLog(err.message)); + process.exit(1); +}); + +server.listen(localPort, '127.0.0.1', () => { + console.log(`[tcp-tunnel] Forwarding localhost:${localPort} → ${remoteHost}:${remotePort}`); +}); diff --git a/docs/gh-cli-proxy-design.md b/docs/gh-cli-proxy-design.md index 7fa2d7bc..80f20cb1 100644 --- a/docs/gh-cli-proxy-design.md +++ b/docs/gh-cli-proxy-design.md @@ -1,5 +1,7 @@ # Design: `gh` CLI Proxy for Agent Container +> **Updated**: The CLI proxy now connects to an **external** DIFC proxy (mcpg) started by the gh-aw compiler on the host, instead of managing the mcpg container internally. See [Architecture Changes](#architecture-external-difc-proxy) below. + ## Problem Statement Today, agents access GitHub data exclusively through the GitHub MCP server, which is spawned by mcpg in a separate container and communicates via the MCP protocol. This has three costs: @@ -49,7 +51,55 @@ The gh-aw compiler already proxies pre-agent `gh` CLI calls through mcpg's proxy By reusing this same pattern inside the cli-proxy sidecar, we get guard policies, audit logging, and credential isolation "for free" — without building a new auth-injection mechanism. -## Architecture: AWF Sidecar with mcpg Proxy +## Architecture: External DIFC Proxy {#architecture-external-difc-proxy} + +The DIFC proxy (mcpg) is now started **externally** by the gh-aw compiler on the host. AWF only launches the cli-proxy container and connects it to the external proxy. + +### New Architecture + +``` +Host (managed by gh-aw compiler): + difc-proxy (mcpg in proxy mode) on 0.0.0.0:18443, --network host + +AWF docker-compose: + squid-proxy (172.30.0.10) + cli-proxy (172.30.0.50) → host difc-proxy via host.docker.internal:18443 + agent (172.30.0.20) → cli-proxy at http://172.30.0.50:11000 +``` + +### TLS Hostname Matching + +The difc-proxy's self-signed TLS cert has SANs for `localhost` and `127.0.0.1`, but not `host.docker.internal`. The cli-proxy container runs a **Node.js TCP tunnel** (`tcp-tunnel.js`): + +``` +localhost:18443 (inside cli-proxy) → TCP tunnel → host.docker.internal:18443 (host difc-proxy) +``` + +The `gh` CLI uses `GH_HOST=localhost:18443`, which matches the cert's SAN. + +### CLI Flags + +| Flag | Description | +|---|---| +| `--difc-proxy-host ` | Connect to external DIFC proxy (e.g., `host.docker.internal:18443`) | +| `--difc-proxy-ca-cert ` | Path to TLS CA cert written by the DIFC proxy | + +### Key Properties + +- **No internal mcpg container**: The mcpg process runs on the host, started by the gh-aw compiler +- **TCP tunnel for TLS**: `tcp-tunnel.js` forwards localhost traffic to the host DIFC proxy +- **Guard policy enforcement**: Handled by the external DIFC proxy, not by AWF +- **Write control**: Delegated to the DIFC guard policy (no read-only mode in cli-proxy) +- **Credential isolation**: Tokens held by the external DIFC proxy, excluded from agent env +- **Audit logging**: mcpg logs all proxied API calls on the host +- **Squid routing**: The external DIFC proxy's traffic is not routed through Squid + +--- + +
+Historical: Original internal mcpg architecture (deprecated) + +## Architecture: AWF Sidecar with mcpg Proxy (deprecated) A new container on the `awf-net`, managed by `docker-manager.ts`, that runs two processes internally: @@ -519,3 +569,5 @@ The exact savings depend on which GitHub toolsets are enabled. Workflows with `" - `pkg/workflow/compiler_difc_proxy.go` — Compiler generates the start/stop steps and policy JSON - `actions/setup/sh/start_difc_proxy.sh` — Runtime startup: `docker run mcpg proxy ...`, TLS cert wait, env var injection - `actions/setup/sh/stop_difc_proxy.sh` — Cleanup: stop container, restore env vars, remove CA cert + +
diff --git a/src/cli-workflow.test.ts b/src/cli-workflow.test.ts index 145dd882..3d17027a 100644 --- a/src/cli-workflow.test.ts +++ b/src/cli-workflow.test.ts @@ -130,7 +130,7 @@ describe('runMainWorkflow', () => { const expectedHostAccess: HostAccessConfig = { enabled: true, allowHostPorts: '3000,8080' }; expect(dependencies.setupHostIptables).toHaveBeenCalledWith( - '172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4'], undefined, undefined, expectedHostAccess + '172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4'], undefined, undefined, expectedHostAccess, undefined ); }); @@ -159,7 +159,7 @@ describe('runMainWorkflow', () => { allowHostServicePorts: '5432,6379', }; expect(dependencies.setupHostIptables).toHaveBeenCalledWith( - '172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4'], undefined, undefined, expectedHostAccess + '172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4'], undefined, undefined, expectedHostAccess, undefined ); }); @@ -177,7 +177,7 @@ describe('runMainWorkflow', () => { await runMainWorkflow(baseConfig, dependencies, { logger, performCleanup }); expect(dependencies.setupHostIptables).toHaveBeenCalledWith( - '172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4'], undefined, undefined, undefined + '172.30.0.10', 3128, ['8.8.8.8', '8.8.4.4'], undefined, undefined, undefined, undefined ); }); diff --git a/src/cli-workflow.ts b/src/cli-workflow.ts index cca04105..945710a8 100644 --- a/src/cli-workflow.ts +++ b/src/cli-workflow.ts @@ -1,10 +1,11 @@ import { WrapperConfig } from './types'; -import { HostAccessConfig } from './host-iptables'; +import { HostAccessConfig, CliProxyHostConfig } from './host-iptables'; import { DEFAULT_DNS_SERVERS } from './dns-resolver'; +import { parseDifcProxyHost } from './docker-manager'; export interface WorkflowDependencies { ensureFirewallNetwork: () => Promise<{ squidIp: string; agentIp: string; proxyIp: string; subnet: string }>; - setupHostIptables: (squidIp: string, port: number, dnsServers: string[], apiProxyIp?: string, dohProxyIp?: string, hostAccess?: HostAccessConfig) => Promise; + setupHostIptables: (squidIp: string, port: number, dnsServers: string[], apiProxyIp?: string, dohProxyIp?: string, hostAccess?: HostAccessConfig, cliProxyConfig?: CliProxyHostConfig) => Promise; writeConfigs: (config: WrapperConfig) => Promise; startContainers: (workDir: string, allowedDomains: string[], proxyLogsDir?: string, skipPull?: boolean) => Promise; runAgentCommand: ( @@ -54,7 +55,14 @@ export async function runMainWorkflow( const hostAccess: HostAccessConfig | undefined = config.enableHostAccess ? { enabled: true, allowHostPorts: config.allowHostPorts, allowHostServicePorts: config.allowHostServicePorts } : undefined; - await dependencies.setupHostIptables(networkConfig.squidIp, 3128, dnsServers, apiProxyIp, dohProxyIp, hostAccess); + // When DIFC proxy is enabled, allow cli-proxy container to reach the host gateway + // on the DIFC proxy port (e.g., 18443) + let cliProxyConfig: CliProxyHostConfig | undefined; + if (config.difcProxyHost) { + const { port } = parseDifcProxyHost(config.difcProxyHost); + cliProxyConfig = { ip: '172.30.0.50', difcProxyPort: parseInt(port, 10) }; + } + await dependencies.setupHostIptables(networkConfig.squidIp, 3128, dnsServers, apiProxyIp, dohProxyIp, hostAccess, cliProxyConfig); onHostIptablesSetup?.(); // Step 1: Write configuration files diff --git a/src/cli.test.ts b/src/cli.test.ts index 3e28dde3..106609a5 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -2041,11 +2041,11 @@ describe('cli', () => { }); describe('emitCliProxyStatusLogs', () => { - it('should emit nothing when cli proxy is disabled', () => { + it('should emit nothing when difcProxyHost is not set', () => { const infos: string[] = []; const warns: string[] = []; emitCliProxyStatusLogs( - { enableCliProxy: false, githubToken: 'tok' }, + { githubToken: 'tok' }, (msg) => infos.push(msg), (msg) => warns.push(msg), ); @@ -2053,7 +2053,7 @@ describe('cli', () => { expect(warns).toHaveLength(0); }); - it('should emit nothing when enableCliProxy is undefined', () => { + it('should emit nothing when difcProxyHost is undefined', () => { const infos: string[] = []; const warns: string[] = []; emitCliProxyStatusLogs( @@ -2065,30 +2065,17 @@ describe('cli', () => { expect(warns).toHaveLength(0); }); - it('should emit info when token is present (read-only)', () => { + it('should emit info when difcProxyHost is set with token', () => { const infos: string[] = []; const warns: string[] = []; emitCliProxyStatusLogs( - { enableCliProxy: true, githubToken: 'ghp_test123', cliProxyWritable: false }, + { difcProxyHost: 'host.docker.internal:18443', githubToken: 'ghp_test123' }, (msg) => infos.push(msg), (msg) => warns.push(msg), ); - expect(infos).toHaveLength(1); + expect(infos.length).toBeGreaterThanOrEqual(1); expect(infos[0]).toContain('CLI proxy enabled'); - expect(infos[0]).toContain('writable=false'); - expect(warns).toHaveLength(0); - }); - - it('should emit info when token is present (writable)', () => { - const infos: string[] = []; - const warns: string[] = []; - emitCliProxyStatusLogs( - { enableCliProxy: true, githubToken: 'ghp_test123', cliProxyWritable: true }, - (msg) => infos.push(msg), - (msg) => warns.push(msg), - ); - expect(infos).toHaveLength(1); - expect(infos[0]).toContain('writable=true'); + expect(infos[0]).toContain('host.docker.internal:18443'); expect(warns).toHaveLength(0); }); @@ -2096,14 +2083,13 @@ describe('cli', () => { const infos: string[] = []; const warns: string[] = []; emitCliProxyStatusLogs( - { enableCliProxy: true }, + { difcProxyHost: 'host.docker.internal:18443' }, (msg) => infos.push(msg), (msg) => warns.push(msg), ); - expect(infos).toHaveLength(0); - expect(warns).toHaveLength(2); + expect(infos.length).toBeGreaterThanOrEqual(1); + expect(warns.length).toBeGreaterThanOrEqual(1); expect(warns[0]).toContain('no GitHub token found'); - expect(warns[1]).toContain('GITHUB_TOKEN or GH_TOKEN'); }); }); @@ -2456,7 +2442,7 @@ describe('cli', () => { imageTag: 'v1.0', agentImage: 'default', enableApiProxy: false, - enableCliProxy: true, + difcProxy: true, }); expect(mockPredownloadCommand).toHaveBeenCalledWith({ @@ -2464,7 +2450,7 @@ describe('cli', () => { imageTag: 'v1.0', agentImage: 'default', enableApiProxy: false, - enableCliProxy: true, + difcProxy: true, }); }); diff --git a/src/cli.ts b/src/cli.ts index 9f78f1ff..c793045f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -399,17 +399,18 @@ export function emitApiProxyTargetWarnings( * Extracted for testability (same pattern as emitApiProxyTargetWarnings). */ export function emitCliProxyStatusLogs( - config: { enableCliProxy?: boolean; cliProxyWritable?: boolean; githubToken?: string }, + config: { difcProxyHost?: string; githubToken?: string }, info: (msg: string) => void, warn: (msg: string) => void, ): void { - if (!config.enableCliProxy) return; + if (!config.difcProxyHost) return; + info(`CLI proxy enabled: connecting to external DIFC proxy at ${config.difcProxyHost}`); if (config.githubToken) { - info(`CLI proxy enabled: token present (GITHUB_TOKEN/GH_TOKEN), writable=${!!config.cliProxyWritable}`); + info('GitHub token present — will be excluded from agent environment'); } else { warn('⚠️ CLI proxy enabled but no GitHub token found in environment'); - warn(' Set GITHUB_TOKEN or GH_TOKEN to enable authenticated gh CLI access through the proxy'); + warn(' The external DIFC proxy handles token authentication'); } } @@ -1434,29 +1435,17 @@ program 'Disable rate limiting in the API proxy (requires --enable-api-proxy)', ) - // -- CLI Proxy -- + // -- CLI Proxy (external DIFC proxy) -- .option( - '--enable-cli-proxy', - 'Enable gh CLI proxy sidecar for secure GitHub CLI access.\n' + - ' Routes gh commands through mcpg DIFC proxy with guard policies.\n' + - ' GH_TOKEN is held in the sidecar; never exposed to the agent.', - false - ) - .option( - '--cli-proxy-writable', - 'Allow write operations through the CLI proxy (default: read-only)', - false - ) - .option( - '--cli-proxy-policy ', - 'Guard policy JSON for the mcpg DIFC proxy inside the CLI proxy sidecar\n' + - ' (e.g. \'{"repos":["owner/repo"],"min-integrity":"public"}\')', + '--difc-proxy-host ', + 'Connect to an external DIFC proxy (mcpg) at host:port.\n' + + ' Enables the CLI proxy sidecar that routes gh commands through the DIFC proxy.\n' + + ' The DIFC proxy must be started externally (e.g., by the gh-aw compiler).', ) .option( - '--cli-proxy-mcpg-image ', - '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' + '--difc-proxy-ca-cert ', + 'Path to TLS CA cert written by the external DIFC proxy.\n' + + ' Recommended when --difc-proxy-host is set for TLS verification.', ) // -- Logging & Debug -- .option( @@ -1829,10 +1818,8 @@ program anthropicApiBasePath: options.anthropicApiBasePath || process.env.ANTHROPIC_API_BASE_PATH, geminiApiTarget: options.geminiApiTarget || process.env.GEMINI_API_TARGET, geminiApiBasePath: options.geminiApiBasePath || process.env.GEMINI_API_BASE_PATH, - enableCliProxy: options.enableCliProxy, - cliProxyWritable: options.cliProxyWritable, - cliProxyPolicy: options.cliProxyPolicy, - cliProxyMcpgImage: options.cliProxyMcpgImage, + difcProxyHost: options.difcProxyHost, + difcProxyCaCert: options.difcProxyCaCert, githubToken: process.env.GITHUB_TOKEN || process.env.GH_TOKEN, }; @@ -2056,8 +2043,7 @@ export async function handlePredownloadAction(options: { imageTag: string; agentImage: string; enableApiProxy: boolean; - enableCliProxy?: boolean; - cliProxyMcpgImage?: string; + difcProxy?: boolean; }): Promise { const { predownloadCommand } = await import('./commands/predownload'); try { @@ -2066,8 +2052,7 @@ export async function handlePredownloadAction(options: { imageTag: options.imageTag, agentImage: options.agentImage, enableApiProxy: options.enableApiProxy, - enableCliProxy: options.enableCliProxy, - cliProxyMcpgImage: options.cliProxyMcpgImage, + difcProxy: options.difcProxy, }); } catch (error) { const exitCode = (error as Error & { exitCode?: number }).exitCode ?? 1; @@ -2091,8 +2076,7 @@ program 'default' ) .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') + .option('--difc-proxy', 'Also download the CLI proxy image (for --difc-proxy-host)', false) .action(handlePredownloadAction); // Logs subcommand - view Squid proxy logs diff --git a/src/commands/predownload.test.ts b/src/commands/predownload.test.ts index cce67705..fde8187c 100644 --- a/src/commands/predownload.test.ts +++ b/src/commands/predownload.test.ts @@ -43,34 +43,22 @@ describe('predownload', () => { ]); }); - it('should include cli-proxy and mcpg when enabled', () => { - const images = resolveImages({ ...defaults, enableCliProxy: true }); + it('should include cli-proxy when enabled (no mcpg — runs externally)', () => { + const images = resolveImages({ ...defaults, difcProxy: 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', ]); }); it('should include both api-proxy and cli-proxy when both enabled', () => { - const images = resolveImages({ ...defaults, enableApiProxy: true, enableCliProxy: true }); + const images = resolveImages({ ...defaults, enableApiProxy: true, difcProxy: 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/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', ]); }); @@ -147,20 +135,15 @@ describe('predownload', () => { ); }); - it('should pull cli-proxy and mcpg when enabled', async () => { - await predownloadCommand({ ...defaults, enableCliProxy: true }); + it('should pull cli-proxy when enabled (no mcpg)', async () => { + await predownloadCommand({ ...defaults, difcProxy: true }); - expect(execa).toHaveBeenCalledTimes(4); + expect(execa).toHaveBeenCalledTimes(3); 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 a89717cc..b9ac8a87 100644 --- a/src/commands/predownload.ts +++ b/src/commands/predownload.ts @@ -6,8 +6,7 @@ export interface PredownloadOptions { imageTag: string; agentImage: string; enableApiProxy: boolean; - enableCliProxy?: boolean; - cliProxyMcpgImage?: string; + difcProxy?: boolean; } /** @@ -49,13 +48,9 @@ export function resolveImages(options: PredownloadOptions): string[] { images.push(`${imageRegistry}/api-proxy:${imageTag}`); } - // Optionally pull cli-proxy and its mcpg sidecar - if (options.enableCliProxy) { + // Optionally pull cli-proxy (mcpg is now started externally by the compiler) + if (options.difcProxy) { 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 eefbdb3f..3cdeeef7 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -2730,246 +2730,194 @@ describe('docker-manager', () => { }); }); - describe('CLI proxy sidecar', () => { + describe('CLI proxy sidecar (external DIFC proxy)', () => { const mockNetworkConfigWithCliProxy = { ...mockNetworkConfig, cliProxyIp: '172.30.0.50', - cliProxyMcpgIp: '172.30.0.51', }; - it('should not include cli-proxy service when enableCliProxy is false', () => { + it('should not include cli-proxy service when difcProxyHost is not set', () => { 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', () => { - const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; + it('should not include cli-proxy service when difcProxyHost is set but no cliProxyIp', () => { + const configWithCliProxy = { ...mockConfig, difcProxyHost: 'host.docker.internal:18443' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfig); 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' }; + it('should include cli-proxy service when difcProxyHost is set with cliProxyIp', () => { + const configWithCliProxy = { ...mockConfig, difcProxyHost: 'host.docker.internal:18443' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); expect(result.services['cli-proxy']).toBeDefined(); const proxy = result.services['cli-proxy']; expect(proxy.container_name).toBe('awf-cli-proxy'); - // 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']; - 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-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 proxyEnv = proxy.environment as Record; - expect(proxyEnv.GH_TOKEN).toBeUndefined(); + // cli-proxy gets its own IP on awf-net (no shared network namespace) + expect((proxy.networks as any)['awf-net'].ipv4_address).toBe('172.30.0.50'); + expect(proxy.network_mode).toBeUndefined(); }); - it('should route cli-proxy-mcpg traffic through Squid', () => { - const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; + it('should not include cli-proxy-mcpg service (mcpg runs externally)', () => { + const configWithCliProxy = { ...mockConfig, difcProxyHost: 'host.docker.internal:18443' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); - // 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'); + expect(result.services['cli-proxy-mcpg']).toBeUndefined(); }); - 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 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 localhost in mcpg healthcheck (matches TLS cert SAN)', () => { - const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; + it('should not add cli-proxy-tls named volume (CA cert is bind-mounted)', () => { + const configWithCliProxy = { ...mockConfig, difcProxyHost: 'host.docker.internal:18443' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); - const mcpg = result.services['cli-proxy-mcpg']; - const healthcheck = (mcpg.healthcheck as any).test as string[]; - // Healthcheck runs inside mcpg container — must use localhost to match - // the self-signed TLS cert's SAN - expect(healthcheck.join(' ')).toContain('https://localhost:18443'); + expect(result.volumes).toBeUndefined(); }); - it('should configure healthcheck for cli-proxy', () => { - const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; + it('should include extra_hosts for host.docker.internal', () => { + const configWithCliProxy = { ...mockConfig, difcProxyHost: 'host.docker.internal:18443' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); const proxy = result.services['cli-proxy']; - expect(proxy.healthcheck).toBeDefined(); - expect((proxy.healthcheck as any).test).toEqual(['CMD', 'curl', '-f', 'http://localhost:11000/health']); + expect(proxy.extra_hosts).toContain('host.docker.internal:host-gateway'); }); - it('should drop all capabilities from cli-proxy', () => { - const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; + it('should mount CA cert as read-only volume when difcProxyCaCert is set', () => { + const configWithCliProxy = { + ...mockConfig, + difcProxyHost: 'host.docker.internal:18443', + difcProxyCaCert: '/tmp/difc-proxy-tls/ca.crt', + }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); const proxy = result.services['cli-proxy']; - expect(proxy.cap_drop).toEqual(['ALL']); - expect(proxy.security_opt).toContain('no-new-privileges:true'); + expect(proxy.volumes).toContainEqual('/tmp/difc-proxy-tls/ca.crt:/tmp/proxy-tls/ca.crt:ro'); }); - it('should update agent depends_on to wait for cli-proxy', () => { - const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; + it('should not mount CA cert when difcProxyCaCert is not set', () => { + const configWithCliProxy = { ...mockConfig, difcProxyHost: 'host.docker.internal:18443' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); - const dependsOn = result.services['agent'].depends_on as Record; - expect(dependsOn['cli-proxy']).toBeDefined(); - expect(dependsOn['cli-proxy'].condition).toBe('service_healthy'); + const proxy = result.services['cli-proxy']; + const volumes = proxy.volumes as string[]; + expect(volumes.some((v: string) => v.includes('ca.crt'))).toBe(false); }); - it('should set AWF_CLI_PROXY_URL in agent environment', () => { - const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; + it('should set AWF_DIFC_PROXY_HOST and AWF_DIFC_PROXY_PORT env vars', () => { + const configWithCliProxy = { ...mockConfig, difcProxyHost: 'host.docker.internal:18443' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); - const agent = result.services['agent']; - const env = agent.environment as Record; - // 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'); + const proxy = result.services['cli-proxy']; + const env = proxy.environment as Record; + expect(env.AWF_DIFC_PROXY_HOST).toBe('host.docker.internal'); + expect(env.AWF_DIFC_PROXY_PORT).toBe('18443'); }); - it('should set AWF_CLI_PROXY_IP in agent environment', () => { - const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; + it('should parse custom host and port from difcProxyHost', () => { + const configWithCliProxy = { ...mockConfig, difcProxyHost: 'custom-host:9999' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); - const agent = result.services['agent']; - const env = agent.environment as Record; - // cli-proxy shares mcpg's network namespace, so use mcpg's IP - expect(env.AWF_CLI_PROXY_IP).toBe('172.30.0.51'); + const proxy = result.services['cli-proxy']; + const env = proxy.environment as Record; + expect(env.AWF_DIFC_PROXY_HOST).toBe('custom-host'); + expect(env.AWF_DIFC_PROXY_PORT).toBe('9999'); }); - it('should pass AWF_CLI_PROXY_IP to iptables-init environment', () => { - const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; + it('should parse IPv6 bracketed host:port from difcProxyHost', () => { + const configWithCliProxy = { ...mockConfig, difcProxyHost: '[::1]:18443' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); - const initEnv = result.services['iptables-init'].environment as Record; - // cli-proxy shares mcpg's network namespace, so use mcpg's IP - expect(initEnv.AWF_CLI_PROXY_IP).toBe('172.30.0.51'); + const proxy = result.services['cli-proxy']; + const env = proxy.environment as Record; + expect(env.AWF_DIFC_PROXY_HOST).toBe('[::1]'); + expect(env.AWF_DIFC_PROXY_PORT).toBe('18443'); }); - it('should set AWF_CLI_PROXY_WRITABLE=false by default', () => { - const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; + it('should default port to 18443 when only host is specified', () => { + const configWithCliProxy = { ...mockConfig, difcProxyHost: 'my-host' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); const proxy = result.services['cli-proxy']; const env = proxy.environment as Record; - expect(env.AWF_CLI_PROXY_WRITABLE).toBe('false'); + expect(env.AWF_DIFC_PROXY_HOST).toBe('my-host'); + expect(env.AWF_DIFC_PROXY_PORT).toBe('18443'); + }); + + it('should throw on invalid difcProxyHost value', () => { + const configWithCliProxy = { ...mockConfig, difcProxyHost: ':::invalid' }; + expect(() => generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy)) + .toThrow('Invalid --difc-proxy-host'); }); - it('should set AWF_CLI_PROXY_WRITABLE=true when cliProxyWritable is true', () => { - const configWithCliProxy = { ...mockConfig, enableCliProxy: true, cliProxyWritable: true, githubToken: 'ghp_test_token' }; + it('should include host.docker.internal in NO_PROXY', () => { + const configWithCliProxy = { ...mockConfig, difcProxyHost: 'host.docker.internal:18443' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); const proxy = result.services['cli-proxy']; const env = proxy.environment as Record; - expect(env.AWF_CLI_PROXY_WRITABLE).toBe('true'); + expect(env.NO_PROXY).toContain('host.docker.internal'); + expect(env.no_proxy).toContain('host.docker.internal'); }); - 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 }; + it('should configure healthcheck for cli-proxy', () => { + const configWithCliProxy = { ...mockConfig, difcProxyHost: 'host.docker.internal:18443' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); - 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); + const proxy = result.services['cli-proxy']; + expect(proxy.healthcheck).toBeDefined(); + expect((proxy.healthcheck as any).test).toEqual(['CMD', 'curl', '-f', 'http://localhost:11000/health']); }); - it('should use default guard policy when cliProxyPolicy is not set', () => { - const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; + it('should drop all capabilities from cli-proxy', () => { + const configWithCliProxy = { ...mockConfig, difcProxyHost: 'host.docker.internal:18443' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); - 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'); + const proxy = result.services['cli-proxy']; + expect(proxy.cap_drop).toEqual(['ALL']); + expect(proxy.security_opt).toContain('no-new-privileges:true'); }); - it('should use GHCR image by default', () => { - const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token', buildLocal: false }; + it('should update agent depends_on to wait for cli-proxy', () => { + const configWithCliProxy = { ...mockConfig, difcProxyHost: 'host.docker.internal:18443' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); - const proxy = result.services['cli-proxy']; - expect(proxy.image).toContain('cli-proxy'); - expect(proxy.build).toBeUndefined(); + const dependsOn = result.services['agent'].depends_on as Record; + expect(dependsOn['cli-proxy']).toBeDefined(); + expect(dependsOn['cli-proxy'].condition).toBe('service_healthy'); }); - it('should use local build when buildLocal is true', () => { - const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token', buildLocal: true }; + it('should set AWF_CLI_PROXY_URL in agent environment using cli-proxy IP', () => { + const configWithCliProxy = { ...mockConfig, difcProxyHost: 'host.docker.internal:18443' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); - const proxy = result.services['cli-proxy']; - expect((proxy.build as any).context).toContain('containers/cli-proxy'); - expect(proxy.image).toBeUndefined(); + const agent = result.services['agent']; + const env = agent.environment as Record; + expect(env.AWF_CLI_PROXY_URL).toBe('http://172.30.0.50:11000'); }); - it('should use mcpg image for cli-proxy-mcpg service', () => { - const configWithCliProxy = { - ...mockConfig, - enableCliProxy: true, - githubToken: 'ghp_test_token', - cliProxyMcpgImage: 'ghcr.io/github/gh-aw-mcpg:v0.3.0', - }; + it('should set AWF_CLI_PROXY_IP in agent environment using cli-proxy IP', () => { + const configWithCliProxy = { ...mockConfig, difcProxyHost: 'host.docker.internal:18443' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); - const mcpg = result.services['cli-proxy-mcpg']; - expect(mcpg.image).toBe('ghcr.io/github/gh-aw-mcpg:v0.3.0'); + const agent = result.services['agent']; + const env = agent.environment as Record; + expect(env.AWF_CLI_PROXY_IP).toBe('172.30.0.50'); }); - it('should use default mcpg image when cliProxyMcpgImage is not set', () => { - const configWithCliProxy = { - ...mockConfig, - enableCliProxy: true, - githubToken: 'ghp_test_token', - }; + it('should pass AWF_CLI_PROXY_IP to iptables-init environment', () => { + const configWithCliProxy = { ...mockConfig, difcProxyHost: 'host.docker.internal:18443' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); - const mcpg = result.services['cli-proxy-mcpg']; - expect(mcpg.image).toContain('gh-aw-mcpg'); + const initEnv = result.services['iptables-init'].environment as Record; + expect(initEnv.AWF_CLI_PROXY_IP).toBe('172.30.0.50'); }); - 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 use GHCR image by default', () => { + const configWithCliProxy = { ...mockConfig, difcProxyHost: 'host.docker.internal:18443', buildLocal: false }; + const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); + const proxy = result.services['cli-proxy']; + expect(proxy.image).toContain('cli-proxy'); + expect(proxy.build).toBeUndefined(); }); - it('should add cli-proxy-tls named volume when cli-proxy is enabled', () => { - const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; + it('should use local build when buildLocal is true', () => { + const configWithCliProxy = { ...mockConfig, difcProxyHost: 'host.docker.internal:18443', buildLocal: true }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); - expect(result.volumes).toBeDefined(); - expect(result.volumes!['cli-proxy-tls']).toBeDefined(); + const proxy = result.services['cli-proxy']; + expect((proxy.build as any).context).toContain('containers/cli-proxy'); + expect(proxy.image).toBeUndefined(); }); - it('should configure cli-proxy to connect to mcpg via shared network namespace', () => { - const configWithCliProxy = { ...mockConfig, enableCliProxy: true, githubToken: 'ghp_test_token' }; + it('should depend only on squid-proxy (not mcpg)', () => { + const configWithCliProxy = { ...mockConfig, difcProxyHost: 'host.docker.internal:18443' }; const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy); const proxy = result.services['cli-proxy']; - const env = proxy.environment as Record; - // 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'); + const dependsOn = proxy.depends_on as Record; + expect(dependsOn).toBeDefined(); + expect(dependsOn['squid-proxy']).toBeDefined(); + expect(dependsOn['cli-proxy-mcpg']).toBeUndefined(); }); }); }); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index b5f32b91..5e81f285 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -22,7 +22,6 @@ 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 @@ -395,13 +394,45 @@ export function stripScheme(value: string): string { } } +/** + * Parses a host:port string into separate host and port components. + * Supports IPv6 bracketed notation (e.g., [::1]:18443), plain host:port, + * and optional scheme prefixes. + * Defaults to host.docker.internal:18443 for empty/missing values. + */ +export function parseDifcProxyHost(value: string): { host: string; port: string } { + const trimmed = value.trim(); + if (!trimmed) { + return { host: 'host.docker.internal', port: '18443' }; + } + // Use URL to parse host:port correctly (handles IPv6 brackets) + const hasScheme = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(trimmed); + const candidate = hasScheme ? trimmed : `tcp://${trimmed}`; + let parsed: URL; + try { + parsed = new URL(candidate); + } catch { + throw new Error(`Invalid --difc-proxy-host value: "${value}". Expected host:port format.`); + } + const host = parsed.hostname || 'host.docker.internal'; + const port = parsed.port || '18443'; + if (!/^\d+$/.test(port)) { + throw new Error(`Invalid --difc-proxy-host port: "${port}". Must be a number.`); + } + const portNum = Number(port); + if (portNum < 1 || portNum > 65535) { + throw new Error(`Invalid --difc-proxy-host port: ${portNum}. Must be between 1 and 65535.`); + } + return { host, port: String(portNum) }; +} + /** * Generates Docker Compose configuration * Note: Uses external network 'awf-net' created by host-iptables setup */ export function generateDockerCompose( config: WrapperConfig, - networkConfig: { subnet: string; squidIp: string; agentIp: string; proxyIp?: string; dohProxyIp?: string; cliProxyIp?: string; cliProxyMcpgIp?: string }, + networkConfig: { subnet: string; squidIp: string; agentIp: string; proxyIp?: string; dohProxyIp?: string; cliProxyIp?: string }, sslConfig?: SslConfig, squidConfigContent?: string ): DockerComposeConfig { @@ -568,16 +599,9 @@ export function generateDockerCompose( // See: github/gh-aw#20875 } - // When cli-proxy is enabled, exclude GitHub tokens from agent environment. - // These tokens are held securely in the cli-proxy sidecar's mcpg process instead, - // so the agent can invoke gh commands without ever seeing the raw token. - // - // Design note: unlike api-proxy (which excludes LLM API keys), this excludes a - // token that many GitHub Actions tools also use. In practice this is safe because - // actions/checkout runs before awf starts, and tools that need GITHUB_TOKEN - // (e.g. gh-mcp-server) should use GITHUB_MCP_SERVER_TOKEN (a separate env var) - // rather than GITHUB_TOKEN. - if (config.enableCliProxy) { + // When cli-proxy is enabled (external DIFC proxy), exclude GitHub tokens + // from agent environment. Tokens are held securely by the external DIFC proxy. + if (config.difcProxyHost) { EXCLUDED_ENV_VARS.add('GITHUB_TOKEN'); EXCLUDED_ENV_VARS.add('GH_TOKEN'); } @@ -1393,9 +1417,8 @@ 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. - // cli-proxy shares mcpg's network namespace, so use the mcpg IP. - if (config.enableCliProxy && networkConfig.cliProxyIp) { - environment.AWF_CLI_PROXY_IP = networkConfig.cliProxyMcpgIp || '172.30.0.51'; + if (config.difcProxyHost && networkConfig.cliProxyIp) { + environment.AWF_CLI_PROXY_IP = networkConfig.cliProxyIp; } // SECURITY: iptables init container - sets up NAT rules in a separate container @@ -1654,125 +1677,41 @@ export function generateDockerCompose( logger.info(`DNS-over-HTTPS proxy sidecar enabled - DNS queries encrypted via ${config.dnsOverHttps}`); } - // 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; + // Add CLI proxy sidecar if enabled (connects to external DIFC proxy) + if (config.difcProxyHost && networkConfig.cliProxyIp) { + const cliProxyIp = networkConfig.cliProxyIp; - // 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.' - ); - } + // Parse host:port from difcProxyHost (supports IPv6, e.g. [::1]:18443) + const { host: difcProxyHost, port: difcProxyPort } = parseDifcProxyHost(config.difcProxyHost); - // 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 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', - '--trusted-bots', 'github-actions[bot],github-actions,dependabot[bot],copilot', - '--log-dir', '/var/log/cli-proxy/mcpg', - ], + // --- CLI proxy HTTP server (Node.js + gh CLI) --- + // Connects to external DIFC proxy via TCP tunnel for TLS hostname matching. + // The TCP tunnel forwards localhost:${difcProxyPort} → ${difcProxyHost}:${difcProxyPort} + // so that gh CLI's GH_HOST=localhost:${difcProxyPort} matches the cert's SAN. + const cliProxyService: any = { + container_name: CLI_PROXY_CONTAINER_NAME, 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 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, - start_period: '30s', - }, - depends_on: { - 'squid-proxy': { - condition: 'service_healthy', + ipv4_address: cliProxyIp, }, }, - 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) --- - // 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, - // Share mcpg's network namespace — localhost resolves to mcpg - network_mode: 'service:cli-proxy-mcpg', + // Enable host.docker.internal resolution for connecting to host DIFC proxy + extra_hosts: ['host.docker.internal:host-gateway'], volumes: [ - // 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`, + // Mount host CA cert for TLS verification + ...(config.difcProxyCaCert ? [`${config.difcProxyCaCert}:/tmp/proxy-tls/ca.crt:ro`] : []), ], environment: { - // 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), + // External DIFC proxy connection info for tcp-tunnel.js + AWF_DIFC_PROXY_HOST: difcProxyHost, + AWF_DIFC_PROXY_PORT: difcProxyPort, // Pass GITHUB_REPOSITORY for GH_REPO default in entrypoint ...(process.env.GITHUB_REPOSITORY && { GITHUB_REPOSITORY: process.env.GITHUB_REPOSITORY }), - // Enable write mode when --cli-proxy-writable is passed - AWF_CLI_PROXY_WRITABLE: String(!!config.cliProxyWritable), - // 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`, + // Prevent curl/node from routing localhost or host.docker.internal through Squid + NO_PROXY: `localhost,127.0.0.1,::1,host.docker.internal`, + no_proxy: `localhost,127.0.0.1,::1,host.docker.internal`, }, healthcheck: { test: ['CMD', 'curl', '-f', `http://localhost:${CLI_PROXY_PORT}/health`], @@ -1781,9 +1720,8 @@ export function generateDockerCompose( retries: 5, start_period: '30s', }, - // Depend on mcpg for TLS cert and API routing depends_on: { - 'cli-proxy-mcpg': { + 'squid-proxy': { condition: 'service_healthy', }, }, @@ -1813,19 +1751,11 @@ export function generateDockerCompose( condition: 'service_healthy', }; - // Tell the agent how to reach the CLI proxy - // 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 - 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'); - } + // Tell the agent how to reach the CLI proxy (use cli-proxy's own IP) + environment.AWF_CLI_PROXY_URL = `http://${cliProxyIp}:${CLI_PROXY_PORT}`; + environment.AWF_CLI_PROXY_IP = cliProxyIp; + + logger.info(`CLI proxy sidecar enabled - connecting to external DIFC proxy at ${config.difcProxyHost}`); } const composeResult: DockerComposeConfig = { @@ -1837,11 +1767,6 @@ 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; } @@ -2016,7 +1941,6 @@ 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/host-iptables.ts b/src/host-iptables.ts index 96b041b5..ec50925e 100644 --- a/src/host-iptables.ts +++ b/src/host-iptables.ts @@ -20,6 +20,16 @@ export interface HostAccessConfig { allowHostServicePorts?: string; } +/** + * Configuration for the CLI proxy's connection to an external DIFC proxy on the host. + */ +export interface CliProxyHostConfig { + /** CLI proxy container IP on awf-net (e.g., 172.30.0.50) */ + ip: string; + /** DIFC proxy port on the host (e.g., 18443) */ + difcProxyPort: number; +} + /** * Validates a port specification string. * Accepts a single port (1-65535) or a port range ("N-M" where both are valid ports and N <= M). @@ -208,8 +218,9 @@ export async function ensureFirewallNetwork(): Promise<{ * @param apiProxyIp - Optional IP address of the API proxy sidecar * @param dnsServers - Upstream DNS servers that Docker embedded DNS forwards to * @param hostAccess - Optional host access configuration for localhost/Playwright support + * @param cliProxyConfig - Optional CLI proxy config for DIFC proxy host access */ -export async function setupHostIptables(squidIp: string, squidPort: number, dnsServers: string[], apiProxyIp?: string, dohProxyIp?: string, hostAccess?: HostAccessConfig): Promise { +export async function setupHostIptables(squidIp: string, squidPort: number, dnsServers: string[], apiProxyIp?: string, dohProxyIp?: string, hostAccess?: HostAccessConfig, cliProxyConfig?: CliProxyHostConfig): Promise { logger.info('Setting up host-level iptables rules...'); // Get the bridge interface name @@ -428,6 +439,27 @@ export async function setupHostIptables(squidIp: string, squidPort: number, dnsS ]); } + // 5b2. Allow CLI proxy container to reach host DIFC proxy (when enabled) + // The cli-proxy container needs to TCP-tunnel to the external DIFC proxy on the host. + // Only the cli-proxy IP is allowed to reach the host gateway on the DIFC port. + if (cliProxyConfig) { + const { ip: cliProxyIp, difcProxyPort } = cliProxyConfig; + const gatewayIp = await getDockerBridgeGateway(); + const gatewayIps = [AWF_NETWORK_GATEWAY]; + if (gatewayIp) { + gatewayIps.push(gatewayIp); + } + for (const gwIp of gatewayIps) { + logger.debug(`Allowing CLI proxy (${cliProxyIp}) → host gateway (${gwIp}):${difcProxyPort}`); + await execa('iptables', [ + '-t', 'filter', '-A', CHAIN_NAME, + '-p', 'tcp', '-s', cliProxyIp, '-d', gwIp, '--dport', difcProxyPort.toString(), + '-j', 'ACCEPT', + ]); + } + logger.info(`CLI proxy host access enabled: ${cliProxyIp} → host gateway:${difcProxyPort}`); + } + // 5c. Allow traffic to host gateway when host access is enabled // This is needed for Playwright localhost testing, MCP servers, etc. if (hostAccess?.enabled) { diff --git a/src/types.ts b/src/types.ts index b4b4298f..6f1c5f33 100644 --- a/src/types.ts +++ b/src/types.ts @@ -787,51 +787,37 @@ export interface WrapperConfig { * Enable CLI proxy sidecar for secure gh CLI access * * When true, deploys a CLI proxy sidecar container that: - * - Holds GH_TOKEN securely (never exposed to the agent) - * - Routes gh CLI invocations through an mcpg DIFC proxy - * - Enforces guard policies (min-integrity, repo restrictions) + * - Routes gh CLI invocations through an external DIFC proxy (mcpg) + * - The DIFC proxy enforces guard policies (min-integrity, repo restrictions) * - Generates audit logs via mcpg's JSONL output * * The agent container gets a /usr/local/bin/gh wrapper script that * forwards invocations to the CLI proxy sidecar at http://172.30.0.50:11000. * - * @default false - * @example - * ```bash - * export GITHUB_TOKEN="ghp_..." - * awf --enable-cli-proxy --allow-domains api.github.com,github.com -- command - * ``` - */ - enableCliProxy?: boolean; - - /** - * Allow write operations through the CLI proxy sidecar - * - * When true, the CLI proxy allows write operations (pr create, issue create, etc.) - * in addition to read-only operations. - * When false (default), only read-only subcommands and actions are permitted. + * The DIFC proxy (mcpg) is started externally by the gh-aw compiler on the + * host. AWF only launches the cli-proxy container and connects it to the + * external DIFC proxy via a TCP tunnel for TLS hostname matching. * - * @default false + * @example 'host.docker.internal:18443' */ - cliProxyWritable?: boolean; + difcProxyHost?: string; /** - * Guard policy JSON for the mcpg DIFC proxy inside the CLI proxy sidecar + * Path to the TLS CA certificate written by the external DIFC proxy. * - * This JSON string is passed to the mcpg proxy's --policy flag to enforce - * DIFC guard policies (repository restrictions, minimum integrity level). - * If not specified, a default policy is generated based on GITHUB_REPOSITORY. + * The DIFC proxy generates a self-signed TLS cert. This path points to + * the CA cert on the host filesystem, which is bind-mounted into the + * cli-proxy container for TLS verification. * - * @example '{"repos":["owner/repo"],"min-integrity":"public"}' + * @example '/tmp/gh-aw/difc-proxy-tls/ca.crt' */ - cliProxyPolicy?: string; + difcProxyCaCert?: string; /** * GitHub token for the CLI proxy sidecar * - * When enableCliProxy is true, this token is injected into the CLI proxy - * container and passed to the mcpg DIFC proxy. The token is never exposed - * to the agent container directly. + * When difcProxyHost is set, GitHub tokens are excluded from the agent + * container environment. The token is held by the external DIFC proxy. * * Read from GITHUB_TOKEN environment variable when not specified. * @@ -839,21 +825,6 @@ export interface WrapperConfig { */ githubToken?: string; - /** - * Docker image reference for the mcpg DIFC proxy container. - * - * 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 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' - */ - cliProxyMcpgImage?: string; - /** * Enable Data Loss Prevention (DLP) scanning * @@ -1222,7 +1193,7 @@ export interface DockerService { * via localhost (e.g., for TLS cert hostname matching). * Mutually exclusive with networks. * - * @example 'service:cli-proxy-mcpg' + * @example 'service:agent' */ network_mode?: string; diff --git a/tests/fixtures/awf-runner.ts b/tests/fixtures/awf-runner.ts index 060ccbbd..b96fd09a 100644 --- a/tests/fixtures/awf-runner.ts +++ b/tests/fixtures/awf-runner.ts @@ -20,8 +20,8 @@ export interface AwfOptions { allowHostPorts?: string; // Ports or port ranges to allow for host access (e.g., '3000' or '3000-8000') allowHostServicePorts?: string; // Ports to allow ONLY to host gateway (bypasses dangerous port restrictions) enableApiProxy?: boolean; // Enable API proxy sidecar for LLM credential management - enableCliProxy?: boolean; // Enable CLI proxy sidecar for secure gh CLI access - cliProxyWritable?: boolean; // Allow write operations through the CLI proxy + difcProxyHost?: string; // Connect to external DIFC proxy at host:port (enables CLI proxy) + difcProxyCaCert?: string; // Path to TLS CA cert written by the external DIFC proxy rateLimitRpm?: number; // Requests per minute per provider rateLimitRph?: number; // Requests per hour per provider rateLimitBytesPm?: number; // Request bytes per minute per provider @@ -132,12 +132,12 @@ export class AwfRunner { args.push('--enable-api-proxy'); } - // Add enable-cli-proxy flags - if (options.enableCliProxy) { - args.push('--enable-cli-proxy'); + // Add DIFC proxy flags (replaces --enable-cli-proxy) + if (options.difcProxyHost) { + args.push('--difc-proxy-host', options.difcProxyHost); } - if (options.cliProxyWritable) { - args.push('--cli-proxy-writable'); + if (options.difcProxyCaCert) { + args.push('--difc-proxy-ca-cert', options.difcProxyCaCert); } // Add API target flags @@ -353,12 +353,12 @@ export class AwfRunner { args.push('--enable-api-proxy'); } - // Add enable-cli-proxy flags - if (options.enableCliProxy) { - args.push('--enable-cli-proxy'); + // Add DIFC proxy flags (replaces --enable-cli-proxy) + if (options.difcProxyHost) { + args.push('--difc-proxy-host', options.difcProxyHost); } - if (options.cliProxyWritable) { - args.push('--cli-proxy-writable'); + if (options.difcProxyCaCert) { + args.push('--difc-proxy-ca-cert', options.difcProxyCaCert); } // Add API target flags diff --git a/tests/integration/cli-proxy.test.ts b/tests/integration/cli-proxy.test.ts index 7f8641f7..f1224a6a 100644 --- a/tests/integration/cli-proxy.test.ts +++ b/tests/integration/cli-proxy.test.ts @@ -1,9 +1,13 @@ /** * CLI Proxy Sidecar Integration Tests * - * Tests that the --enable-cli-proxy flag correctly starts the CLI proxy sidecar, - * routes gh CLI commands through the mcpg DIFC proxy, enforces subcommand - * allowlists, and isolates GITHUB_TOKEN from the agent container. + * Tests that the --difc-proxy-host flag correctly starts the CLI proxy sidecar, + * connects to an external DIFC proxy, routes gh CLI commands through it, + * and isolates GITHUB_TOKEN from the agent container. + * + * Note: These tests require a running external DIFC proxy. In CI, the + * smoke-copilot workflow provides full end-to-end coverage. These tests + * validate the compose generation and container setup. */ /// @@ -18,9 +22,11 @@ const CLI_PROXY_IP = '172.30.0.50'; const CLI_PROXY_PORT = 11000; // Common test options for cli-proxy tests +// Note: These tests require a running external DIFC proxy at the specified host const cliProxyDefaults = { allowDomains: ['github.com', 'api.github.com'], - enableCliProxy: true, + difcProxyHost: 'host.docker.internal:18443', + difcProxyCaCert: '/tmp/difc-proxy-tls/ca.crt', buildLocal: true, logLevel: 'debug' as const, timeout: 120000, @@ -41,39 +47,6 @@ describe('CLI Proxy Sidecar', () => { await cleanup(false); }); - describe('Health and Startup', () => { - test('should start cli-proxy sidecar and pass healthcheck', async () => { - const result = await runner.runWithSudo( - `curl -s http://${CLI_PROXY_IP}:${CLI_PROXY_PORT}/health`, - cliProxyDefaults, - ); - - expect(result).toSucceed(); - expect(result.stdout).toContain('"status":"ok"'); - expect(result.stdout).toContain('"service":"cli-proxy"'); - }, 180000); - - test('should report writable=false in healthcheck by default', async () => { - const result = await runner.runWithSudo( - `curl -s http://${CLI_PROXY_IP}:${CLI_PROXY_PORT}/health`, - cliProxyDefaults, - ); - - expect(result).toSucceed(); - expect(result.stdout).toContain('"writable":false'); - }, 180000); - - test('should report writable=true when --cli-proxy-writable is set', async () => { - const result = await runner.runWithSudo( - `curl -s http://${CLI_PROXY_IP}:${CLI_PROXY_PORT}/health`, - { ...cliProxyDefaults, cliProxyWritable: true }, - ); - - expect(result).toSucceed(); - expect(result.stdout).toContain('"writable":true'); - }, 180000); - }); - describe('Token Isolation', () => { test('should not expose GITHUB_TOKEN in agent environment', async () => { const result = await runner.runWithSudo( @@ -137,9 +110,7 @@ describe('CLI Proxy Sidecar', () => { ); // gh --version goes through the wrapper and the proxy server - // The proxy may block --version as it's not a recognized subcommand. // Either way, it should not crash — we just verify the wrapper is invoked. - // If it fails, the error should come from the proxy, not "command not found" const output = extractCommandOutput(result.stdout); const stderr = result.stderr || ''; // Should NOT get "command not found" — the wrapper must be installed @@ -147,86 +118,17 @@ describe('CLI Proxy Sidecar', () => { }, 180000); }); - describe('Read-Only Mode (default)', () => { - test('should block write operations in read-only mode', async () => { - // Try to execute a write operation: 'gh issue create' - // In read-only mode, 'create' action under 'issue' is blocked - const result = await runner.runWithSudo( - `bash -c 'curl -s -X POST http://${CLI_PROXY_IP}:${CLI_PROXY_PORT}/exec -H "Content-Type: application/json" -d "{\\"args\\":[\\"issue\\",\\"create\\",\\"--title\\",\\"test\\"]}"'`, - cliProxyDefaults, - ); - - expect(result).toSucceed(); - // The proxy should return a 403 with an error about the blocked action - expect(result.stdout).toMatch(/denied|blocked|not allowed|read.only/i); - }, 180000); - - test('should block gh api in read-only mode', async () => { - // 'api' is always blocked in read-only mode (raw HTTP passthrough risk) - const result = await runner.runWithSudo( - `bash -c 'curl -s -X POST http://${CLI_PROXY_IP}:${CLI_PROXY_PORT}/exec -H "Content-Type: application/json" -d "{\\"args\\":[\\"api\\",\\"/repos/github/gh-aw-firewall\\"]}"'`, - cliProxyDefaults, - ); - - expect(result).toSucceed(); - expect(result.stdout).toMatch(/denied|blocked|not allowed/i); - }, 180000); - - test('should block auth subcommand even in writable mode', async () => { + describe('Meta-command Denial', () => { + test('should block auth subcommand', async () => { // 'auth' is always denied (meta-command) const result = await runner.runWithSudo( `bash -c 'curl -s -w "\\nHTTP_STATUS:%{http_code}" -X POST http://${CLI_PROXY_IP}:${CLI_PROXY_PORT}/exec -H "Content-Type: application/json" -d "{\\"args\\":[\\"auth\\",\\"status\\"]}"'`, - { ...cliProxyDefaults, cliProxyWritable: true }, + cliProxyDefaults, ); expect(result).toSucceed(); expect(result.stdout).toContain('HTTP_STATUS:403'); expect(result.stdout).toMatch(/denied|blocked|not allowed|not permitted/i); }, 180000); - - test('should allow read operations in read-only mode', async () => { - // 'pr list' is a read-only operation — should be allowed by the proxy. - // The actual gh command may fail (auth error from mcpg with fake token), - // but the proxy should NOT block it at the allowlist level. - const result = await runner.runWithSudo( - `bash -c 'curl -s -w "\\nHTTP_STATUS:%{http_code}" -X POST http://${CLI_PROXY_IP}:${CLI_PROXY_PORT}/exec -H "Content-Type: application/json" -d "{\\"args\\":[\\"pr\\",\\"list\\",\\"--repo\\",\\"github/gh-aw-firewall\\",\\"--limit\\",\\"1\\"]}"'`, - cliProxyDefaults, - ); - - expect(result).toSucceed(); - // HTTP 200 means the proxy allowed the command (even if gh itself errored) - expect(result.stdout).toContain('HTTP_STATUS:200'); - }, 180000); - }); - - describe('Writable Mode', () => { - test('should allow gh api in writable mode', async () => { - // 'api' is permitted in writable mode - const result = await runner.runWithSudo( - `bash -c 'curl -s -w "\\nHTTP_STATUS:%{http_code}" -X POST http://${CLI_PROXY_IP}:${CLI_PROXY_PORT}/exec -H "Content-Type: application/json" -d "{\\"args\\":[\\"api\\",\\"/repos/github/gh-aw-firewall\\"]}"'`, - { ...cliProxyDefaults, cliProxyWritable: true }, - ); - - expect(result).toSucceed(); - // HTTP 200 means the proxy allowed the command - expect(result.stdout).toContain('HTTP_STATUS:200'); - }, 180000); - }); - - describe('Squid Integration', () => { - test('should route cli-proxy traffic through Squid domain allowlist', async () => { - // The cli-proxy container uses HTTP_PROXY/HTTPS_PROXY to route through Squid. - // A domain NOT in --allow-domains should be blocked by Squid. - // We verify by checking that the cli-proxy env includes the proxy settings. - const result = await runner.runWithSudo( - `bash -c 'docker exec awf-cli-proxy env | grep -i proxy || true'`, - { ...cliProxyDefaults, keepContainers: true }, - ); - - // `env | grep -i proxy` writes matches to stdout, and `|| true` forces a zero exit code. - // Verify the cli-proxy environment includes the expected proxy-related settings. - expect(result).toSucceed(); - expect(extractCommandOutput(result.stdout)).toMatch(/HTTP_PROXY|HTTPS_PROXY|squid/i); - }, 180000); }); });