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
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