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
6 changes: 6 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,9 @@ jobs:
cd containers/api-proxy
npm ci
npm test

- name: Run CLI proxy unit tests
run: |
cd containers/cli-proxy
npm ci
npm test
6 changes: 4 additions & 2 deletions containers/agent/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,15 @@ RUN if ! getent group awfuser >/dev/null 2>&1; then \
mkdir -p /home/awfuser/.copilot/logs && \
chown -R awfuser:awfuser /home/awfuser

# Copy iptables setup script, PID logger, API proxy health check, and Claude key helper
# Copy iptables setup script, PID logger, API proxy health check, Claude key helper,
# and gh CLI proxy wrapper (used when --enable-cli-proxy is active)
COPY setup-iptables.sh /usr/local/bin/setup-iptables.sh
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
COPY pid-logger.sh /usr/local/bin/pid-logger.sh
COPY api-proxy-health-check.sh /usr/local/bin/api-proxy-health-check.sh
COPY get-claude-key.sh /usr/local/bin/get-claude-key.sh
RUN chmod +x /usr/local/bin/setup-iptables.sh /usr/local/bin/entrypoint.sh /usr/local/bin/pid-logger.sh /usr/local/bin/api-proxy-health-check.sh /usr/local/bin/get-claude-key.sh
COPY gh-cli-proxy-wrapper.sh /usr/local/bin/gh-cli-proxy-wrapper.sh
RUN chmod +x /usr/local/bin/setup-iptables.sh /usr/local/bin/entrypoint.sh /usr/local/bin/pid-logger.sh /usr/local/bin/api-proxy-health-check.sh /usr/local/bin/get-claude-key.sh /usr/local/bin/gh-cli-proxy-wrapper.sh

# Copy pre-built one-shot-token library from rust-builder stage
# This prevents tokens from being read multiple times (e.g., by malicious code)
Expand Down
33 changes: 33 additions & 0 deletions containers/agent/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,24 @@ if [ "${AWF_CHROOT_ENABLED}" = "true" ]; then
fi
fi

# Activate gh CLI proxy wrapper when CLI proxy sidecar is enabled.
# The wrapper at /usr/local/bin/gh-cli-proxy-wrapper.sh (baked into the image)
# is copied to /tmp/awf-lib/gh so it is accessible inside the chroot at a
# location that takes precedence over the host's /usr/bin/gh mount.
if [ -n "$AWF_CLI_PROXY_URL" ] && [ -f /usr/local/bin/gh-cli-proxy-wrapper.sh ]; then
if mkdir -p /host/tmp/awf-lib 2>/dev/null; then
if cp /usr/local/bin/gh-cli-proxy-wrapper.sh /host/tmp/awf-lib/gh 2>/dev/null && \
chmod +x /host/tmp/awf-lib/gh 2>/dev/null; then
# The chroot will see this as /tmp/awf-lib/gh (the /host prefix is the bind mount)
echo "[entrypoint] gh CLI proxy wrapper installed at /tmp/awf-lib/gh (inside chroot)"
# Prepend /tmp/awf-lib to PATH so the wrapper takes precedence over host gh
export AWF_HOST_PATH="/tmp/awf-lib:${AWF_HOST_PATH:-$PATH}"
else
echo "[entrypoint][WARN] Could not install gh CLI proxy wrapper"
fi
fi
fi

# Copy AWF CA certificate to chroot-accessible path for ssl-bump TLS trust.
# NODE_EXTRA_CA_CERTS points to /usr/local/share/ca-certificates/awf-ca.crt which
# is a Docker volume mount on the container's overlay filesystem. After chroot /host,
Expand Down Expand Up @@ -782,6 +800,21 @@ AWFEOF
else
# Original behavior - run in container filesystem
# Drop capabilities and privileges, then execute the user command

# Activate gh CLI proxy wrapper in non-chroot mode.
# Copy the wrapper to /tmp/awf-lib/gh so it takes precedence over
# the system gh at /usr/bin/gh (since /tmp/awf-lib is prepended to PATH).
if [ -n "$AWF_CLI_PROXY_URL" ] && [ -f /usr/local/bin/gh-cli-proxy-wrapper.sh ]; then
mkdir -p /tmp/awf-lib
if cp /usr/local/bin/gh-cli-proxy-wrapper.sh /tmp/awf-lib/gh 2>/dev/null && \
chmod +x /tmp/awf-lib/gh 2>/dev/null; then
export PATH="/tmp/awf-lib:${PATH}"
echo "[entrypoint] gh CLI proxy wrapper installed at /tmp/awf-lib/gh"
else
echo "[entrypoint][WARN] Could not install gh CLI proxy wrapper"
fi
fi

# This prevents malicious code from modifying iptables rules or using chroot
# Security note: capsh --drop removes capabilities from the bounding set,
# preventing any process (even if it escalates to root) from acquiring them
Expand Down
67 changes: 67 additions & 0 deletions containers/agent/gh-cli-proxy-wrapper.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#!/bin/sh
# /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
# host-mounted gh binary at /host/usr/bin/gh.
#
# Dependencies: curl, jq (both available in the agent container)

CLI_PROXY="${AWF_CLI_PROXY_URL:-http://172.30.0.50:11000}"

# Build JSON array from all positional arguments
ARGS_JSON='[]'
if [ $# -gt 0 ]; then
ARGS_JSON=$(printf '%s\n' "$@" | jq -R . | jq -s .)
fi

# Capture working directory
CWD=$(pwd)

# Read stdin if data is available (non-interactive)
STDIN_DATA=""
if [ ! -t 0 ]; then
STDIN_DATA=$(cat | base64 | tr -d '\n')
fi

# Use a temporary file to capture the response body without -f,
# so we can read the body even on 4xx/5xx responses (e.g., 403 policy block).
RESPONSE_FILE=$(mktemp)
HTTP_STATUS=$(curl -s \
--max-time 60 \
-o "$RESPONSE_FILE" \
-w "%{http_code}" \
-X POST "${CLI_PROXY}/exec" \
-H "Content-Type: application/json" \
--data-binary "$(printf '{"args":%s,"cwd":%s,"stdin":"%s"}' \
"$ARGS_JSON" \
"$(printf '%s' "$CWD" | jq -Rs .)" \
"$STDIN_DATA")")
CURL_EXIT=$?
RESPONSE=$(cat "$RESPONSE_FILE")
rm -f "$RESPONSE_FILE"

if [ "$CURL_EXIT" -ne 0 ]; then
echo "gh: CLI proxy unavailable at ${CLI_PROXY} (curl exit ${CURL_EXIT})" >&2
exit 1
fi

# Surface policy errors (403), request errors (400/413), and server errors (5xx)
if [ "$HTTP_STATUS" != "200" ]; then
ERROR=$(printf '%s' "$RESPONSE" | jq -r '.error // empty' 2>/dev/null)
if [ -n "$ERROR" ]; then
echo "gh: ${ERROR}" >&2
else
echo "gh: CLI proxy returned HTTP ${HTTP_STATUS}" >&2
fi
exit 1
fi

# Extract and emit stdout/stderr from a successful 200 response
STDOUT=$(printf '%s' "$RESPONSE" | jq -r '.stdout // empty' 2>/dev/null)
STDERR=$(printf '%s' "$RESPONSE" | jq -r '.stderr // empty' 2>/dev/null)
EXIT_CODE=$(printf '%s' "$RESPONSE" | jq -r '.exitCode // 1' 2>/dev/null)

printf '%s' "$STDOUT"
printf '%s' "$STDERR" >&2
exit "${EXIT_CODE:-1}"
7 changes: 7 additions & 0 deletions containers/agent/setup-iptables.sh
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,13 @@ if [ -n "$AWF_API_PROXY_IP" ]; then
iptables -t nat -A OUTPUT -d "$AWF_API_PROXY_IP" -j RETURN
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
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
fi

# Validate port specification (single port 1-65535 or range N-M)
# Rejects leading zeros (e.g., 080) to align with TypeScript isValidPortSpec()
is_valid_port_spec() {
Expand Down
66 changes: 66 additions & 0 deletions containers/cli-proxy/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# CLI Proxy sidecar for AWF - provides gh CLI access with mcpg DIFC proxy
#
# Multi-stage build:
# Stage 1: Extract mcpg binary from ghcr.io/github/gh-aw-mcpg image
# Stage 2: Assemble final image with gh CLI, Node.js, and mcpg
#
# This container runs two processes:
# 1. mcpg proxy (TLS, port 18443) - holds GH_TOKEN, enforces guard policies
# 2. HTTP server (port 11000) - receives gh invocations from the agent container

# Stage 1: Extract the mcpg binary from the official gh-aw-mcpg image.
# MCPG_IMAGE is configurable via --cli-proxy-mcpg-image so the AWF compiler
# can control which mcpg version is pulled and run (e.g. for version pinning
# or testing a new mcpg release before it is bundled in the GHCR cli-proxy image).
ARG MCPG_IMAGE=ghcr.io/github/gh-aw-mcpg:v0.2.15
FROM ${MCPG_IMAGE} AS mcpg-source

# Stage 2: Build the CLI proxy image
FROM node:22-alpine

# Install gh CLI and curl for healthchecks/wrapper
# gh CLI is available in the Alpine community repository
RUN apk add --no-cache \
curl \
github-cli \
ca-certificates \
bash

# Copy the mcpg binary from the mcpg-source stage
COPY --from=mcpg-source /usr/local/bin/mcpg /usr/local/bin/mcpg
RUN chmod +x /usr/local/bin/mcpg

# Create app directory
WORKDIR /app

# Copy package files
COPY package*.json ./

# Install dependencies from lockfile (deterministic)
RUN npm ci --omit=dev

# Copy application files
COPY server.js ./
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
COPY healthcheck.sh /usr/local/bin/healthcheck.sh

RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/healthcheck.sh

# Create non-root user
RUN addgroup -S cliproxy && adduser -S cliproxy -G cliproxy

# Create log directory owned by cliproxy (so non-root process can write)
RUN mkdir -p /var/log/cli-proxy/mcpg && \
chown -R cliproxy:cliproxy /var/log/cli-proxy

# Create /tmp/proxy-tls directory owned by cliproxy for mcpg TLS cert generation
RUN mkdir -p /tmp/proxy-tls && chown cliproxy:cliproxy /tmp/proxy-tls

# Switch to non-root user
USER cliproxy

# Expose port for agent→cli-proxy HTTP communication
# 11000 - gh exec endpoint and health check
EXPOSE 11000

ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
Comment on lines +49 to +66

Copilot AI Apr 6, 2026

Copy link

Choose a reason for hiding this comment

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

The image creates a non-root user (cliproxy) but never switches to it, so both mcpg and the HTTP server will run as root. This also contradicts the comment in src/docker-manager.ts that the CLI proxy runs as non-root. Add a USER cliproxy (or equivalent) before the entrypoint and ensure required paths (/var/log/cli-proxy, /tmp/proxy-tls) remain writable.

Copilot uses AI. Check for mistakes.
103 changes: 103 additions & 0 deletions containers/cli-proxy/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/bin/bash
# CLI Proxy sidecar entrypoint
# Starts the mcpg DIFC proxy (GH_TOKEN required), then starts the Node.js HTTP server
# under a supervisor loop so signals are properly handled and mcpg is cleaned up.
set -e

echo "[cli-proxy] Starting CLI proxy sidecar..."

MCPG_PID=""
NODE_PID=""

# GH_TOKEN is required: without it, mcpg cannot authenticate and DIFC guard policies
# cannot be enforced. Fail closed rather than starting an unenforced server.
if [ -z "$GH_TOKEN" ]; then
echo "[cli-proxy] ERROR: GH_TOKEN not set - refusing to start without mcpg DIFC enforcement"
exit 1
fi

echo "[cli-proxy] GH_TOKEN present - starting mcpg DIFC proxy..."

mkdir -p /tmp/proxy-tls /var/log/cli-proxy/mcpg

# Build the guard policy JSON if not explicitly provided
if [ -z "$AWF_GH_GUARD_POLICY" ]; then
if [ -n "$GITHUB_REPOSITORY" ]; then
AWF_GH_GUARD_POLICY="{\"repos\":[\"${GITHUB_REPOSITORY}\"],\"min-integrity\":\"public\"}"
else
AWF_GH_GUARD_POLICY="{\"min-integrity\":\"public\"}"
fi
echo "[cli-proxy] Using default guard policy: ${AWF_GH_GUARD_POLICY}"
else
echo "[cli-proxy] Using provided guard policy"
fi

# Start mcpg proxy in background
# mcpg proxy holds GH_TOKEN and applies DIFC guard policies before forwarding
mcpg proxy \
--policy "${AWF_GH_GUARD_POLICY}" \
--listen 127.0.0.1:18443 \
--tls \
--tls-dir /tmp/proxy-tls \
--guards-mode filter \
--trusted-bots "github-actions[bot],github-actions,dependabot[bot],copilot" \
--log-dir /var/log/cli-proxy/mcpg &
MCPG_PID=$!
echo "[cli-proxy] mcpg proxy started (PID: ${MCPG_PID})"

# Wait for TLS cert to be generated (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

if [ ! -f /tmp/proxy-tls/ca.crt ]; then
echo "[cli-proxy] ERROR: mcpg TLS certificate not generated within 30s"
kill "$MCPG_PID" 2>/dev/null || true
exit 1
fi

# Configure gh CLI to route through the mcpg proxy (TLS, self-signed CA)
export GH_HOST="localhost:18443"
export NODE_EXTRA_CA_CERTS="/tmp/proxy-tls/ca.crt"
export GH_REPO="${GH_REPO:-$GITHUB_REPOSITORY}"

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

# Cleanup handler: stop both the Node HTTP server and mcpg when we receive a signal
# or when the server exits. This runs correctly because we do NOT exec Node — we
# start it in the background and wait, so the shell (and its traps) remain active.
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 "$MCPG_PID" ]; then
kill "$MCPG_PID" 2>/dev/null || true
wait "$MCPG_PID" 2>/dev/null || true
fi
}
trap 'cleanup; exit 0' INT TERM

# Start the Node.js HTTP server in the background so the shell keeps running
# and traps remain active for graceful shutdown.
echo "[cli-proxy] Starting HTTP server on port 11000..."
node /app/server.js &
NODE_PID=$!

# Wait for Node to exit and propagate its exit code
if wait "$NODE_PID"; then
NODE_EXIT=0
else
NODE_EXIT=$?
fi

cleanup
exit "$NODE_EXIT"
4 changes: 4 additions & 0 deletions containers/cli-proxy/healthcheck.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/bin/sh
# Healthcheck for the CLI proxy sidecar
# Verifies the HTTP server is responsive
curl -sf --max-time 3 http://localhost:11000/health > /dev/null
Loading
Loading