Skip to content

Commit e229765

Browse files
lpcoxCopilotCopilot
authored
test: add CLI proxy sidecar integration tests (#1734)
* test: add CLI proxy sidecar integration tests Phase 2 of the CLI proxy implementation (issue #1726): - Add enableCliProxy and cliProxyWritable to AwfOptions interface - Map --enable-cli-proxy and --cli-proxy-writable flags in both run() and runWithSudo() methods - Add awf-cli-proxy to cleanup in both test fixtures and CI script - Create cli-proxy.test.ts with integration tests covering: - Health endpoint and startup (read-only and writable modes) - gh wrapper installation and invocation - Token isolation (GITHUB_TOKEN/GH_TOKEN excluded from agent) - AWF_CLI_PROXY_URL environment variable injection - Read-only mode: write operations blocked, read operations allowed - Always-denied meta-commands (auth) even in writable mode - Writable mode: gh api permitted - Squid proxy integration (traffic routed through domain allowlist) Closes #1726 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update tests/integration/cli-proxy.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update tests/integration/cli-proxy.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update tests/fixtures/cleanup.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 93903cf commit e229765

File tree

4 files changed

+252
-2
lines changed

4 files changed

+252
-2
lines changed

scripts/ci/cleanup.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ echo "==========================================="
1212

1313
# First, explicitly remove containers by name (handles orphaned containers)
1414
echo "Removing awf containers by name..."
15-
docker rm -f awf-squid awf-agent awf-iptables-init awf-api-proxy 2>/dev/null || true
15+
docker rm -f awf-squid awf-agent awf-iptables-init awf-api-proxy awf-cli-proxy 2>/dev/null || true
1616

1717
# Cleanup diagnostic test containers
1818
echo "Stopping docker compose services..."

tests/fixtures/awf-runner.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export interface AwfOptions {
2020
allowHostPorts?: string; // Ports or port ranges to allow for host access (e.g., '3000' or '3000-8000')
2121
allowHostServicePorts?: string; // Ports to allow ONLY to host gateway (bypasses dangerous port restrictions)
2222
enableApiProxy?: boolean; // Enable API proxy sidecar for LLM credential management
23+
enableCliProxy?: boolean; // Enable CLI proxy sidecar for secure gh CLI access
24+
cliProxyWritable?: boolean; // Allow write operations through the CLI proxy
2325
rateLimitRpm?: number; // Requests per minute per provider
2426
rateLimitRph?: number; // Requests per hour per provider
2527
rateLimitBytesPm?: number; // Request bytes per minute per provider
@@ -130,6 +132,14 @@ export class AwfRunner {
130132
args.push('--enable-api-proxy');
131133
}
132134

135+
// Add enable-cli-proxy flags
136+
if (options.enableCliProxy) {
137+
args.push('--enable-cli-proxy');
138+
}
139+
if (options.cliProxyWritable) {
140+
args.push('--cli-proxy-writable');
141+
}
142+
133143
// Add API target flags
134144
if (options.copilotApiTarget) {
135145
args.push('--copilot-api-target', options.copilotApiTarget);
@@ -343,6 +353,14 @@ export class AwfRunner {
343353
args.push('--enable-api-proxy');
344354
}
345355

356+
// Add enable-cli-proxy flags
357+
if (options.enableCliProxy) {
358+
args.push('--enable-cli-proxy');
359+
}
360+
if (options.cliProxyWritable) {
361+
args.push('--cli-proxy-writable');
362+
}
363+
346364
// Add API target flags
347365
if (options.copilotApiTarget) {
348366
args.push('--copilot-api-target', options.copilotApiTarget);

tests/fixtures/cleanup.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export class Cleanup {
2525
async removeContainers(): Promise<void> {
2626
this.log('Removing awf containers by name...');
2727
try {
28-
await execa('docker', ['rm', '-f', 'awf-squid', 'awf-agent', 'awf-api-proxy']);
28+
await execa('docker', ['rm', '-f', 'awf-squid', 'awf-agent', 'awf-api-proxy', 'awf-cli-proxy', 'awf-iptables-init']);
2929
} catch (error) {
3030
// Ignore errors (containers may not exist)
3131
}
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
/**
2+
* CLI Proxy Sidecar Integration Tests
3+
*
4+
* Tests that the --enable-cli-proxy flag correctly starts the CLI proxy sidecar,
5+
* routes gh CLI commands through the mcpg DIFC proxy, enforces subcommand
6+
* allowlists, and isolates GITHUB_TOKEN from the agent container.
7+
*/
8+
9+
/// <reference path="../jest-custom-matchers.d.ts" />
10+
11+
import { describe, test, expect, beforeAll, afterAll } from '@jest/globals';
12+
import { createRunner, AwfRunner } from '../fixtures/awf-runner';
13+
import { cleanup } from '../fixtures/cleanup';
14+
import { extractCommandOutput } from '../fixtures/stdout-helpers';
15+
16+
// The CLI proxy sidecar is at this fixed IP on the awf-net network
17+
const CLI_PROXY_IP = '172.30.0.50';
18+
const CLI_PROXY_PORT = 11000;
19+
20+
// Common test options for cli-proxy tests
21+
const cliProxyDefaults = {
22+
allowDomains: ['github.com', 'api.github.com'],
23+
enableCliProxy: true,
24+
buildLocal: true,
25+
logLevel: 'debug' as const,
26+
timeout: 120000,
27+
env: {
28+
GITHUB_TOKEN: 'ghp_fake-test-token-for-cli-proxy-12345',
29+
},
30+
};
31+
32+
describe('CLI Proxy Sidecar', () => {
33+
let runner: AwfRunner;
34+
35+
beforeAll(async () => {
36+
await cleanup(false);
37+
runner = createRunner();
38+
});
39+
40+
afterAll(async () => {
41+
await cleanup(false);
42+
});
43+
44+
describe('Health and Startup', () => {
45+
test('should start cli-proxy sidecar and pass healthcheck', async () => {
46+
const result = await runner.runWithSudo(
47+
`curl -s http://${CLI_PROXY_IP}:${CLI_PROXY_PORT}/health`,
48+
cliProxyDefaults,
49+
);
50+
51+
expect(result).toSucceed();
52+
expect(result.stdout).toContain('"status":"ok"');
53+
expect(result.stdout).toContain('"service":"cli-proxy"');
54+
}, 180000);
55+
56+
test('should report writable=false in healthcheck by default', async () => {
57+
const result = await runner.runWithSudo(
58+
`curl -s http://${CLI_PROXY_IP}:${CLI_PROXY_PORT}/health`,
59+
cliProxyDefaults,
60+
);
61+
62+
expect(result).toSucceed();
63+
expect(result.stdout).toContain('"writable":false');
64+
}, 180000);
65+
66+
test('should report writable=true when --cli-proxy-writable is set', async () => {
67+
const result = await runner.runWithSudo(
68+
`curl -s http://${CLI_PROXY_IP}:${CLI_PROXY_PORT}/health`,
69+
{ ...cliProxyDefaults, cliProxyWritable: true },
70+
);
71+
72+
expect(result).toSucceed();
73+
expect(result.stdout).toContain('"writable":true');
74+
}, 180000);
75+
});
76+
77+
describe('Token Isolation', () => {
78+
test('should not expose GITHUB_TOKEN in agent environment', async () => {
79+
const result = await runner.runWithSudo(
80+
'bash -c "if [ -z \\"$GITHUB_TOKEN\\" ]; then echo GITHUB_TOKEN_NOT_SET; else echo GITHUB_TOKEN=$GITHUB_TOKEN; fi"',
81+
cliProxyDefaults,
82+
);
83+
84+
expect(result).toSucceed();
85+
const output = extractCommandOutput(result.stdout);
86+
expect(output).toContain('GITHUB_TOKEN_NOT_SET');
87+
}, 180000);
88+
89+
test('should not expose GH_TOKEN in agent environment', async () => {
90+
const result = await runner.runWithSudo(
91+
'bash -c "if [ -z \\"$GH_TOKEN\\" ]; then echo GH_TOKEN_NOT_SET; else echo GH_TOKEN=$GH_TOKEN; fi"',
92+
{
93+
...cliProxyDefaults,
94+
env: {
95+
GH_TOKEN: 'ghp_fake-test-token-gh-12345',
96+
},
97+
},
98+
);
99+
100+
expect(result).toSucceed();
101+
const output = extractCommandOutput(result.stdout);
102+
expect(output).toContain('GH_TOKEN_NOT_SET');
103+
}, 180000);
104+
105+
test('should set AWF_CLI_PROXY_URL in agent environment', async () => {
106+
const result = await runner.runWithSudo(
107+
'bash -c "echo AWF_CLI_PROXY_URL=$AWF_CLI_PROXY_URL"',
108+
cliProxyDefaults,
109+
);
110+
111+
expect(result).toSucceed();
112+
expect(result.stdout).toContain(`AWF_CLI_PROXY_URL=http://${CLI_PROXY_IP}:${CLI_PROXY_PORT}`);
113+
}, 180000);
114+
});
115+
116+
describe('gh Wrapper', () => {
117+
test('should install gh wrapper that routes to cli-proxy', async () => {
118+
// The gh wrapper should be at /usr/local/bin/gh or accessible via PATH.
119+
// Running 'which gh' should find it.
120+
const result = await runner.runWithSudo(
121+
'bash -c "which gh && head -3 $(which gh)"',
122+
cliProxyDefaults,
123+
);
124+
125+
expect(result).toSucceed();
126+
const output = extractCommandOutput(result.stdout);
127+
// The wrapper script should reference CLI_PROXY or AWF_CLI_PROXY_URL
128+
expect(output).toMatch(/cli.proxy|AWF_CLI_PROXY/i);
129+
}, 180000);
130+
131+
test('should execute gh commands through the wrapper', async () => {
132+
// gh --version should work through the proxy (it runs locally in the sidecar)
133+
// Note: this tests that the wrapper → HTTP POST → server.js → execFile chain works
134+
const result = await runner.runWithSudo(
135+
'gh --version',
136+
cliProxyDefaults,
137+
);
138+
139+
// gh --version goes through the wrapper and the proxy server
140+
// The proxy may block --version as it's not a recognized subcommand.
141+
// Either way, it should not crash — we just verify the wrapper is invoked.
142+
// If it fails, the error should come from the proxy, not "command not found"
143+
const output = extractCommandOutput(result.stdout);
144+
const stderr = result.stderr || '';
145+
// Should NOT get "command not found" — the wrapper must be installed
146+
expect(output + stderr).not.toContain('command not found');
147+
}, 180000);
148+
});
149+
150+
describe('Read-Only Mode (default)', () => {
151+
test('should block write operations in read-only mode', async () => {
152+
// Try to execute a write operation: 'gh issue create'
153+
// In read-only mode, 'create' action under 'issue' is blocked
154+
const result = await runner.runWithSudo(
155+
`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\\"]}"'`,
156+
cliProxyDefaults,
157+
);
158+
159+
expect(result).toSucceed();
160+
// The proxy should return a 403 with an error about the blocked action
161+
expect(result.stdout).toMatch(/denied|blocked|not allowed|read.only/i);
162+
}, 180000);
163+
164+
test('should block gh api in read-only mode', async () => {
165+
// 'api' is always blocked in read-only mode (raw HTTP passthrough risk)
166+
const result = await runner.runWithSudo(
167+
`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\\"]}"'`,
168+
cliProxyDefaults,
169+
);
170+
171+
expect(result).toSucceed();
172+
expect(result.stdout).toMatch(/denied|blocked|not allowed/i);
173+
}, 180000);
174+
175+
test('should block auth subcommand even in writable mode', async () => {
176+
// 'auth' is always denied (meta-command)
177+
const result = await runner.runWithSudo(
178+
`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\\"]}"'`,
179+
{ ...cliProxyDefaults, cliProxyWritable: true },
180+
);
181+
182+
expect(result).toSucceed();
183+
expect(result.stdout).toContain('HTTP_STATUS:403');
184+
expect(result.stdout).toMatch(/denied|blocked|not allowed|not permitted/i);
185+
}, 180000);
186+
187+
test('should allow read operations in read-only mode', async () => {
188+
// 'pr list' is a read-only operation — should be allowed by the proxy.
189+
// The actual gh command may fail (auth error from mcpg with fake token),
190+
// but the proxy should NOT block it at the allowlist level.
191+
const result = await runner.runWithSudo(
192+
`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\\"]}"'`,
193+
cliProxyDefaults,
194+
);
195+
196+
expect(result).toSucceed();
197+
// HTTP 200 means the proxy allowed the command (even if gh itself errored)
198+
expect(result.stdout).toContain('HTTP_STATUS:200');
199+
}, 180000);
200+
});
201+
202+
describe('Writable Mode', () => {
203+
test('should allow gh api in writable mode', async () => {
204+
// 'api' is permitted in writable mode
205+
const result = await runner.runWithSudo(
206+
`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\\"]}"'`,
207+
{ ...cliProxyDefaults, cliProxyWritable: true },
208+
);
209+
210+
expect(result).toSucceed();
211+
// HTTP 200 means the proxy allowed the command
212+
expect(result.stdout).toContain('HTTP_STATUS:200');
213+
}, 180000);
214+
});
215+
216+
describe('Squid Integration', () => {
217+
test('should route cli-proxy traffic through Squid domain allowlist', async () => {
218+
// The cli-proxy container uses HTTP_PROXY/HTTPS_PROXY to route through Squid.
219+
// A domain NOT in --allow-domains should be blocked by Squid.
220+
// We verify by checking that the cli-proxy env includes the proxy settings.
221+
const result = await runner.runWithSudo(
222+
`bash -c 'docker exec awf-cli-proxy env | grep -i proxy || true'`,
223+
{ ...cliProxyDefaults, keepContainers: true },
224+
);
225+
226+
// `env | grep -i proxy` writes matches to stdout, and `|| true` forces a zero exit code.
227+
// Verify the cli-proxy environment includes the expected proxy-related settings.
228+
expect(result).toSucceed();
229+
expect(extractCommandOutput(result.stdout)).toMatch(/HTTP_PROXY|HTTPS_PROXY|squid/i);
230+
}, 180000);
231+
});
232+
});

0 commit comments

Comments
 (0)