Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
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
53 changes: 33 additions & 20 deletions containers/cli-proxy/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,25 +1,31 @@
#!/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..."
# 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=$!

# Wait for CA cert to appear (mounted from host by docker-manager.ts)
echo "[cli-proxy] Waiting for DIFC proxy TLS certificate..."
i=0
while [ $i -lt 30 ]; do
if [ -f /tmp/proxy-tls/ca.crt ]; then
Expand All @@ -31,26 +37,33 @@ while [ $i -lt 30 ]; do
done

if [ ! -f /tmp/proxy-tls/ca.crt ]; then
echo "[cli-proxy] ERROR: mcpg TLS certificate not found within 30s"
exit 1
echo "[cli-proxy] WARNING: DIFC proxy TLS certificate not found within 30s, continuing without it"
fi

# 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}"

echo "[cli-proxy] gh CLI configured to route through mcpg proxy at ${GH_HOST}"
# Only set NODE_EXTRA_CA_CERTS if the CA cert was mounted
if [ -f /tmp/proxy-tls/ca.crt ]; then
export NODE_EXTRA_CA_CERTS="/tmp/proxy-tls/ca.crt"
fi

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
106 changes: 13 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,61 +24,8 @@ 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([
Expand All @@ -87,13 +35,14 @@ const ALWAYS_DENIED_SUBCOMMANDS = new Set([
]);

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 +54,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 +68,6 @@ function validateArgs(args, writable) {
}
} else {
subcommand = arg;
subcommandIndex = i;
break;
}
}
Expand All @@ -140,28 +82,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 +141,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 +181,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 +284,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 +293,4 @@ if (require.main === module) {
});
}

module.exports = { validateArgs, ALLOWED_SUBCOMMANDS_READONLY, BLOCKED_ACTIONS_READONLY, ALWAYS_DENIED_SUBCOMMANDS };
module.exports = { validateArgs, ALWAYS_DENIED_SUBCOMMANDS };
Loading