Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
2 changes: 1 addition & 1 deletion containers/agent/gh-cli-proxy-wrapper.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion containers/agent/setup-iptables.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 6 additions & 7 deletions containers/cli-proxy/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand All @@ -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
Expand Down
61 changes: 33 additions & 28 deletions containers/cli-proxy/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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

Expand Down
107 changes: 14 additions & 93 deletions containers/cli-proxy/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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',
]);

Comment on lines 27 to 37
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current meta-command denylist is very small. In particular, gh alias should be denied as well: gh aliases can be defined with shell execution (e.g., !cmd), which allows arbitrary command execution inside the cli-proxy container (bypassing the intent of routing through the DIFC proxy). Add alias to ALWAYS_DENIED_SUBCOMMANDS (and consider other gh subcommands that can execute local programs).

Copilot uses AI. Check for mistakes.
/**
* 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' };
}
Expand All @@ -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];
Expand All @@ -125,7 +69,6 @@ function validateArgs(args, writable) {
}
} else {
subcommand = arg;
subcommandIndex = i;
break;
}
}
Expand All @@ -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 };
}

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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 => {
Expand All @@ -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 };
Loading
Loading