Skip to content

Commit 9071254

Browse files
lpcoxCopilot
andauthored
feat: enable cli-proxy in smoke-copilot workflow (#1820)
* feat: enable cli-proxy in smoke-copilot workflow Add features.cli-proxy: true to smoke-copilot.md and recompile with gh-aw dev build (bda91a78) that emits --difc-proxy-host and --difc-proxy-ca-cert flags plus Start/Stop CLI proxy steps. Post-processed with postprocess-smoke-workflows.ts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: handle quoted paths in postprocess install step regex The new gh-aw compiler quotes the install_awf_binary.sh path: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.17 The regex only matched unquoted paths, so the install step was not replaced with npm ci / npm run build. This caused --build-local to fail at runtime since the standalone bundle doesn't support it. Add optional double-quote matching ("?) around the path in the regex. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: inject GH_TOKEN into cli-proxy container environment The gh CLI inside the cli-proxy container needs a GitHub token to authenticate API requests. Without it, gh commands hang waiting for auth and time out after 30s. The token is safe in the cli-proxy container: it's inside the firewall perimeter, not accessible to the agent, and the DIFC proxy on the host provides write-control via its guard policy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: add iptables filter ACCEPT rule for cli-proxy The cli-proxy container (172.30.0.50) had a NAT RETURN rule so traffic wouldn't be DNAT'd to Squid, but was missing the corresponding filter ACCEPT rule. The final 'iptables -A OUTPUT -p tcp -j DROP' rule silently dropped all TCP connections to cli-proxy, causing 'curl exit 28' timeouts in the agent's gh-cli-proxy-wrapper.sh. This matches how api-proxy is handled: both NAT RETURN (line 173) and filter ACCEPT (line 406). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: add structured logging to cli-proxy Add JSON-line access logging to server.js and connection logging to tcp-tunnel.js for better observability in CI: server.js: - Writes structured JSON entries to /var/log/cli-proxy/access.log (volume already mounted and preserved by docker-manager.ts) - Also emits to stderr for docker logs capture - Logs: server_start (config summary), exec_start (args, cwd), exec_done (exit code, duration, output sizes), exec_denied, exec_error, and unhandled errors - Includes truncated stderr preview on non-zero exit for debugging tcp-tunnel.js: - Logs new connections and disconnects with client address - Includes client address in error messages for correlation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: ignore agent cwd in cli-proxy execFile The agent wrapper sends its container workspace path (e.g. /home/runner/work/gh-aw-firewall/gh-aw-firewall) as the cwd in /exec requests. This path doesn't exist inside the cli-proxy container, causing Node.js execFile to throw ENOENT — which looks like 'gh' is missing but is actually a cwd resolution failure. The gh CLI doesn't need the agent's workspace path — it operates on remote GitHub resources via --repo flags. Always use the server's own cwd (/app) instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: create combined CA bundle for gh CLI TLS trust The gh CLI is a Go binary that uses the system CA store (or SSL_CERT_FILE), not NODE_EXTRA_CA_CERTS. The DIFC proxy's self-signed CA cert was only trusted by Node.js, causing every gh command to fail with 'x509: certificate signed by unknown authority'. Create a combined CA bundle (system CAs + DIFC proxy CA) at startup and export SSL_CERT_FILE so the gh CLI trusts the proxy. Also add SSL_CERT_FILE to the protected env keys to prevent agent override. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: add CLI_PROXY_POLICY to DIFC proxy startup The DIFC proxy (mcpg) requires a --policy flag to forward API requests. Without it, it returns 503 'proxy enforcement not configured' for all requests. The gh-aw compiler doesn't emit CLI_PROXY_POLICY yet, so add it directly to the lock file. Uses a permissive allow-only policy for smoke testing: repos=all, min-integrity=none. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1fe1010 commit 9071254

File tree

8 files changed

+284
-207
lines changed

8 files changed

+284
-207
lines changed

.github/workflows/smoke-copilot.lock.yml

Lines changed: 192 additions & 200 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/workflows/smoke-copilot.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ safe-outputs:
3434
run-success: "📰 VERDICT: [{workflow_name}]({run_url}) has concluded. All systems operational. This is a developing story. 🎤"
3535
run-failure: "📰 DEVELOPING STORY: [{workflow_name}]({run_url}) reports {status}. Our correspondents are investigating the incident..."
3636
timeout-minutes: 5
37+
features:
38+
cli-proxy: true
3739
strict: true
3840
steps:
3941
- name: Pre-compute smoke test data

containers/agent/setup-iptables.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,11 @@ if [ -n "$AWF_API_PROXY_IP" ]; then
406406
iptables -A OUTPUT -p tcp -d "$AWF_API_PROXY_IP" -j ACCEPT
407407
fi
408408

409+
# Allow traffic to CLI proxy sidecar (when enabled)
410+
if [ -n "$AWF_CLI_PROXY_IP" ]; then
411+
iptables -A OUTPUT -p tcp -d "$AWF_CLI_PROXY_IP" -j ACCEPT
412+
fi
413+
409414
# Log dangerous port access attempts for audit (rate-limited to avoid log flooding)
410415
# These ports are blocked by NAT RETURN + final DROP, but logging helps identify
411416
# what the agent tried to access

containers/cli-proxy/entrypoint.sh

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,22 @@ if [ ! -f /tmp/proxy-tls/ca.crt ]; then
3535
fi
3636
echo "[cli-proxy] TLS certificate available"
3737

38+
# Build a combined CA bundle so the gh CLI (Go binary) trusts the DIFC proxy's
39+
# self-signed cert. NODE_EXTRA_CA_CERTS only helps Node.js; Go programs use
40+
# the system store or SSL_CERT_FILE.
41+
COMBINED_CA="/tmp/proxy-tls/combined-ca.crt"
42+
cat /etc/ssl/certs/ca-certificates.crt /tmp/proxy-tls/ca.crt > "${COMBINED_CA}"
43+
echo "[cli-proxy] Combined CA bundle created at ${COMBINED_CA}"
44+
3845
# Configure gh CLI to route through the DIFC proxy via the TCP tunnel
3946
# Uses localhost because the tunnel makes the DIFC proxy appear on localhost,
4047
# matching the self-signed cert's SAN.
4148
export GH_HOST="localhost:${DIFC_PORT}"
4249
export GH_REPO="${GH_REPO:-$GITHUB_REPOSITORY}"
43-
# The CA cert is guaranteed to exist at this point (we exit above if missing)
50+
# Node.js (server.js / tcp-tunnel.js) uses NODE_EXTRA_CA_CERTS;
51+
# gh CLI (Go) uses SSL_CERT_FILE pointing to the combined bundle.
4452
export NODE_EXTRA_CA_CERTS="/tmp/proxy-tls/ca.crt"
53+
export SSL_CERT_FILE="${COMBINED_CA}"
4554

4655
echo "[cli-proxy] gh CLI configured to route through DIFC proxy at ${GH_HOST}"
4756

containers/cli-proxy/server.js

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,42 @@
1818
*/
1919

2020
const http = require('http');
21+
const fs = require('fs');
22+
const path = require('path');
2123
const { execFile } = require('child_process');
2224

2325
const CLI_PROXY_PORT = parseInt(process.env.AWF_CLI_PROXY_PORT || '11000', 10);
2426
const COMMAND_TIMEOUT_MS = parseInt(process.env.AWF_CLI_PROXY_TIMEOUT_MS || '30000', 10);
2527
const MAX_OUTPUT_BYTES = parseInt(process.env.AWF_CLI_PROXY_MAX_OUTPUT_BYTES || String(10 * 1024 * 1024), 10);
2628

29+
// --- Structured logging to /var/log/cli-proxy/access.log ---
30+
31+
const LOG_DIR = process.env.AWF_CLI_PROXY_LOG_DIR || '/var/log/cli-proxy';
32+
const LOG_FILE = path.join(LOG_DIR, 'access.log');
33+
34+
let logStream = null;
35+
try {
36+
if (fs.existsSync(LOG_DIR)) {
37+
logStream = fs.createWriteStream(LOG_FILE, { flags: 'a' });
38+
}
39+
} catch {
40+
// Non-fatal: logging to file is best-effort
41+
}
42+
43+
/**
44+
* Write a structured JSON log entry to the access log file and stderr.
45+
* Each line is a self-contained JSON object for easy parsing.
46+
*/
47+
function accessLog(entry) {
48+
const record = { ts: new Date().toISOString(), ...entry };
49+
const line = JSON.stringify(record);
50+
if (logStream) {
51+
logStream.write(line + '\n');
52+
}
53+
// Also emit to stderr so docker logs captures it
54+
console.error(line);
55+
}
56+
2757
/**
2858
* Meta-commands that are always denied.
2959
* These modify gh itself rather than GitHub resources.
@@ -169,13 +199,15 @@ function handleHealth(res) {
169199
* }
170200
*/
171201
async function handleExec(req, res) {
202+
const startTime = Date.now();
172203
let body;
173204
try {
174205
const raw = await readBody(req, res);
175206
// null means readBody already sent a 413 error response
176207
if (raw === null) return;
177208
body = JSON.parse(raw.toString('utf8'));
178209
} catch {
210+
accessLog({ event: 'exec_error', error: 'Invalid JSON body' });
179211
return sendError(res, 400, 'Invalid JSON body');
180212
}
181213

@@ -184,15 +216,18 @@ async function handleExec(req, res) {
184216
// Validate args
185217
const validation = validateArgs(args);
186218
if (!validation.valid) {
219+
accessLog({ event: 'exec_denied', args, error: validation.error });
187220
return sendError(res, 403, validation.error);
188221
}
189222

223+
accessLog({ event: 'exec_start', args, cwd: cwd || null });
224+
190225
// Build environment for the subprocess
191226
// Inherit server environment (includes GH_HOST, NODE_EXTRA_CA_CERTS, GH_REPO, etc.)
192227
const childEnv = Object.assign({}, process.env);
193228
if (extraEnv && typeof extraEnv === 'object') {
194229
// Only allow safe string env overrides; never allow overriding GH_HOST or GH_TOKEN
195-
const PROTECTED_KEYS = new Set(['GH_HOST', 'GH_TOKEN', 'GITHUB_TOKEN', 'NODE_EXTRA_CA_CERTS']);
230+
const PROTECTED_KEYS = new Set(['GH_HOST', 'GH_TOKEN', 'GITHUB_TOKEN', 'NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE']);
196231
for (const [key, value] of Object.entries(extraEnv)) {
197232
if (typeof key === 'string' && typeof value === 'string' && !PROTECTED_KEYS.has(key)) {
198233
childEnv[key] = value;
@@ -201,14 +236,16 @@ async function handleExec(req, res) {
201236
}
202237

203238
// Execute gh directly (no shell — prevents injection attacks)
239+
// Always use the server's own cwd — the agent sends its container workspace
240+
// path which doesn't exist inside the cli-proxy container.
204241
let stdout = '';
205242
let stderr = '';
206243
let exitCode = 0;
207244

208245
try {
209246
const result = await new Promise((resolve, reject) => {
210247
const child = execFile('gh', args, {
211-
cwd: cwd || process.cwd(),
248+
cwd: process.cwd(),
212249
env: childEnv,
213250
timeout: COMMAND_TIMEOUT_MS,
214251
maxBuffer: MAX_OUTPUT_BYTES,
@@ -251,6 +288,19 @@ async function handleExec(req, res) {
251288
}
252289

253290
const responseBody = JSON.stringify({ stdout, stderr, exitCode });
291+
292+
const durationMs = Date.now() - startTime;
293+
accessLog({
294+
event: 'exec_done',
295+
args,
296+
exitCode,
297+
durationMs,
298+
stdoutBytes: stdout.length,
299+
stderrBytes: stderr.length,
300+
// Include truncated stderr for debugging failures (redact tokens)
301+
...(exitCode !== 0 && stderr ? { stderrPreview: stderr.slice(0, 500) } : {}),
302+
});
303+
254304
res.writeHead(200, {
255305
'Content-Type': 'application/json',
256306
'Content-Length': Buffer.byteLength(responseBody),
@@ -277,18 +327,27 @@ async function requestHandler(req, res) {
277327
if (require.main === module) {
278328
const server = http.createServer((req, res) => {
279329
requestHandler(req, res).catch(err => {
280-
console.error('[cli-proxy] Unhandled request error:', err);
330+
accessLog({ event: 'unhandled_error', error: err.message });
281331
if (!res.headersSent) {
282332
sendError(res, 500, 'Internal server error');
283333
}
284334
});
285335
});
286336

287337
server.listen(CLI_PROXY_PORT, '0.0.0.0', () => {
338+
accessLog({
339+
event: 'server_start',
340+
port: CLI_PROXY_PORT,
341+
timeoutMs: COMMAND_TIMEOUT_MS,
342+
ghHost: process.env.GH_HOST || '(not set)',
343+
caCert: process.env.NODE_EXTRA_CA_CERTS || '(not set)',
344+
hasGhToken: !!process.env.GH_TOKEN,
345+
});
288346
console.log(`[cli-proxy] HTTP server listening on port ${CLI_PROXY_PORT}`);
289347
});
290348

291349
server.on('error', err => {
350+
accessLog({ event: 'server_error', error: err.message });
292351
console.error('[cli-proxy] Server error:', err);
293352
process.exit(1);
294353
});

containers/cli-proxy/tcp-tunnel.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,14 @@ if (isNaN(remotePort) || remotePort < 1 || remotePort > 65535) {
3939
}
4040

4141
const server = net.createServer(client => {
42+
const clientAddr = `${client.remoteAddress}:${client.remotePort}`;
43+
console.error(`[tcp-tunnel] Connection from ${sanitizeForLog(clientAddr)}`);
4244
const upstream = net.connect(remotePort, remoteHost);
4345
client.pipe(upstream);
4446
upstream.pipe(client);
45-
client.on('error', (err) => { console.error('[tcp-tunnel] Client error:', sanitizeForLog(err.message)); upstream.destroy(); });
46-
upstream.on('error', (err) => { console.error('[tcp-tunnel] Upstream error:', sanitizeForLog(err.message)); client.destroy(); });
47+
client.on('error', (err) => { console.error(`[tcp-tunnel] Client error (${sanitizeForLog(clientAddr)}): ${sanitizeForLog(err.message)}`); upstream.destroy(); });
48+
upstream.on('error', (err) => { console.error(`[tcp-tunnel] Upstream error (${sanitizeForLog(clientAddr)}): ${sanitizeForLog(err.message)}`); client.destroy(); });
49+
client.on('close', () => { console.error(`[tcp-tunnel] Connection closed: ${sanitizeForLog(clientAddr)}`); });
4750
});
4851

4952
server.on('error', (err) => {

scripts/ci/postprocess-smoke-workflows.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,9 @@ const workflowPaths = [
5454
// Matches the install step with captured indentation:
5555
// - "Install awf binary" or "Install AWF binary" step at any indent level
5656
// - run command invoking install_awf_binary.sh with a version
57+
// - path may or may not be double-quoted (newer gh-aw compilers quote it)
5758
const installStepRegex =
58-
/^(\s*)- name: Install [Aa][Ww][Ff] binary\n\1\s*run: bash (?:\/opt\/gh-aw|\$\{RUNNER_TEMP\}\/gh-aw)\/actions\/install_awf_binary\.sh v[0-9.]+\n/m;
59+
/^(\s*)- name: Install [Aa][Ww][Ff] binary\n\1\s*run: bash "?(?:\/opt\/gh-aw|\$\{RUNNER_TEMP\}\/gh-aw)\/actions\/install_awf_binary\.sh"? v[0-9.]+\n/m;
5960
const installStepRegexGlobal = new RegExp(installStepRegex.source, 'gm');
6061

6162
function buildLocalInstallSteps(indent: string): string {

src/docker-manager.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1713,6 +1713,12 @@ export function generateDockerCompose(
17131713
AWF_DIFC_PROXY_PORT: difcProxyPort,
17141714
// Pass GITHUB_REPOSITORY for GH_REPO default in entrypoint
17151715
...(process.env.GITHUB_REPOSITORY && { GITHUB_REPOSITORY: process.env.GITHUB_REPOSITORY }),
1716+
// The gh CLI inside the cli-proxy needs a GitHub token to authenticate API
1717+
// requests. The token is safe here: the cli-proxy container is inside the
1718+
// firewall perimeter and not accessible to the agent. The DIFC proxy on the
1719+
// host provides write-control via its guard policy.
1720+
...(process.env.GH_TOKEN && { GH_TOKEN: process.env.GH_TOKEN }),
1721+
...(process.env.GITHUB_TOKEN && !process.env.GH_TOKEN && { GH_TOKEN: process.env.GITHUB_TOKEN }),
17161722
// Prevent curl/node from routing localhost or host.docker.internal through Squid
17171723
NO_PROXY: `localhost,127.0.0.1,::1,host.docker.internal`,
17181724
no_proxy: `localhost,127.0.0.1,::1,host.docker.internal`,

0 commit comments

Comments
 (0)