Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions containers/cli-proxy/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ DIFC_PORT="${AWF_DIFC_PROXY_PORT:-18443}"

echo "[cli-proxy] External DIFC proxy at ${DIFC_HOST}:${DIFC_PORT}"

if [ "${AWF_CLI_PROXY_RELAY_ONLY:-}" = "1" ]; then
echo "[cli-proxy-egress] Starting fixed-target relay on 0.0.0.0:${DIFC_PORT} → ${DIFC_HOST}:${DIFC_PORT}"
exec node /app/tcp-tunnel.js "${DIFC_PORT}" "${DIFC_HOST}" "${DIFC_PORT}" "0.0.0.0"
fi

# 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}"
Expand Down
13 changes: 9 additions & 4 deletions containers/cli-proxy/tcp-tunnel.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
* 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 <localPort> <remoteHost> <remotePort>
* Usage: node tcp-tunnel.js <localPort> <remoteHost> <remotePort> [bindHost]
*
* bindHost defaults to loopback-only. The credential-free egress relay passes
* 0.0.0.0 so the internal cli-proxy can reach its single fixed target.
*/

const net = require('net');
Expand All @@ -20,9 +23,10 @@ function sanitizeForLog(value) {
const localPortStr = process.argv[2];
const remoteHost = process.argv[3];
const remotePortStr = process.argv[4];
const bindHostArg = process.argv[5];

if (!localPortStr || !remoteHost || !remotePortStr) {
console.error('[tcp-tunnel] Usage: node tcp-tunnel.js <localPort> <remoteHost> <remotePort>');
console.error('[tcp-tunnel] Usage: node tcp-tunnel.js <localPort> <remoteHost> <remotePort> [bindHost]');
process.exit(1);
}

Expand All @@ -38,7 +42,7 @@ if (isNaN(remotePort) || remotePort < 1 || remotePort > 65535) {
process.exit(1);
}

const bindHosts = ['127.0.0.1', '::1'];
const bindHosts = bindHostArg ? [bindHostArg] : ['127.0.0.1', '::1'];
let startedServers = 0;
let readyLogged = false;

Expand Down Expand Up @@ -69,7 +73,8 @@ for (const bindHost of bindHosts) {
startedServers += 1;
if (!readyLogged && (startedServers === bindHosts.length || bindHost === '127.0.0.1')) {
readyLogged = true;
console.log(`[tcp-tunnel] Forwarding localhost:${localPort} → ${remoteHost}:${remotePort}`);
const listenAddress = bindHostArg || 'localhost';
console.log(`[tcp-tunnel] Forwarding ${listenAddress}:${localPort} → ${remoteHost}:${remotePort}`);
}
});
}
24 changes: 17 additions & 7 deletions docs/network-isolation-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,25 @@ filtering:
- The agent (and sidecars) live on `awf-net`, declared `internal: true` — an internal
network has **no route to the host or the internet**.
- **Squid is dual-homed** (`awf-net` + an external `awf-ext` bridge) and is therefore the
**sole** egress path; it still applies the same domain allowlist.
**sole egress path for the agent**; it still applies the same domain allowlist. The agent
itself never touches `awf-ext` directly.
- The credential-bearing **cli-proxy sidecar always remains on `awf-net` only**. When its
`--difc-proxy-host` target is external (`host.docker.internal`, a bare IP outside
`awf-net`'s subnet, or a dotted DNS name), AWF creates a separate credential-free
`cli-proxy-egress` relay. Only this fixed-target TCP relay is dual-homed, and it can
forward solely to the configured DIFC host and port. This lets the cli-proxy reach the
host DIFC proxy without giving agent-controlled `gh` or Git subprocesses an unrestricted
route through `awf-ext`. When the DIFC proxy is an attached sibling container, no relay
is needed or created.
- No host iptables, no `NET_ADMIN`, no `sudo`.

This works today for the agent's own egress. What it does **not** yet handle is the
**MCP gateway (mcpg)** and the **gh CLI integrity proxy (DIFC)**, both of which gh-aw runs
as **host-network containers** reached via `--enable-host-access`. Topology mode
deliberately rejects `--enable-host-access`, and an `internal` network has no host route
anyway — so the standard Copilot/gh-aw harness cannot currently run under
`--network-isolation`.
This works today for the agent's own egress, and (through the fixed-target relay above)
for AWF's own `--difc-proxy-host` cli-proxy sidecar. What it does **not** yet handle is gh-aw's
**separate** integration model, where gh-aw itself launches the **MCP gateway (mcpg)** and
the **gh CLI integrity proxy (DIFC)** as **host-network containers** reached via
`--enable-host-access`. Topology mode deliberately rejects `--enable-host-access`, and an
`internal` network has no host route anyway — so the standard Copilot/gh-aw harness cannot
currently run under `--network-isolation`.

This note records the analysis and the concrete path to close that gap.

Expand Down
60 changes: 60 additions & 0 deletions src/compose-generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,66 @@ describe('generateDockerCompose', () => {
expect(cliProxyNetworks['awf-ext']).toBeUndefined();
});

it('keeps cli-proxy on awf-net only when the DIFC proxy is a sibling addressed by its awf-net IP', () => {
const config = {
...mockConfig,
networkIsolation: true,
difcProxyHost: '172.30.0.60:18443',
};
const networkWithCliProxy = {
...mockNetworkConfig,
cliProxyIp: '172.30.0.50',
};
const result = generateDockerCompose(config, networkWithCliProxy);

const cliProxyNetworks = result.services['cli-proxy'].networks as { [key: string]: { ipv4_address?: string } };
expect(cliProxyNetworks['awf-net'].ipv4_address).toBe('172.30.0.50');
expect(cliProxyNetworks['awf-ext']).toBeUndefined();
});

it('dual-homes only a credential-free relay when cli-proxy targets an external DIFC proxy', () => {
const config = {
...mockConfig,
networkIsolation: true,
difcProxyHost: 'host.docker.internal:18443',
};
const networkWithCliProxy = {
...mockNetworkConfig,
cliProxyIp: '172.30.0.50',
};
const result = generateDockerCompose(config, networkWithCliProxy);

const cliProxyNetworks = result.services['cli-proxy'].networks as { [key: string]: { ipv4_address?: string } };
expect(cliProxyNetworks['awf-net'].ipv4_address).toBe('172.30.0.50');
expect(cliProxyNetworks['awf-ext']).toBeUndefined();

const relay = result.services['cli-proxy-egress'];
const relayNetworks = relay.networks as Record<string, unknown>;
const relayEnvironment = relay.environment as Record<string, string>;
expect(relayNetworks['awf-net']).toBeDefined();
expect(relayNetworks['awf-ext']).toBeDefined();
expect(relayEnvironment.GH_TOKEN).toBeUndefined();
expect(relayEnvironment.AWF_DIFC_PROXY_HOST).toBe('host.docker.internal');
expect(relayEnvironment.AWF_DIFC_PROXY_PORT).toBe('18443');
});

it('keeps cli-proxy off awf-ext outside network-isolation mode', () => {
const config = {
...mockConfig,
networkIsolation: false,
difcProxyHost: 'host.docker.internal:18443',
};
const networkWithCliProxy = {
...mockNetworkConfig,
cliProxyIp: '172.30.0.50',
};
const result = generateDockerCompose(config, networkWithCliProxy);

const cliProxyNetworks = result.services['cli-proxy'].networks as { [key: string]: { ipv4_address?: string } };
expect(cliProxyNetworks['awf-ext']).toBeUndefined();
expect(result.services['cli-proxy-egress']).toBeUndefined();
});

it('should keep the agent on awf-net only (no external network)', () => {
const result = generateDockerCompose({ ...mockConfig, networkIsolation: true }, mockNetworkConfig);

Expand Down
110 changes: 108 additions & 2 deletions src/services/cli-proxy-service.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isExternalDifcProxyHost, normalizeLoopbackDifcHost } from './cli-proxy-service';
import { generateDockerCompose, WrapperConfig, baseConfig, mockNetworkConfig, useTempWorkDir } from './service-test-setup.test-utils';

// Create mock functions (must remain per-file — jest.mock() is hoisted before imports)
Expand Down Expand Up @@ -92,6 +93,60 @@ describe('CLI proxy sidecar (external DIFC proxy)', () => {
expect(env.AWF_DIFC_PROXY_PORT).toBe('18443');
});

it('should route cli-proxy through a credential-free relay for an external topology target', () => {
const originalGhToken = process.env.GH_TOKEN;
try {
process.env.GH_TOKEN = 'ghp_cli_proxy_test_token';
const configWithCliProxy = {
...mockConfig,
networkIsolation: true,
difcProxyHost: 'host.docker.internal:18443',
};
const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy);
const proxy = result.services['cli-proxy'];
const relay = result.services['cli-proxy-egress'];
const proxyNetworks = proxy.networks as Record<string, unknown>;
const proxyEnvironment = proxy.environment as Record<string, string>;
const proxyDependencies = proxy.depends_on as Record<string, { condition: string }>;
const relayNetworks = relay.networks as Record<string, unknown>;
const relayEnvironment = relay.environment as Record<string, string>;

expect(proxyNetworks['awf-ext']).toBeUndefined();
expect(proxyEnvironment.AWF_DIFC_PROXY_HOST).toBe('cli-proxy-egress');
expect(proxyDependencies['cli-proxy-egress'].condition).toBe('service_healthy');

expect(relayNetworks['awf-net']).toBeDefined();
expect(relayNetworks['awf-ext']).toBeDefined();
expect(relayEnvironment).toEqual({
AWF_CLI_PROXY_RELAY_ONLY: '1',
AWF_DIFC_PROXY_HOST: 'host.docker.internal',
AWF_DIFC_PROXY_PORT: '18443',
});
expect(relay.volumes).toBeUndefined();
expect(relay.read_only).toBe(true);
expect(relay.cap_drop).toEqual(['ALL']);
} finally {
if (originalGhToken !== undefined) {
process.env.GH_TOKEN = originalGhToken;
} else {
delete process.env.GH_TOKEN;
}
}
});

it('should not create an egress relay for an attached topology sibling', () => {
const configWithCliProxy = {
...mockConfig,
networkIsolation: true,
difcProxyHost: 'awmg-cli-proxy:18443',
};
const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy);
const proxyEnvironment = result.services['cli-proxy'].environment as Record<string, string>;

expect(result.services['cli-proxy-egress']).toBeUndefined();
expect(proxyEnvironment.AWF_DIFC_PROXY_HOST).toBe('awmg-cli-proxy');
});

it('should parse custom host and port from difcProxyHost', () => {
const configWithCliProxy = { ...mockConfig, difcProxyHost: 'custom-host:9999' };
const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy);
Expand All @@ -101,15 +156,28 @@ describe('CLI proxy sidecar (external DIFC proxy)', () => {
expect(env.AWF_DIFC_PROXY_PORT).toBe('9999');
});

it('should parse IPv6 bracketed host:port from difcProxyHost', () => {
it('should parse IPv6 bracketed host:port from difcProxyHost, normalizing loopback', () => {
// [::1] is the cli-proxy container's own loopback, not the runner
// host, so it must be normalized to host.docker.internal — otherwise
// the tcp-tunnel would dial itself instead of the host DIFC proxy.
const configWithCliProxy = { ...mockConfig, difcProxyHost: '[::1]:18443' };
const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy);
const proxy = result.services['cli-proxy'];
const env = proxy.environment as Record<string, string>;
expect(env.AWF_DIFC_PROXY_HOST).toBe('::1');
expect(env.AWF_DIFC_PROXY_HOST).toBe('host.docker.internal');
expect(env.AWF_DIFC_PROXY_PORT).toBe('18443');
});

it('should normalize localhost and 127.0.0.1 difcProxyHost to host.docker.internal', () => {
for (const host of ['localhost:18443', '127.0.0.1:18443']) {
const configWithCliProxy = { ...mockConfig, difcProxyHost: host };
const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy);
const proxy = result.services['cli-proxy'];
const env = proxy.environment as Record<string, string>;
expect(env.AWF_DIFC_PROXY_HOST).toBe('host.docker.internal');
}
});

it('should default port to 18443 when only host is specified', () => {
const configWithCliProxy = { ...mockConfig, difcProxyHost: 'my-host' };
const result = generateDockerCompose(configWithCliProxy, mockNetworkConfigWithCliProxy);
Expand Down Expand Up @@ -274,4 +342,42 @@ describe('CLI proxy sidecar (external DIFC proxy)', () => {
}
}
});
describe('isExternalDifcProxyHost', () => {
it('treats host gateway, non-subnet IPs and dotted names as external', () => {
expect(isExternalDifcProxyHost('host.docker.internal')).toBe(true);
expect(isExternalDifcProxyHost('172.17.0.1')).toBe(true);
expect(isExternalDifcProxyHost('::1')).toBe(true);
expect(isExternalDifcProxyHost('difc.example.com')).toBe(true);
});

it('treats bare container names as attached siblings', () => {
expect(isExternalDifcProxyHost('awmg-cli-proxy')).toBe(false);
expect(isExternalDifcProxyHost('')).toBe(false);
});

it('treats a static IP inside awf-net\'s own subnet as an attached sibling', () => {
// 172.30.0.0/24 is awf-net's own subnet (see NETWORK_SUBNET); a sibling
// given a static address there needs no extra egress.
expect(isExternalDifcProxyHost('172.30.0.50')).toBe(false);
expect(isExternalDifcProxyHost('172.30.0.255')).toBe(false);
// Just outside the /24 is still external.
expect(isExternalDifcProxyHost('172.30.1.1')).toBe(true);
});
});

describe('normalizeLoopbackDifcHost', () => {
it('rewrites loopback spellings to host.docker.internal', () => {
expect(normalizeLoopbackDifcHost('localhost')).toBe('host.docker.internal');
expect(normalizeLoopbackDifcHost('LOCALHOST')).toBe('host.docker.internal');
expect(normalizeLoopbackDifcHost('127.0.0.1')).toBe('host.docker.internal');
expect(normalizeLoopbackDifcHost('127.5.6.7')).toBe('host.docker.internal');
expect(normalizeLoopbackDifcHost('::1')).toBe('host.docker.internal');
});

it('leaves non-loopback hosts unchanged', () => {
expect(normalizeLoopbackDifcHost('host.docker.internal')).toBe('host.docker.internal');
expect(normalizeLoopbackDifcHost('awmg-cli-proxy')).toBe('awmg-cli-proxy');
expect(normalizeLoopbackDifcHost('172.30.0.50')).toBe('172.30.0.50');
});
});
});
Loading
Loading