diff --git a/container/gateway/Dockerfile b/container/gateway/Dockerfile new file mode 100644 index 0000000000..23741a5558 --- /dev/null +++ b/container/gateway/Dockerfile @@ -0,0 +1,66 @@ +FROM python:3.11-slim + +# Install git, gh CLI, and Squid proxy for network lockdown +RUN apt-get update && apt-get install -y \ + git curl gnupg \ + # Squid HTTP proxy with SSL support for SNI inspection + squid-openssl \ + # OpenSSL for certificate generation + openssl \ + # gosu for dropping privileges (Squid needs root start, gateway needs non-root) + gosu \ + && \ + curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | \ + dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] \ + https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list && \ + apt-get update && apt-get install -y gh && \ + rm -rf /var/lib/apt/lists/* + +# Generate self-signed certificate for Squid SSL bump (peek/splice, not MITM) +# This certificate is used only to inspect the SNI field, not to decrypt traffic +# Using 10-year validity (3650 days) since this is internal-only and doesn't +# require the security properties of short-lived certificates (no chain of trust) +RUN mkdir -p /etc/squid/ssl /var/log/squid /var/spool/squid && \ + openssl req -new -newkey rsa:2048 -sha256 -days 3650 -nodes -x509 \ + -subj "/CN=egg-gateway-proxy/O=egg/C=US" \ + -keyout /etc/squid/squid-ca.pem \ + -out /etc/squid/squid-ca.pem && \ + # Squid runs as proxy user - ensure it can read the certificate + chown proxy:proxy /etc/squid/squid-ca.pem && \ + chmod 400 /etc/squid/squid-ca.pem && \ + chown -R proxy:proxy /var/log/squid /var/spool/squid && \ + # Initialize Squid cache directories + /usr/sbin/squid -z -N 2>/dev/null || true + +# Create sandbox user with UID/GID 1000 so gosu can resolve HOME correctly +# Without this, gosu sets HOME=/ when dropping to UID 1000, breaking Path.home() +# This matches the sandbox container user setup for consistency +RUN groupadd -g 1000 sandbox && \ + useradd -m -u 1000 -g 1000 -s /bin/bash sandbox + +WORKDIR /app + +# Copy gateway Python modules +COPY gateway/*.py ./gateway/ +COPY shared/*.py ./shared/ + +# Copy Squid configuration for network lockdown +# squid.conf is the restrictive allowlist-based config (used in private mode) +# squid-allow-all.conf allows full internet access (used in public mode) +COPY container/gateway/squid.conf /etc/squid/squid.conf +COPY container/gateway/squid-allow-all.conf /etc/squid/squid-allow-all.conf +COPY container/gateway/allowed_domains.txt /etc/squid/allowed_domains.txt +RUN chmod 644 /etc/squid/squid.conf /etc/squid/squid-allow-all.conf /etc/squid/allowed_domains.txt + +# Install dependencies +RUN pip install --no-cache-dir flask waitress pyyaml requests PyJWT cryptography + +ENV PYTHONPATH="/app" +# Expose both gateway API port and Squid proxy port +EXPOSE 9847 3128 + +COPY container/gateway/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/container/gateway/allowed_domains.txt b/container/gateway/allowed_domains.txt new file mode 100644 index 0000000000..d5d1d48108 --- /dev/null +++ b/container/gateway/allowed_domains.txt @@ -0,0 +1,24 @@ +# Allowed domains for network lockdown (private mode) +# These are the only domains the sandbox container can access through the proxy +# +# Format: One domain per line, supports subdomains via .domain.com syntax +# Lines starting with # are comments + +# Anthropic Claude API +api.anthropic.com + +# IMPORTANT: GitHub domains are intentionally NOT in this allowlist. +# All GitHub/git access MUST go through the gateway sidecar's git/gh wrappers +# (via REST API at egg-gateway:9847), not directly through the proxy. +# This ensures policy enforcement (branch ownership, merge blocking, etc.) +# cannot be bypassed by direct API calls. +# +# The gateway sidecar holds the GitHub token and enforces: +# - Branch ownership policy (agent can only push to owned branches) +# - Merge blocking (agent cannot merge PRs - human must merge via GitHub UI) +# - Force push blocking (agent cannot force push) +# - Audit logging for all operations + +# Note: PyPI, npm, and other package managers are intentionally NOT included +# All dependencies must be pre-installed in the Docker image +# This prevents supply chain attacks and ensures reproducible builds diff --git a/container/gateway/entrypoint.sh b/container/gateway/entrypoint.sh new file mode 100644 index 0000000000..d6026af2dc --- /dev/null +++ b/container/gateway/entrypoint.sh @@ -0,0 +1,121 @@ +#!/bin/bash +set -e + +# ============================================================================= +# Gateway Sidecar Entrypoint +# +# Starts the gateway API server and Squid proxy for network filtering. +# +# The gateway always runs with locked-down Squid (allows only api.anthropic.com). +# Per-container mode is enforced at the container level: +# - Private containers: Use isolated network + route through this proxy +# - Public containers: Use external network + bypass proxy (direct internet) +# +# This allows private and public containers to run simultaneously without +# gateway restarts. +# ============================================================================= + +echo "=== Egg Gateway Sidecar Starting (Per-Container Mode Architecture) ===" +echo " Squid: Locked (api.anthropic.com only)" +echo " Private containers: Use proxy on isolated network" +echo " Public containers: Bypass proxy on external network" +echo "" + +# Always use locked-down Squid (only private containers route through it) +# Note: PRIVATE_MODE env var is no longer used - mode is per-container via sessions +SQUID_CONF="/etc/squid/squid.conf" + +# Note: GitHub tokens are now managed in-memory by token_refresher.py +# We only need to verify the launcher secret is mounted +if [ ! -f "/secrets/launcher-secret" ]; then + echo "ERROR: /secrets/launcher-secret not mounted" + exit 1 +fi + +# Export launcher secret for authentication +export EGG_LAUNCHER_SECRET=$(cat /secrets/launcher-secret) + +# ============================================================================= +# Start Squid Proxy for Network Filtering +# ============================================================================= + +echo "Starting Squid proxy for network filtering..." +echo "Using config: $SQUID_CONF" + +# Ensure log and spool directories exist and are writable +# Note: We may not have permission to chown (running as non-root), so we +# check writability directly and configure Squid to run with current user. +mkdir -p /var/log/squid /var/spool/squid + +# Try to set ownership for squid's preferred user, but don't fail if we can't +if chown -R proxy:proxy /var/log/squid /var/spool/squid 2>/dev/null; then + echo " Log directories owned by proxy:proxy" +else + # Running as non-root - verify directories are writable + if [ -w /var/log/squid ] && [ -w /var/spool/squid ]; then + echo " Log directories writable by current user ($(id -un))" + else + echo "WARNING: Log directories may not be writable - Squid logging may fail" + fi +fi + +# Initialize cache directories if needed +if [ ! -d "/var/spool/squid/00" ]; then + /usr/sbin/squid -z -N 2>/dev/null || true +fi + +# Verify Squid configuration exists +if [ ! -f "$SQUID_CONF" ]; then + echo "ERROR: Squid configuration not found: $SQUID_CONF" + exit 1 +fi +# Only check allowed_domains.txt in lockdown mode (not used in allow-all mode) +if [ "$SQUID_CONF" = "/etc/squid/squid.conf" ] && [ ! -f "/etc/squid/allowed_domains.txt" ]; then + echo "ERROR: Allowed domains file not found: /etc/squid/allowed_domains.txt" + exit 1 +fi + +# Start Squid in daemon mode +/usr/sbin/squid -f "$SQUID_CONF" + +# Wait for Squid to start +elapsed=0 +max_wait=30 +while [ $elapsed -lt $max_wait ]; do + if /usr/sbin/squid -k check 2>/dev/null; then + echo "Squid proxy started successfully on port 3128" + break + fi + sleep 1 + elapsed=$((elapsed + 1)) + echo "Waiting for Squid to start... ($elapsed/$max_wait)" +done + +if [ $elapsed -ge $max_wait ]; then + echo "ERROR: Squid failed to start within $max_wait seconds" + cat /var/log/squid/cache.log 2>/dev/null || true + exit 1 +fi + +# ============================================================================= +# Start Gateway API Server +# ============================================================================= + +echo "Starting gateway API server on port 9847..." + +# Run gateway on all interfaces (for container networking) +# Use exec to replace shell process with Python for proper signal handling +# +# If HOST_UID/HOST_GID are set, drop privileges using gosu before starting +# the Python gateway. This is required because: +# - Container starts as root so Squid can read its certificate +# - Gateway Python code must run as host user to avoid root-owned git files +if [ -n "${HOST_UID:-}" ] && [ -n "${HOST_GID:-}" ] && [ "$(id -u)" = "0" ]; then + echo "Dropping privileges to UID=$HOST_UID GID=$HOST_GID" + # Explicitly set HOME before gosu (consistent with sandbox container entrypoint) + # This ensures Path.home() resolves correctly in token_refresher.py + export HOME=/home/sandbox + exec gosu "$HOST_UID:$HOST_GID" python3 -m gateway.gateway --host 0.0.0.0 --port 9847 +else + exec python3 -m gateway.gateway --host 0.0.0.0 --port 9847 +fi diff --git a/container/gateway/squid-allow-all.conf b/container/gateway/squid-allow-all.conf new file mode 100644 index 0000000000..55b289cc9e --- /dev/null +++ b/container/gateway/squid-allow-all.conf @@ -0,0 +1,157 @@ +# Squid proxy configuration for "Public Mode" (PRIVATE_MODE=false) +# +# This configuration allows all domain access while still routing traffic +# through the proxy for audit logging. +# +# SECURITY MODEL: +# This config is used when PRIVATE_MODE=false (public mode), which provides: +# - Full internet access (all domains allowed) +# - Public repos only (private repos blocked) +# +# The single PRIVATE_MODE flag ensures you can't accidentally combine open +# network with private repo access (a security anti-pattern). +# +# Key properties: +# - All domains are allowed through the proxy +# - Direct IP connections are still blocked (security) +# - All traffic is logged for audit +# - Repository access control handled by gateway API (PRIVATE_MODE=false) + +# ============================================================================== +# Port Configuration +# ============================================================================== + +# HTTP/HTTPS proxy port with SSL bump for SNI inspection +# The certificate is used for peek/splice operations, NOT for MITM +# Note: generate-host-certificates=off because we only peek/splice (no MITM) +# This avoids needing to initialize the ssl_db certificate database +http_port 3128 ssl-bump \ + cert=/etc/squid/squid-ca.pem \ + generate-host-certificates=off \ + dynamic_cert_mem_cache_size=4MB + +# ============================================================================== +# Access Control Lists +# ============================================================================== + +# Define local network (egg-isolated subnet) +acl localnet src 172.30.0.0/24 + +# Block direct IP connections (bypass attempts) +# This prevents connections to IP addresses instead of hostnames. +# Multiple formats must be blocked to prevent bypass attacks: +# - Standard IPv4 decimal: 192.168.1.1 +# - IPv6 in brackets: [2607:f8b0:4004:800::200e] +# - Octal notation: 0177.0.0.1 (equals 127.0.0.1) +# - Hexadecimal notation: 0x7f.0x00.0x00.0x01 +# - Integer notation: 2130706433 (9-10 digit number) +acl direct_ipv4 url_regex ^https?://[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+ +acl direct_ipv6 url_regex ^https?://\[ +acl direct_ip_octal url_regex ^https?://0[0-7]+\. +acl direct_ip_hex url_regex ^https?://0x[0-9a-fA-F]+ +acl direct_ip_int url_regex ^https?://[0-9]{9,10}(/|$|:) + +# CONNECT method (used for HTTPS tunneling) +acl CONNECT method CONNECT + +# ============================================================================== +# SSL Bump Rules (SNI Inspection) +# ============================================================================== + +# Step 1: Peek at the TLS ClientHello to read SNI +acl step1 at_step SslBump1 + +# Peek at SNI, then splice all connections (allow all domains) +# In allow-all mode, we splice everything for maximum compatibility +ssl_bump peek step1 +ssl_bump splice all + +# ============================================================================== +# HTTP Access Rules +# ============================================================================== + +# Block direct IP connections (security: prevent SNI bypass) +# All IP address formats are blocked to prevent circumvention +http_access deny direct_ipv4 +http_access deny direct_ipv6 +http_access deny direct_ip_octal +http_access deny direct_ip_hex +http_access deny direct_ip_int + +# Allow CONNECT from local network (HTTPS tunneling to any domain) +http_access allow CONNECT localnet + +# Allow HTTP from local network (to any domain) +http_access allow localnet + +# Deny everything else +http_access deny all + +# ============================================================================== +# Logging Configuration +# ============================================================================== + +# Structured logging for audit purposes +# Format: timestamp duration client_ip status_code bytes method url hierarchy_code mime_type +logformat squid_json {"timestamp":"%{%Y-%m-%dT%H:%M:%S}tl.%03tu","duration_ms":%tr,"client_ip":"%>a","status":%>Hs,"bytes":%a","status":%>Hs,"bytes":% most-changing (scripts, configs) +# - When a layer changes, all subsequent layers rebuild +# - Copying files with COPY checks file contents, not timestamps + +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive + +# Install comprehensive development tools +RUN apt-get update && apt-get install -y \ + # Core utilities (includes grep, cut, sort, uniq, tr, etc.) + coreutils findutils util-linux \ + wget curl ca-certificates software-properties-common \ + gnupg lsb-release sudo gosu git \ + # Text processing + sed gawk less vim nano \ + # Build tools + make cmake gcc g++ build-essential pkg-config autoconf automake libtool \ + # Network tools + netcat-openbsd telnet iputils-ping dnsutils net-tools iproute2 \ + # File operations + rsync tar gzip bzip2 zip unzip p7zip-full \ + # Process management + procps htop lsof psmisc \ + # Development + strace ltrace gdb \ + # Other useful tools + jq tree watch tmux screen inotify-tools ripgrep shellcheck \ + && rm -rf /var/lib/apt/lists/* + +# Install GitHub CLI (gh) +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \ + && chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \ + && apt-get update \ + && apt-get install -y gh \ + && rm -rf /var/lib/apt/lists/* + +# Install Python 3.11 as default (Ubuntu 22.04 ships with 3.10) +# Required for datetime.UTC and other 3.11+ features used by egg scripts +RUN add-apt-repository -y ppa:deadsnakes/ppa && \ + apt-get update && \ + apt-get install -y python3.11 python3.11-venv python3.11-dev python3-pip && \ + update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1 && \ + update-alternatives --set python3 /usr/bin/python3.11 && \ + # Ensure 'python' command maps to python3 (many tools expect this) + update-alternatives --install /usr/bin/python python /usr/bin/python3.11 1 + +# Install pyyaml early - required for reading config +RUN pip3 install --no-cache-dir pyyaml + +# Install Python dependencies for egg components +RUN pip3 install --no-cache-dir \ + pyyaml \ + requests \ + cryptography \ + PyJWT \ + # Dev dependencies for linting and testing + ruff \ + pytest \ + # Required by gateway (for running tests) + flask \ + waitress + +# Network Lockdown: Pre-install all Python dependencies +# In lockdown mode, the container cannot access PyPI at runtime. +# All commonly-needed packages must be pre-installed here. +RUN pip3 install --no-cache-dir \ + # HTTP/API clients + requests httpx aiohttp urllib3 \ + # Data formats and parsing + pyyaml toml python-dateutil orjson \ + # Testing frameworks + pytest pytest-cov pytest-asyncio pytest-xdist hypothesis \ + # Code quality tools + black ruff mypy isort \ + # Type stubs + types-requests types-PyYAML types-toml \ + # CLI and terminal + click rich typer \ + # Cryptography and security + cryptography PyJWT \ + # Utilities + python-dotenv tenacity pydantic \ + # Database clients + psycopg2-binary redis \ + # Web frameworks + flask waitress \ + # Documentation + markdown + +# Copy egg runtime scripts and tools to /opt/egg-runtime +# This provides container-resident executables available in PATH +COPY container/sandbox/ /opt/egg-runtime/sandbox/ +COPY shared/ /opt/egg-runtime/shared/ +RUN chmod +x /opt/egg-runtime/sandbox/bin/* 2>/dev/null || true && \ + chmod +x /opt/egg-runtime/sandbox/scripts/* 2>/dev/null || true + +# Relocate git/gh binaries and symlink /usr/bin to wrappers +# This ensures ALL git/gh invocations route through the gateway sidecar, +# whether called as 'git' (PATH) or '/usr/bin/git' (absolute path) +RUN mkdir -p /opt/.egg-internal && \ + mv /usr/bin/git /opt/.egg-internal/git && \ + mv /usr/bin/gh /opt/.egg-internal/gh && \ + ln -s /opt/egg-runtime/sandbox/scripts/git /usr/bin/git && \ + ln -s /opt/egg-runtime/sandbox/scripts/gh /usr/bin/gh + +# Make egg modules importable via PYTHONPATH +ENV PYTHONPATH="/opt/egg-runtime/sandbox:/opt/egg-runtime/shared" + +# Create tmp directory in image (not mounted - container-only scratch space) +RUN mkdir -p /tmp/agent-tmp && chmod 1777 /tmp/agent-tmp + +# Create fixed container user 'sandbox' with default UID/GID 1000 +# The entrypoint will adjust UID/GID at runtime to match host user for proper file permissions +RUN groupadd -g 1000 sandbox && \ + useradd -m -u 1000 -g 1000 -s /bin/bash sandbox && \ + echo "sandbox ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/010-sandbox-nopasswd && \ + chmod 0440 /etc/sudoers.d/010-sandbox-nopasswd + +# Copy entrypoint script (Python for maintainability) +COPY container/sandbox/entrypoint.py /usr/local/bin/entrypoint.py +RUN chmod +x /usr/local/bin/entrypoint.py + +WORKDIR /home/sandbox + +ENTRYPOINT ["python3", "/usr/local/bin/entrypoint.py"] + +# ============================================================================= +# END OF DOCKERFILE +# ============================================================================= +# The entrypoint script (entrypoint.py) handles: +# - Adjusting sandbox user's UID/GID to match host user (for file permissions) +# - Git configuration and worktree setup +# - Environment configuration +# - Interactive/exec mode handling +# ============================================================================= diff --git a/container/sandbox/bin/git-credential-github-token b/container/sandbox/bin/git-credential-github-token new file mode 100644 index 0000000000..63edc50baa --- /dev/null +++ b/container/sandbox/bin/git-credential-github-token @@ -0,0 +1,115 @@ +#!/bin/bash +# +# Git credential helper that provides GitHub tokens for HTTPS authentication. +# +# NOTE: In the normal egg architecture, git operations are routed through the +# gateway sidecar which handles token management. This credential helper is +# a fallback for direct git operations. +# +# Token Sources (in priority order): +# 1. ~/sharing/.github-token file (if present) +# 2. GITHUB_TOKEN environment variable (set at container start) +# 3. GITHUB_READONLY_TOKEN for repos outside the primary App's scope +# +# Installation (done automatically by entrypoint): +# git config --global credential.helper /path/to/git-credential-github-token +# git config --global credential.https://github.com.helper '!/path/to/git-credential-github-token' +# +# How it works: +# 1. Git calls this script with "get" when authentication is needed +# 2. Script checks if the host is github.com +# 3. Reads repo path to determine which token to use +# 4. Returns username=x-access-token and password=$TOKEN +# 5. Git uses these credentials for the operation +# + +# Only respond to "get" requests +if [ "$1" != "get" ]; then + exit 0 +fi + +# Read the input from git (protocol, host, path, etc.) +host="" +path="" +while IFS='=' read -r key value; do + case "$key" in + host) + host="$value" + ;; + path) + path="$value" + ;; + esac +done + +# Only provide credentials for github.com +if [[ "$host" != "github.com" ]]; then + exit 0 +fi + +# Extract owner from path (e.g., "/owner/repo.git" -> "owner") +owner="" +if [[ -n "$path" ]]; then + # Remove leading slash if present + path="${path#/}" + # Extract owner (everything before first /) + owner="${path%%/*}" +fi + +# Token file location (legacy - now managed by gateway sidecar in-memory) +TOKEN_FILE="${HOME}/sharing/.github-token" + +# Determine which token to use based on repo owner +USE_READONLY=false +PRIMARY_OWNER="${GITHUB_PRIMARY_OWNER:-}" +if [[ -n "$owner" && -n "$PRIMARY_OWNER" && "$owner" != "$PRIMARY_OWNER" ]]; then + USE_READONLY=true +fi + +# Get token - prefer file (refreshed) over env var (potentially stale) +TOKEN="" + +# For external repos, try GITHUB_READONLY_TOKEN first +if [[ "$USE_READONLY" == "true" && -n "${GITHUB_READONLY_TOKEN:-}" ]]; then + TOKEN="$GITHUB_READONLY_TOKEN" +fi + +# If no readonly token, try the standard token sources +if [ -z "$TOKEN" ]; then + # Try to read from token file first + if [ -f "$TOKEN_FILE" ]; then + # Extract token from JSON file using python (available in container) + TOKEN=$(python3 -c " +import json +import sys +try: + with open('$TOKEN_FILE') as f: + data = json.load(f) + print(data.get('token', '')) +except: + pass +" 2>/dev/null) + fi +fi + +# Fall back to environment variable if file token not available +if [ -z "$TOKEN" ]; then + TOKEN="$GITHUB_TOKEN" +fi + +# Check if we have a token from any source +if [ -z "$TOKEN" ]; then + echo "error: No GitHub token available" >&2 + echo "error: Neither ~/sharing/.github-token nor GITHUB_TOKEN is set" >&2 + if [[ "$USE_READONLY" == "true" ]]; then + echo "error: For repo $owner/$path, GITHUB_READONLY_TOKEN is also not set" >&2 + fi + exit 1 +fi + +# Output credentials in git credential format +# x-access-token is the special username for GitHub App tokens +echo "protocol=https" +echo "host=github.com" +echo "username=x-access-token" +echo "password=$TOKEN" diff --git a/container/sandbox/entrypoint.py b/container/sandbox/entrypoint.py new file mode 100644 index 0000000000..ca0fd3eba7 --- /dev/null +++ b/container/sandbox/entrypoint.py @@ -0,0 +1,690 @@ +#!/usr/bin/env python3 +""" +Egg Sandbox Container Entrypoint + +Sets up the sandboxed container environment for the AI agent. +Handles user setup, git configuration, service initialization, and launches +the appropriate command. +""" + +# ruff: noqa: E402 +# Capture container start time FIRST - before any other imports +import time + +_CONTAINER_START_TIME = time.time() + +# Now import everything else +import json +import os +import signal +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path + +# ============================================================================= +# Startup Timing (Debug) +# ============================================================================= + +# Enabled via EGG_TIMING=1 env var +ENABLE_STARTUP_TIMING = os.environ.get("EGG_TIMING", "0") == "1" + + +class StartupTimer: + """Collects timing data for startup phases.""" + + def __init__(self): + self.timings: list[tuple[str, float]] = [] + self.start_time: float = time.perf_counter() + self._phase_start: float | None = None + self._phase_name: str | None = None + self.host_timings: list[tuple[str, float]] = [] + self.host_total_time: float = 0.0 + self.docker_startup_time: float = 0.0 # Gap between host launch and container start + # Capture time spent in Python init (imports) before this point + # Uses wall clock since _CONTAINER_START_TIME is wall clock + self.python_init_time: float = (time.time() - _CONTAINER_START_TIME) * 1000 + self._load_host_timing() + + def _load_host_timing(self) -> None: + """Load host timing data from environment variable.""" + host_timing_json = os.environ.get("EGG_HOST_TIMING", "") + if host_timing_json: + try: + data = json.loads(host_timing_json) + self.host_timings = data.get("timings", []) + self.host_total_time = data.get("total_time", 0.0) + except (json.JSONDecodeError, KeyError): + pass + + # Calculate docker startup gap (time between host launching container and Python starting) + host_launch_time_str = os.environ.get("EGG_HOST_LAUNCH_TIME", "") + if host_launch_time_str: + try: + host_launch_time = float(host_launch_time_str) + # Gap = container start time - host launch time (in milliseconds) + self.docker_startup_time = (_CONTAINER_START_TIME - host_launch_time) * 1000 + except (ValueError, TypeError): + pass + + def start_phase(self, name: str) -> None: + """Start timing a phase.""" + if not ENABLE_STARTUP_TIMING: + return + self._phase_name = name + self._phase_start = time.perf_counter() + + def end_phase(self) -> None: + """End timing the current phase.""" + if not ENABLE_STARTUP_TIMING or self._phase_start is None: + return + elapsed = (time.perf_counter() - self._phase_start) * 1000 # ms + self.timings.append((self._phase_name, elapsed)) + self._phase_name = None + self._phase_start = None + + def phase(self, name: str): + """Context manager for timing a phase.""" + timer = self + phase_name = name + + class PhaseContext: + def __enter__(self): + timer.start_phase(phase_name) + return self + + def __exit__(self, *args): + timer.end_phase() + + return PhaseContext() + + def print_summary(self) -> None: + """Print combined timing summary (host + container phases).""" + if not ENABLE_STARTUP_TIMING: + return + if not self.timings and not self.host_timings: + return + + # Container total includes python_init (imports) + all phases + phases_total = (time.perf_counter() - self.start_time) * 1000 + container_total = self.python_init_time + phases_total + grand_total = self.host_total_time + self.docker_startup_time + container_total + + print("\n" + "=" * 60) + print("STARTUP TIMING SUMMARY") + print("=" * 60) + print(f"{'Phase':<40} {'Time (ms)':>10} {'%':>6}") + print("-" * 60) + + # Print host phases (% of grand total) + if self.host_timings: + print("HOST:") + for name, elapsed in self.host_timings: + pct = (elapsed / grand_total) * 100 if grand_total > 0 else 0 + bar = "#" * int(pct / 5) + print(f" {name:<38} {elapsed:>10.1f} {pct:>5.1f}% {bar}") + print(f" {'(host total)':<38} {self.host_total_time:>10.1f}") + print() + + # Print docker startup gap + if self.docker_startup_time > 0: + print("DOCKER:") + pct = (self.docker_startup_time / grand_total) * 100 if grand_total > 0 else 0 + bar = "#" * int(pct / 5) + print( + f" {'container_startup':<38} {self.docker_startup_time:>10.1f} {pct:>5.1f}% {bar}" + ) + print() + + # Print container phases + if self.timings or self.python_init_time > 0: + print("CONTAINER:") + if self.python_init_time > 0: + pct = ( + (self.python_init_time / container_total) * 100 + if container_total > 0 + else 0 + ) + bar = "#" * int(pct / 5) + print( + f" {'python_init':<38} {self.python_init_time:>10.1f} {pct:>5.1f}% {bar}" + ) + for name, elapsed in self.timings: + pct = (elapsed / container_total) * 100 if container_total > 0 else 0 + bar = "#" * int(pct / 5) + print(f" {name:<38} {elapsed:>10.1f} {pct:>5.1f}% {bar}") + print(f" {'(container total)':<38} {container_total:>10.1f}") + + print("-" * 60) + print(f"{'GRAND TOTAL':<40} {grand_total:>10.1f}") + print("=" * 60 + "\n") + + +# Global timer instance +_startup_timer = StartupTimer() + + +# ============================================================================= +# Configuration +# ============================================================================= + + +@dataclass +class Config: + """Container configuration from environment variables.""" + + # Fixed container user - UID/GID adjusted at runtime to match host + container_user: str = "sandbox" + runtime_uid: int = field( + default_factory=lambda: int(os.environ.get("RUNTIME_UID", "1000")) + ) + runtime_gid: int = field( + default_factory=lambda: int(os.environ.get("RUNTIME_GID", "1000")) + ) + quiet: bool = field(default_factory=lambda: os.environ.get("EGG_QUIET", "0") == "1") + + # Derived paths - fixed home directory for sandbox user + @property + def user_home(self) -> Path: + return Path("/home/sandbox") + + @property + def repos_dir(self) -> Path: + """The directory containing mounted repositories.""" + return self.user_home / "repos" + + @property + def sharing_dir(self) -> Path: + return self.user_home / "sharing" + + +# ============================================================================= +# Logging +# ============================================================================= + + +class Logger: + """Simple logger with quiet mode support.""" + + def __init__(self, quiet: bool = False): + self.quiet = quiet + + def info(self, msg: str) -> None: + """Info message (hidden in quiet mode).""" + if not self.quiet: + print(msg) + + def success(self, msg: str) -> None: + """Success message with checkmark (hidden in quiet mode).""" + if not self.quiet: + print(f"[OK] {msg}") + + def warn(self, msg: str) -> None: + """Warning message (always shown).""" + print(f"[WARN] {msg}") + + def error(self, msg: str) -> None: + """Error message (always shown, to stderr).""" + print(f"[ERROR] {msg}", file=sys.stderr) + + +# ============================================================================= +# Utility Functions +# ============================================================================= + + +def run_cmd( + cmd: list[str], + check: bool = True, + capture: bool = False, + timeout: int = 30, + as_user: tuple[int, int] | None = None, +) -> subprocess.CompletedProcess: + """Run a command, optionally as a different user via gosu.""" + if as_user: + uid, gid = as_user + cmd = ["gosu", f"{uid}:{gid}"] + cmd + + return subprocess.run( + cmd, + check=check, + capture_output=capture, + text=True, + timeout=timeout, + ) + + +def chown_recursive(path: Path, uid: int, gid: int) -> None: + """Recursively change ownership of a path.""" + run_cmd(["chown", "-R", f"{uid}:{gid}", str(path)]) + + +# ============================================================================= +# Setup Functions +# ============================================================================= + + +def setup_user(config: Config, logger: Logger) -> None: + """Adjust sandbox user's UID/GID to match host user for proper file permissions.""" + import grp + import pwd + + logger.info( + f"Setting up sandboxed environment for user: {config.container_user} " + f"(uid={config.runtime_uid}, gid={config.runtime_gid})" + ) + + # Get current sandbox user's UID/GID + try: + current_uid = pwd.getpwnam(config.container_user).pw_uid + current_gid = grp.getgrnam(config.container_user).gr_gid + except KeyError: + logger.error( + f"User {config.container_user} not found - container image may be corrupt" + ) + raise + + # Adjust GID if needed + if current_gid != config.runtime_gid: + logger.info( + f"Adjusting {config.container_user} group GID: " + f"{current_gid} -> {config.runtime_gid}" + ) + run_cmd(["groupmod", "-g", str(config.runtime_gid), config.container_user]) + + # Adjust UID if needed + if current_uid != config.runtime_uid: + logger.info( + f"Adjusting {config.container_user} user UID: " + f"{current_uid} -> {config.runtime_uid}" + ) + run_cmd(["usermod", "-u", str(config.runtime_uid), config.container_user]) + + # Fix ownership of home directory after UID/GID change + if current_uid != config.runtime_uid or current_gid != config.runtime_gid: + logger.info("Fixing home directory ownership...") + start_time = time.time() + chown_recursive(config.user_home, config.runtime_uid, config.runtime_gid) + elapsed = time.time() - start_time + if elapsed > 1.0: + logger.info(f" chown completed in {elapsed:.1f}s") + + +def setup_environment(config: Config) -> None: + """Set up environment variables.""" + os.environ["HOME"] = str(config.user_home) + os.environ["USER"] = config.container_user + + # Add user's local bin and egg runtime scripts to PATH + current_path = os.environ.get("PATH", "") + local_bin = config.user_home / ".local" / "bin" + os.environ["PATH"] = ( + f"{local_bin}:/opt/egg-runtime/sandbox/bin:/usr/local/bin:{current_path}" + ) + + # Python settings + os.environ["PYTHONDONTWRITEBYTECODE"] = "1" + os.environ["PYTHONUNBUFFERED"] = "1" + + # Git editor - use 'true' (no-op) for non-interactive environment + os.environ["GIT_EDITOR"] = "true" + + +def setup_git(config: Config, logger: Logger) -> None: + """Configure git for sandbox identity and credential helper.""" + user_tuple = (config.runtime_uid, config.runtime_gid) + + # Set git identity + run_cmd(["git", "config", "--global", "user.name", "sandbox"], as_user=user_tuple) + run_cmd( + ["git", "config", "--global", "user.email", "sandbox@localhost"], + as_user=user_tuple, + ) + + # Configure credential helper + github_token = os.environ.get("GITHUB_TOKEN") + if github_token: + run_cmd( + [ + "git", + "config", + "--global", + "credential.helper", + "/opt/egg-runtime/sandbox/bin/git-credential-github-token", + ], + as_user=user_tuple, + ) + run_cmd( + ["git", "config", "--global", "credential.useHttpPath", "true"], + as_user=user_tuple, + ) + logger.success("Git credential helper configured for GitHub push") + else: + run_cmd(["git", "config", "--global", "credential.helper", ""], as_user=user_tuple) + + # Never embed tokens in URLs + run_cmd( + ["git", "config", "--global", "advice.pushUpdateRejected", "false"], + as_user=user_tuple, + ) + + logger.success("Git configured to commit as sandbox ") + + +def setup_worktrees(config: Config, logger: Logger) -> bool: + """Validate gateway-managed worktree configuration. + + In the gateway-managed worktree architecture: + - Gateway creates/manages worktrees before container starts + - Container mounts only working directory (no git metadata access) + - All git operations route through gateway API + - No path rewriting needed - gateway controls all paths + + Returns False if setup failed fatally. + """ + if not config.repos_dir.exists(): + logger.warn("Repos workspace not found - check mount configuration") + return True + + # Count repos for logging + repo_count = 0 + for repo_dir in config.repos_dir.iterdir(): + if repo_dir.is_dir(): + repo_count += 1 + + if repo_count > 0: + logger.success(f"Repos mounted: {repo_count} repo(s) (gateway-managed worktrees)") + logger.info(" All git operations route through gateway API") + + return True + + +def setup_sharing(config: Config, logger: Logger) -> None: + """Set up shared directories and symlinks.""" + if not config.sharing_dir.exists(): + logger.warn("Sharing directory not found - check mount configuration") + return + + # Create symlink: ~/tmp -> ~/sharing/tmp + tmp_link = config.user_home / "tmp" + if tmp_link.is_symlink(): + tmp_link.unlink() + elif not tmp_link.exists(): + tmp_link.symlink_to(config.sharing_dir / "tmp") + + # Ensure subdirectories exist + subdirs = ["tmp", "notifications", "context", "tracking", "traces", "logs"] + for subdir in subdirs: + (config.sharing_dir / subdir).mkdir(parents=True, exist_ok=True) + + chown_recursive(config.sharing_dir, config.runtime_uid, config.runtime_gid) + + logger.success("Shared directories configured") + + +def setup_bashrc(config: Config, logger: Logger) -> None: + """Set up .bashrc with useful settings.""" + bashrc = config.user_home / ".bashrc" + + # Append our settings + with open(bashrc, "a") as f: + f.write("\n# Added by egg entrypoint\n") + f.write( + r"export PS1='\[\033[01;32m\]\u@sandboxed\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ '" + + "\n" + ) + + os.chown(bashrc, config.runtime_uid, config.runtime_gid) + logger.success("Shell prompt configured for sandboxed environment") + + +def check_gateway_health(config: Config, logger: Logger) -> bool: + """Wait for gateway readiness before starting. + + In network lockdown mode, the container cannot reach the internet directly. + All traffic must go through the gateway's proxy. This function ensures + the gateway and proxy are ready before the agent starts. + + Returns: + True if gateway is ready, False on timeout + """ + import socket + + import requests + from requests.exceptions import RequestException + + gateway_url = os.environ.get("GATEWAY_URL", "http://egg-gateway:9847") + proxy_url = os.environ.get("HTTPS_PROXY") + + # Detect network mode: private mode has HTTPS_PROXY set, public mode doesn't + is_private_mode = proxy_url is not None + if is_private_mode: + logger.info("Network mode: PRIVATE (lockdown, proxy filtering)") + else: + logger.info("Network mode: PUBLIC (direct internet access)") + + # Log configuration for debugging + logger.info("Gateway configuration:") + logger.info(f" GATEWAY_URL: {gateway_url}") + if is_private_mode: + logger.info(f" HTTPS_PROXY: {proxy_url}") + else: + logger.info(" HTTPS_PROXY: (not set - direct internet access)") + + # Check hostname resolution + gateway_host = "egg-gateway" + try: + resolved_ip = socket.gethostbyname(gateway_host) + logger.info(f" {gateway_host} resolves to: {resolved_ip}") + except socket.gaierror as e: + logger.error(f" DNS resolution failed for {gateway_host}: {e}") + logger.error(" Check --add-host configuration in container startup") + + logger.info("Waiting for gateway readiness...") + + timeout = 60 # seconds + interval = 2 # seconds + elapsed = 0 + + api_health_passed = False + api_health_error = None + + while elapsed < timeout: + # Check Gateway API health endpoint + try: + health_url = f"{gateway_url}/api/v1/health" + health_response = requests.get( + health_url, + timeout=5, + proxies={"http": None, "https": None}, + ) + if health_response.status_code == 200: + try: + health_data = health_response.json() + health_status = health_data.get("status", "unknown") + + if not api_health_passed: + logger.success( + f" Gateway API responding (HTTP {health_response.status_code})" + ) + logger.info(f" Status: {health_status}") + + if health_status == "healthy": + api_health_passed = True + else: + api_health_error = f"Status: {health_status}" + api_health_passed = True # Proceed anyway + except (ValueError, KeyError) as e: + api_health_error = f"Invalid JSON response: {e}" + api_health_passed = True + else: + api_health_error = ( + f"HTTP {health_response.status_code}: {health_response.text[:100]}" + ) + + except RequestException as e: + api_health_error = f"{type(e).__name__}: {e}" + if not config.quiet and elapsed % 10 == 0: + logger.info(f" Gateway API check failed: {api_health_error}") + + # Check proxy connectivity (only in private mode) + if api_health_passed: + if not is_private_mode: + logger.success("Gateway ready! (public mode - direct internet access)") + return True + + # Private mode: verify proxy connectivity to Anthropic API + try: + proxies = {"http": proxy_url, "https": proxy_url} + api_response = requests.get( + "https://api.anthropic.com/", + proxies=proxies, + timeout=10, + verify=True, + ) + if api_response.status_code in (200, 401, 403, 404): + logger.success( + f" Proxy connectivity verified (Anthropic returned " + f"HTTP {api_response.status_code})" + ) + logger.success("Gateway ready!") + return True + except RequestException as e: + if not config.quiet and elapsed % 10 == 0: + logger.info(f" Proxy check failed: {type(e).__name__}: {e}") + + if not config.quiet and elapsed > 0 and elapsed % 10 == 0: + logger.info(f" Still waiting... ({elapsed}/{timeout}s)") + + time.sleep(interval) + elapsed += interval + + logger.error(f"Gateway not ready after {timeout} seconds") + return False + + +# ============================================================================= +# Cleanup +# ============================================================================= + + +def cleanup_on_exit(config: Config, logger: Logger) -> None: + """Cleanup handler for container shutdown.""" + if not config.quiet: + print("") + print("Cleaning up on container exit...") + print("[OK] Cleanup complete") + + +# ============================================================================= +# Main Entry Points +# ============================================================================= + + +def run_interactive(config: Config, logger: Logger) -> None: + """Launch interactive shell session.""" + logger.info("") + logger.info("Starting interactive shell session...") + + # Change to repos directory + if config.repos_dir.exists(): + os.chdir(config.repos_dir) + else: + os.chdir(config.user_home) + + # Build environment + env = os.environ.copy() + env.update( + { + "PYTHONPATH": "/opt/egg-runtime/sandbox:/opt/egg-runtime/shared", + "NO_PROXY": os.environ.get("NO_PROXY", "127.0.0.1"), + } + ) + + # Print timing summary right before launching shell + _startup_timer.print_summary() + + # Launch via gosu + os.execvpe( + "gosu", + [ + "gosu", + f"{config.runtime_uid}:{config.runtime_gid}", + "/bin/bash", + ], + env, + ) + + +def run_exec(config: Config, logger: Logger, args: list[str]) -> None: + """Run a command in exec mode.""" + env = os.environ.copy() + + # Print timing summary before exec + _startup_timer.print_summary() + + os.execvpe( + "gosu", + ["gosu", f"{config.runtime_uid}:{config.runtime_gid}"] + args, + env, + ) + + +# ============================================================================= +# Main +# ============================================================================= + + +def main() -> None: + """Main entry point.""" + config = Config() + logger = Logger(config.quiet) + + # Register cleanup handler + def signal_handler(signum, frame): + cleanup_on_exit(config, logger) + sys.exit(0) + + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGINT, signal_handler) + + # Run setup with timing instrumentation + with _startup_timer.phase("setup_user"): + setup_user(config, logger) + + with _startup_timer.phase("setup_environment"): + setup_environment(config) + + with _startup_timer.phase("setup_git"): + setup_git(config, logger) + + with _startup_timer.phase("setup_worktrees"): + if not setup_worktrees(config, logger): + logger.error("") + logger.error("Container startup aborted due to worktree configuration failure.") + logger.error("Please check your setup and try again.") + sys.exit(1) + + with _startup_timer.phase("setup_sharing"): + setup_sharing(config, logger) + + with _startup_timer.phase("setup_bashrc"): + setup_bashrc(config, logger) + + # Wait for gateway readiness (network lockdown mode) + with _startup_timer.phase("check_gateway"): + if not check_gateway_health(config, logger): + logger.error("") + logger.error("Container startup aborted: gateway not ready.") + logger.error("Ensure the gateway sidecar is running.") + sys.exit(1) + + # Run appropriate mode (timing summary is printed inside each mode) + if len(sys.argv) == 1: + run_interactive(config, logger) + else: + run_exec(config, logger, sys.argv[1:]) + + +if __name__ == "__main__": + main() diff --git a/container/sandbox/scripts/gh b/container/sandbox/scripts/gh new file mode 100644 index 0000000000..d842fda102 --- /dev/null +++ b/container/sandbox/scripts/gh @@ -0,0 +1,503 @@ +#!/bin/bash +# +# gh CLI wrapper for egg sandbox container +# Routes gh commands through the gateway sidecar for policy enforcement. +# +# Security: Requires gateway sidecar - fails closed if gateway unavailable. +# The gateway sidecar holds the GitHub token and enforces policies: +# - PR operations (create, comment, edit, close) go through gateway +# - Merge operations are blocked (human must merge via GitHub UI) +# - Read-only operations are passed through +# + +# Real gh binary - relocated to hidden path to prevent direct access +REAL_GH=/opt/.egg-internal/gh +# Gateway runs as container on egg-network, reachable by container name +GATEWAY_URL="${GATEWAY_URL:-http://egg-gateway:9847}" +# Session token for per-container authentication (required) +EGG_SESSION_TOKEN="${EGG_SESSION_TOKEN:-}" +# Worktree container ID - set by launcher to translate container paths +EGG_WORKTREE_HOST_PATH="${EGG_WORKTREE_HOST_PATH:-}" +CONTAINER_REPOS_DIR="${HOME}/repos" +# Fixed container home path (must match gateway's CONTAINER_HOME) +GATEWAY_CONTAINER_HOME="/home/sandbox" + +# Function to translate container path to gateway-accessible path +translate_path_for_gateway() { + local container_path="$1" + + # If no worktree host path is set, return the path as-is + if [ -z "$EGG_WORKTREE_HOST_PATH" ]; then + echo "$container_path" + return + fi + + # Check if path is under the container's repos directory + if [[ "$container_path" == "$CONTAINER_REPOS_DIR"/* ]]; then + local relative_path="${container_path#$CONTAINER_REPOS_DIR/}" + local container_id="${EGG_WORKTREE_HOST_PATH##*/}" + echo "${GATEWAY_CONTAINER_HOME}/.egg-worktrees/${container_id}/${relative_path}" + else + echo "$container_path" + fi +} + +# Function to show no gateway error message +show_no_gateway_message() { + cat >&2 << 'EOF' + +================================================================================ + GATEWAY SIDECAR NOT AVAILABLE +================================================================================ + +Cannot run gh command: The gateway sidecar is required but not reachable. + +The gateway enforces ownership policies and holds GitHub credentials. +Without it, gh operations are not allowed. + +Please ensure: + 1. Gateway sidecar is running + + 2. The container can reach the gateway: + curl http://egg-gateway:9847/api/v1/health + +================================================================================ + +EOF + return 1 +} + +# Function to show merge blocked message +show_merge_blocked_message() { + cat >&2 << 'EOF' + +================================================================================ + MERGE OPERATIONS NOT SUPPORTED +================================================================================ + +The gateway sidecar does not support merge operations. + +Human must merge PRs via the GitHub web interface. + +This is a safety measure to ensure human review before merging. + +================================================================================ + +EOF + return 1 +} + +# Function to get authentication token for gateway requests +get_gateway_auth() { + if [ -n "$EGG_SESSION_TOKEN" ]; then + echo "$EGG_SESSION_TOKEN" + return 0 + fi + echo "" +} + +# Function to check if gateway is available +check_gateway_available() { + if command -v curl >/dev/null 2>&1; then + curl -s --connect-timeout 2 "${GATEWAY_URL}/api/v1/health" >/dev/null 2>&1 + return $? + fi + return 1 +} + +# Function to extract repo from current directory or args +get_repo() { + local repo="" + + # Check if --repo is specified + for i in "${!ARGS[@]}"; do + if [[ "${ARGS[$i]}" == "--repo" ]] && [[ -n "${ARGS[$((i+1))]}" ]]; then + repo="${ARGS[$((i+1))]}" + break + elif [[ "${ARGS[$i]}" == --repo=* ]]; then + repo="${ARGS[$i]#--repo=}" + break + elif [[ "${ARGS[$i]}" == -R ]] && [[ -n "${ARGS[$((i+1))]}" ]]; then + repo="${ARGS[$((i+1))]}" + break + fi + done + + # If no --repo, try to get from git remote + if [ -z "$repo" ]; then + local url + url=$(git remote get-url origin 2>/dev/null) + if [ -n "$url" ]; then + repo=$(echo "$url" | sed -E 's|.*github\.com[:/]([^/]+)/([^/.]+)(\.git)?$|\1/\2|') + fi + fi + + echo "$repo" +} + +# Function to call gateway API with authentication +call_gateway() { + local endpoint="$1" + local payload="$2" + + # Get session token for authentication + local secret + secret=$(get_gateway_auth) + if [ -z "$secret" ]; then + echo "ERROR: EGG_SESSION_TOKEN not set. Session required for gateway access" >&2 + return 1 + fi + + local response + local http_code + response=$(curl -s -w "\n%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $secret" \ + -d "$payload" \ + "${GATEWAY_URL}${endpoint}" 2>&1) + + # Split response and status code + http_code=$(echo "$response" | tail -n1) + response=$(echo "$response" | sed '$d') + + # Parse response + local success + success=$(echo "$response" | python3 -c "import sys, json; print(json.load(sys.stdin).get('success', False))" 2>/dev/null) + + if [ "$success" = "True" ]; then + # Show stdout from response + local stdout + stdout=$(echo "$response" | python3 -c "import sys, json; d=json.load(sys.stdin).get('data', {}); print(d.get('stdout', '') if d else '')" 2>/dev/null) + [ -n "$stdout" ] && echo "$stdout" + return 0 + else + # Show error message + local message + message=$(echo "$response" | python3 -c "import sys, json; print(json.load(sys.stdin).get('message', 'Unknown error'))" 2>/dev/null) + echo "ERROR: $message" >&2 + + # Show stderr if available + local stderr + stderr=$(echo "$response" | python3 -c "import sys, json; d=json.load(sys.stdin).get('data', {}); print(d.get('stderr', '') if d else '')" 2>/dev/null) + [ -n "$stderr" ] && echo "$stderr" >&2 + + # Special handling for error codes + case "$http_code" in + 401) + echo "Authentication failed - check session token" >&2 + ;; + 429) + echo "Rate limit exceeded - please wait before trying again" >&2 + ;; + esac + + return 1 + fi +} + +# Function to handle PR create - uses proper JSON escaping +handle_pr_create() { + local repo + repo=$(get_repo) + + if [ -z "$repo" ]; then + echo "ERROR: Could not determine repository" >&2 + return 1 + fi + + # Parse args for title, body, base, head + local title="" body="" base="main" head="" + + local i=0 + while [ $i -lt ${#ARGS[@]} ]; do + case "${ARGS[$i]}" in + --title|-t) + ((i++)) + title="${ARGS[$i]}" + ;; + --body|-b) + ((i++)) + body="${ARGS[$i]}" + ;; + --base|-B) + ((i++)) + base="${ARGS[$i]}" + ;; + --head|-H) + ((i++)) + head="${ARGS[$i]}" + ;; + esac + ((i++)) + done + + # Get current branch if head not specified + if [ -z "$head" ]; then + head=$(git branch --show-current 2>/dev/null) + fi + + if [ -z "$title" ]; then + echo "ERROR: Missing --title for PR create" >&2 + return 1 + fi + + # Build JSON payload using Python for proper escaping + local payload + payload=$(python3 -c " +import json +import sys +print(json.dumps({ + 'repo': sys.argv[1], + 'title': sys.argv[2], + 'body': sys.argv[3], + 'base': sys.argv[4], + 'head': sys.argv[5] +})) +" "$repo" "$title" "$body" "$base" "$head") + + call_gateway "/api/v1/gh/pr/create" "$payload" +} + +# Function to handle PR comment - uses proper JSON escaping +handle_pr_comment() { + local repo + repo=$(get_repo) + + if [ -z "$repo" ]; then + echo "ERROR: Could not determine repository" >&2 + return 1 + fi + + # Parse args for PR number and body + local pr_number="" body="" + + local i=0 + while [ $i -lt ${#ARGS[@]} ]; do + case "${ARGS[$i]}" in + --body|-b) + ((i++)) + body="${ARGS[$i]}" + ;; + [0-9]*) + if [ -z "$pr_number" ]; then + pr_number="${ARGS[$i]}" + fi + ;; + esac + ((i++)) + done + + if [ -z "$pr_number" ]; then + echo "ERROR: Missing PR number" >&2 + return 1 + fi + + if [ -z "$body" ]; then + echo "ERROR: Missing --body for PR comment" >&2 + return 1 + fi + + # Build JSON payload using Python for proper escaping + local payload + payload=$(python3 -c " +import json +import sys +print(json.dumps({ + 'repo': sys.argv[1], + 'pr_number': int(sys.argv[2]), + 'body': sys.argv[3] +})) +" "$repo" "$pr_number" "$body") + + call_gateway "/api/v1/gh/pr/comment" "$payload" +} + +# Function to handle PR edit - uses proper JSON escaping +handle_pr_edit() { + local repo + repo=$(get_repo) + + if [ -z "$repo" ]; then + echo "ERROR: Could not determine repository" >&2 + return 1 + fi + + # Parse args for PR number, title, body + local pr_number="" title="" body="" + + local i=0 + while [ $i -lt ${#ARGS[@]} ]; do + case "${ARGS[$i]}" in + --title|-t) + ((i++)) + title="${ARGS[$i]}" + ;; + --body|-b) + ((i++)) + body="${ARGS[$i]}" + ;; + [0-9]*) + if [ -z "$pr_number" ]; then + pr_number="${ARGS[$i]}" + fi + ;; + esac + ((i++)) + done + + if [ -z "$pr_number" ]; then + echo "ERROR: Missing PR number" >&2 + return 1 + fi + + # Build JSON payload using Python for proper escaping + local payload + payload=$(python3 -c " +import json +import sys +data = { + 'repo': sys.argv[1], + 'pr_number': int(sys.argv[2]) +} +if sys.argv[3]: + data['title'] = sys.argv[3] +if sys.argv[4]: + data['body'] = sys.argv[4] +print(json.dumps(data)) +" "$repo" "$pr_number" "$title" "$body") + + call_gateway "/api/v1/gh/pr/edit" "$payload" +} + +# Function to handle PR close +handle_pr_close() { + local repo + repo=$(get_repo) + + if [ -z "$repo" ]; then + echo "ERROR: Could not determine repository" >&2 + return 1 + fi + + # Parse args for PR number + local pr_number="" + + for arg in "${ARGS[@]}"; do + if [[ "$arg" =~ ^[0-9]+$ ]]; then + pr_number="$arg" + break + fi + done + + if [ -z "$pr_number" ]; then + echo "ERROR: Missing PR number" >&2 + return 1 + fi + + # Build JSON payload + local payload + payload=$(python3 -c " +import json +import sys +print(json.dumps({ + 'repo': sys.argv[1], + 'pr_number': int(sys.argv[2]) +})) +" "$repo" "$pr_number") + + call_gateway "/api/v1/gh/pr/close" "$payload" +} + +# Function to execute via gateway passthrough - uses proper JSON escaping +execute_via_gateway() { + local container_cwd + local cwd + container_cwd=$(pwd) + # Translate container path to host path for gateway + cwd=$(translate_path_for_gateway "$container_cwd") + + # Detect repo from container context + local repo + repo=$(get_repo) + + # Build JSON payload using Python for proper escaping + local payload + payload=$(python3 -c " +import json +import sys +args = sys.argv[3:] +data = { + 'args': args, + 'cwd': sys.argv[1] +} +if sys.argv[2]: + data['repo'] = sys.argv[2] +print(json.dumps(data)) +" "$cwd" "$repo" "${ARGS[@]}") + + call_gateway "/api/v1/gh/execute" "$payload" +} + +# Store original args +ARGS=("$@") + +# Parse the main command +main_cmd="" +sub_cmd="" +for arg in "$@"; do + if [[ "$arg" != -* ]]; then + if [ -z "$main_cmd" ]; then + main_cmd="$arg" + elif [ -z "$sub_cmd" ]; then + sub_cmd="$arg" + break + fi + fi +done + +# Check for merge command - always blocked +if [ "$main_cmd" = "pr" ] && [ "$sub_cmd" = "merge" ]; then + show_merge_blocked_message + exit 1 +fi + +# Gateway is REQUIRED - fail closed if not available +if ! check_gateway_available; then + show_no_gateway_message + exit 1 +fi + +# Route commands through gateway +case "$main_cmd" in + pr) + case "$sub_cmd" in + create) + handle_pr_create + exit $? + ;; + comment) + handle_pr_comment + exit $? + ;; + edit) + handle_pr_edit + exit $? + ;; + close) + handle_pr_close + exit $? + ;; + *) + # Other PR commands - pass through via gateway execute + execute_via_gateway + exit $? + ;; + esac + ;; + *) + # All other commands - pass through via gateway execute + execute_via_gateway + exit $? + ;; +esac diff --git a/container/sandbox/scripts/git b/container/sandbox/scripts/git new file mode 100644 index 0000000000..12369d5833 --- /dev/null +++ b/container/sandbox/scripts/git @@ -0,0 +1,786 @@ +#!/bin/bash +# +# Git wrapper for egg sandbox container (Gateway-Managed Worktree Architecture) +# +# In this architecture, the container has NO direct access to git metadata. +# The .git directory is shadowed by a tmpfs mount (appears empty to the container). +# ALL git operations must route through the gateway sidecar. +# +# Security Model: +# - Gateway holds all git metadata and credentials +# - Container can only see working directory files +# - All git operations are validated and executed by gateway +# - Fails closed if gateway unavailable +# + +# Real git binary - relocated to hidden path (used only for gateway fallback testing) +REAL_GIT=/opt/.egg-internal/git +GATEWAY_URL="${GATEWAY_URL:-http://egg-gateway:9847}" +CONTAINER_ID="${CONTAINER_ID:-unknown}" +CONTAINER_REPOS_DIR="${HOME}/repos" +# Session token for per-container authentication (required) +EGG_SESSION_TOKEN="${EGG_SESSION_TOKEN:-}" + +# Function to show gateway unavailable message +show_gateway_unavailable() { + cat >&2 << 'EOF' + +================================================================================ + GATEWAY SIDECAR NOT AVAILABLE +================================================================================ + +Git operations require the gateway sidecar, but it is not reachable. + +In the gateway-managed worktree architecture: +- The container has no direct access to git metadata +- All git operations must route through the gateway +- The gateway holds credentials and enforces policies + +Please ensure the gateway sidecar is running: + curl http://egg-gateway:9847/api/v1/health + +================================================================================ + +EOF + return 1 +} + +# Function to show remote modification blocked message +show_remote_blocked_message() { + cat >&2 << 'EOF' + +================================================================================ + GIT REMOTE MODIFICATION BLOCKED +================================================================================ + +Git remote URLs are managed by the gateway and cannot be modified. + +If you need to interact with a different repository: + - Clone it fresh or work in a different directory + - The gateway manages remote URLs for security + +================================================================================ + +EOF + return 1 +} + +# Function to get authentication token for gateway requests +get_gateway_auth() { + if [ -n "$EGG_SESSION_TOKEN" ]; then + echo "$EGG_SESSION_TOKEN" + return 0 + fi + echo "" +} + +# Function to check if gateway is available +check_gateway_available() { + if command -v curl >/dev/null 2>&1; then + curl -s --connect-timeout 2 "${GATEWAY_URL}/api/v1/health" >/dev/null 2>&1 + return $? + fi + return 1 +} + +# Function to get current repo path +get_repo_path() { + local work_dir="${1:-}" + local path + + if [ -n "$work_dir" ]; then + if [[ "$work_dir" != /* ]]; then + path="$(pwd)/$work_dir" + else + path="$work_dir" + fi + else + path=$(pwd) + fi + + echo "$path" +} + +# Function to execute git command via gateway +# Args: operation work_dir args... +execute_via_gateway() { + local operation="$1" + local work_dir="$2" + shift 2 + local args=("$@") + + local repo_path + repo_path=$(get_repo_path "$work_dir") + + # Get session token for authentication + local secret + secret=$(get_gateway_auth) + if [ -z "$secret" ]; then + echo "ERROR: EGG_SESSION_TOKEN not set. Session required for gateway access" >&2 + echo "The gateway sidecar must be running and properly configured." >&2 + return 1 + fi + + # Build JSON payload using Python for proper escaping + local payload + payload=$(python3 -c " +import json +import sys +args = sys.argv[4:] if len(sys.argv) > 4 else [] +print(json.dumps({ + 'repo_path': sys.argv[1], + 'operation': sys.argv[2], + 'args': args, + 'container_id': sys.argv[3] +})) +" "$repo_path" "$operation" "$CONTAINER_ID" "${args[@]}") + + # Call gateway with authentication + local response + local http_code + response=$(curl -s -w "\n%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $secret" \ + -d "$payload" \ + "${GATEWAY_URL}/api/v1/git/execute" 2>&1) + + # Split response and status code + http_code=$(echo "$response" | tail -n1) + response=$(echo "$response" | sed '$d') + + # Parse response + local success + local message + success=$(echo "$response" | python3 -c "import sys, json; print(json.load(sys.stdin).get('success', False))" 2>/dev/null) + message=$(echo "$response" | python3 -c "import sys, json; print(json.load(sys.stdin).get('message', 'Unknown error'))" 2>/dev/null) + + if [ "$success" = "True" ]; then + # Show stdout/stderr from the operation + local stdout stderr + stdout=$(echo "$response" | python3 -c "import sys, json; print(json.load(sys.stdin).get('data', {}).get('stdout', ''))" 2>/dev/null) + stderr=$(echo "$response" | python3 -c "import sys, json; print(json.load(sys.stdin).get('data', {}).get('stderr', ''))" 2>/dev/null) + [ -n "$stdout" ] && echo "$stdout" + [ -n "$stderr" ] && echo "$stderr" >&2 + return 0 + else + # Show error message + echo "ERROR: $message" >&2 + + # Show stdout and stderr from failed command + local stdout stderr + stdout=$(echo "$response" | python3 -c "import sys, json; d=json.load(sys.stdin).get('data', {}); print(d.get('stdout', '') if d else '')" 2>/dev/null) + stderr=$(echo "$response" | python3 -c "import sys, json; d=json.load(sys.stdin).get('data', {}); print(d.get('stderr', '') if d else '')" 2>/dev/null) + [ -n "$stdout" ] && echo "$stdout" >&2 + [ -n "$stderr" ] && echo "$stderr" >&2 + + # Special handling for error codes + case "$http_code" in + 401) + echo "Authentication failed - check session token" >&2 + ;; + 403) + echo "Operation not allowed by gateway policy" >&2 + ;; + esac + + return 1 + fi +} + +# Function to push via gateway +# Args: remote refspec force [work_dir] +push_via_gateway() { + local remote="$1" + local refspec="$2" + local force="$3" + local work_dir="${4:-}" + + local repo_path + repo_path=$(get_repo_path "$work_dir") + + # Get session token for authentication + local secret + secret=$(get_gateway_auth) + if [ -z "$secret" ]; then + echo "ERROR: EGG_SESSION_TOKEN not set. Session required for gateway access" >&2 + return 1 + fi + + # Build JSON payload + local force_json="False" + [ "$force" = "true" ] && force_json="True" + + local payload + payload=$(python3 -c " +import json +print(json.dumps({ + 'repo_path': '$repo_path', + 'remote': '$remote', + 'refspec': '$refspec', + 'force': $force_json, + 'container_id': '$CONTAINER_ID' +})) +") + + # Call gateway with authentication + local response + local http_code + response=$(curl -s -w "\n%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $secret" \ + -d "$payload" \ + "${GATEWAY_URL}/api/v1/git/push" 2>&1) + + # Split response and status code + http_code=$(echo "$response" | tail -n1) + response=$(echo "$response" | sed '$d') + + # Parse response + local success message + success=$(echo "$response" | python3 -c "import sys, json; print(json.load(sys.stdin).get('success', False))" 2>/dev/null) + message=$(echo "$response" | python3 -c "import sys, json; print(json.load(sys.stdin).get('message', 'Unknown error'))" 2>/dev/null) + + if [ "$success" = "True" ]; then + local stdout stderr + stdout=$(echo "$response" | python3 -c "import sys, json; print(json.load(sys.stdin).get('data', {}).get('stdout', ''))" 2>/dev/null) + stderr=$(echo "$response" | python3 -c "import sys, json; print(json.load(sys.stdin).get('data', {}).get('stderr', ''))" 2>/dev/null) + [ -n "$stdout" ] && echo "$stdout" + [ -n "$stderr" ] && echo "$stderr" >&2 + return 0 + else + echo "ERROR: $message" >&2 + local details + details=$(echo "$response" | python3 -c "import sys, json; d=json.load(sys.stdin).get('data', {}); print(d.get('stderr', '') if d else '')" 2>/dev/null) + [ -n "$details" ] && echo "$details" >&2 + + case "$http_code" in + 401) echo "Authentication failed - check session token" >&2 ;; + 403) + cat >&2 << 'EOF' + +================================================================================ + PUSH BLOCKED BY POLICY +================================================================================ + +The gateway sidecar blocked this push operation. + +To push, ensure you are pushing to a branch you own. + +================================================================================ + +EOF + ;; + 429) echo "Rate limit exceeded - please wait before trying again" >&2 ;; + esac + + return 1 + fi +} + +# Function to fetch via gateway +# Args: operation remote work_dir extra_args... +fetch_via_gateway() { + local operation="$1" + local remote="$2" + local work_dir="${3:-}" + shift 3 + local extra_args=("$@") + + local repo_path + repo_path=$(get_repo_path "$work_dir") + + # Get session token for authentication + local secret + secret=$(get_gateway_auth) + if [ -z "$secret" ]; then + echo "ERROR: EGG_SESSION_TOKEN not set. Session required for gateway access" >&2 + return 1 + fi + + # Build JSON payload + local payload + payload=$(python3 -c " +import json +import sys +import os +args = sys.argv[4:] if len(sys.argv) > 4 else [] +print(json.dumps({ + 'repo_path': sys.argv[1], + 'remote': sys.argv[2], + 'operation': sys.argv[3], + 'args': args, + 'container_id': os.environ.get('CONTAINER_ID', '') +})) +" "$repo_path" "$remote" "$operation" "${extra_args[@]}") + + # Call gateway with authentication + local response http_code + response=$(curl -s -w "\n%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $secret" \ + -d "$payload" \ + "${GATEWAY_URL}/api/v1/git/fetch" 2>&1) + + http_code=$(echo "$response" | tail -n1) + response=$(echo "$response" | sed '$d') + + local success message + success=$(echo "$response" | python3 -c "import sys, json; print(json.load(sys.stdin).get('success', False))" 2>/dev/null) + message=$(echo "$response" | python3 -c "import sys, json; print(json.load(sys.stdin).get('message', 'Unknown error'))" 2>/dev/null) + + if [ "$success" = "True" ]; then + local stdout stderr + stdout=$(echo "$response" | python3 -c "import sys, json; print(json.load(sys.stdin).get('data', {}).get('stdout', ''))" 2>/dev/null) + stderr=$(echo "$response" | python3 -c "import sys, json; print(json.load(sys.stdin).get('data', {}).get('stderr', ''))" 2>/dev/null) + [ -n "$stdout" ] && echo "$stdout" + [ -n "$stderr" ] && echo "$stderr" >&2 + return 0 + else + echo "ERROR: $message" >&2 + local details + details=$(echo "$response" | python3 -c "import sys, json; d=json.load(sys.stdin).get('data', {}); print(d.get('stderr', '') if d else '')" 2>/dev/null) + [ -n "$details" ] && echo "$details" >&2 + return 1 + fi +} + +# ============================================================================= +# Main Command Parsing +# ============================================================================= + +# Early check for commands that don't need gateway connectivity +for arg in "$@"; do + case "$arg" in + config) + # Config reads and global operations don't need gateway + is_write=false + for inner_arg in "$@"; do + case "$inner_arg" in + --set|--unset|--unset-all|--add|--replace-all|--rename-section|--remove-section) + is_write=true + break + ;; + esac + done + if [ "$is_write" = "false" ]; then + exec "$REAL_GIT" "$@" + fi + break + ;; + rev-parse) + # rev-parse for repo discovery + for inner_arg in "$@"; do + case "$inner_arg" in + --git-dir|--git-common-dir|--show-toplevel|--is-inside-work-tree|--is-inside-git-dir|--is-bare-repository|--show-prefix|--show-cdup) + exec "$REAL_GIT" "$@" + ;; + esac + done + break + ;; + --help|--version|--exec-path|--html-path|--man-path|--info-path) + exec "$REAL_GIT" "$@" + ;; + esac +done + +# Check gateway availability - required for repo operations +if ! check_gateway_available; then + show_gateway_unavailable + exit 1 +fi + +# Parse global git options and extract the actual command +cmd="" +git_work_dir="" +args_after_globals=() +skip_next=false +capture_work_dir=false +found_command=false + +for arg in "$@"; do + if $capture_work_dir; then + git_work_dir="$arg" + capture_work_dir=false + continue + fi + + if $skip_next; then + skip_next=false + continue + fi + + if ! $found_command; then + case "$arg" in + -C) + capture_work_dir=true + ;; + -c|--git-dir|--work-tree|--namespace|--super-prefix|--config-env) + skip_next=true + ;; + -C=*|--git-dir=*|--work-tree=*|-c=*|--namespace=*|--super-prefix=*|--config-env=*) + if [[ "$arg" == -C=* ]]; then + git_work_dir="${arg#-C=}" + fi + ;; + --version|--help|--html-path|--man-path|--info-path|-p|--paginate|-P|--no-pager|--no-replace-objects|--bare|--literal-pathspecs|--glob-pathspecs|--noglob-pathspecs|--icase-pathspecs|--no-optional-locks|--list-cmds=*) + ;; + -*) + args_after_globals+=("$arg") + ;; + *) + cmd="$arg" + found_command=true + args_after_globals+=("$arg") + ;; + esac + else + args_after_globals+=("$arg") + fi +done + +# Route commands based on type +case "$cmd" in + # ========================================================================== + # Network operations - use dedicated endpoints + # ========================================================================== + push) + remote="origin" + refspec="" + force="false" + skip_next=false + remote_seen=false + + for arg in "${args_after_globals[@]}"; do + if $skip_next; then + skip_next=false + continue + fi + + case "$arg" in + push) continue ;; + -f|--force|--force-with-lease*) force="true" ;; + -u|--set-upstream) ;; + --repo|--receive-pack|--exec|-o|--push-option) skip_next=true ;; + -*) ;; + *) + if [ "$remote_seen" = "false" ]; then + remote="$arg" + remote_seen=true + elif [ -z "$refspec" ]; then + refspec="$arg" + fi + ;; + esac + done + + push_via_gateway "$remote" "$refspec" "$force" "$git_work_dir" + exit $? + ;; + + fetch) + remote="origin" + extra_args=() + skip_next=false + remote_seen=false + + for arg in "${args_after_globals[@]}"; do + if $skip_next; then + extra_args+=("$arg") + skip_next=false + continue + fi + + case "$arg" in + fetch) continue ;; + --depth|--deepen|--shallow-since|--shallow-exclude|-j|--jobs|--recurse-submodules-default|--submodule-prefix|--upload-pack|-o|--server-option|--negotiation-tip|--filter|--refmap) + extra_args+=("$arg") + skip_next=true + ;; + -*) + extra_args+=("$arg") + ;; + *) + if [ "$remote_seen" = "false" ]; then + remote="$arg" + remote_seen=true + else + extra_args+=("$arg") + fi + ;; + esac + done + + fetch_via_gateway "fetch" "$remote" "$git_work_dir" "${extra_args[@]}" + exit $? + ;; + + ls-remote) + remote="origin" + extra_args=() + skip_next=false + remote_seen=false + + for arg in "${args_after_globals[@]}"; do + if $skip_next; then + extra_args+=("$arg") + skip_next=false + continue + fi + + case "$arg" in + ls-remote) continue ;; + --upload-pack|-o|--server-option|--sort) + extra_args+=("$arg") + skip_next=true + ;; + -*) + extra_args+=("$arg") + ;; + *) + if [ "$remote_seen" = "false" ]; then + remote="$arg" + remote_seen=true + else + extra_args+=("$arg") + fi + ;; + esac + done + + fetch_via_gateway "ls-remote" "$remote" "$git_work_dir" "${extra_args[@]}" + exit $? + ;; + + pull) + # git pull = git fetch + git merge + remote="origin" + branch="" + fetch_args=() + merge_args=() + skip_next=false + prev_flag="" + remote_seen=false + + for arg in "${args_after_globals[@]}"; do + if $skip_next; then + case "$prev_flag" in + --depth|--deepen|--shallow-since|--shallow-exclude|-j|--jobs) + fetch_args+=("$prev_flag" "$arg") + ;; + -s|--strategy|-X|--strategy-option) + merge_args+=("$prev_flag" "$arg") + ;; + *) + merge_args+=("$prev_flag" "$arg") + ;; + esac + skip_next=false + continue + fi + + case "$arg" in + pull) continue ;; + --depth|--deepen|--shallow-since|--shallow-exclude|-j|--jobs|-s|--strategy|-X|--strategy-option) + prev_flag="$arg" + skip_next=true + ;; + --rebase|--no-rebase|--ff|--no-ff|--ff-only|--squash|--no-squash|--commit|--no-commit|--edit|--no-edit|--autostash|--no-autostash) + merge_args+=("$arg") + ;; + --all|--tags|--prune|--no-tags|-f|--force|-k|--keep|-t|--update-head-ok|-q|--quiet|-v|--verbose|--progress|--no-progress) + fetch_args+=("$arg") + ;; + -*) + merge_args+=("$arg") + ;; + *) + if [ "$remote_seen" = "false" ]; then + remote="$arg" + remote_seen=true + elif [ -z "$branch" ]; then + branch="$arg" + fi + ;; + esac + done + + # Step 1: Fetch via gateway + if [ -n "$branch" ]; then + if ! fetch_via_gateway "fetch" "$remote" "$git_work_dir" "${fetch_args[@]}" "$branch"; then + echo "ERROR: Fetch failed, aborting pull" >&2 + exit 1 + fi + else + if ! fetch_via_gateway "fetch" "$remote" "$git_work_dir" "${fetch_args[@]}"; then + echo "ERROR: Fetch failed, aborting pull" >&2 + exit 1 + fi + fi + + # Step 2: Merge via gateway + merge_target="" + if [ -n "$branch" ]; then + merge_target="$remote/$branch" + else + current_branch=$(execute_via_gateway "branch" "$git_work_dir" "--show-current" 2>/dev/null | tr -d '\n') + if [ -n "$current_branch" ]; then + upstream=$(execute_via_gateway "rev-parse" "$git_work_dir" "--abbrev-ref" "@{upstream}" 2>/dev/null | tr -d '\n') + if [ -n "$upstream" ]; then + merge_target="$upstream" + else + merge_target="$remote/$current_branch" + fi + else + merge_target="FETCH_HEAD" + fi + fi + + execute_via_gateway "merge" "$git_work_dir" "${merge_args[@]}" "$merge_target" + exit $? + ;; + + # ========================================================================== + # Remote modification - blocked + # ========================================================================== + remote) + subcmd="" + for arg in "${args_after_globals[@]}"; do + if [ "$arg" = "remote" ]; then continue; fi + if [[ "$arg" != -* ]]; then + subcmd="$arg" + break + fi + done + + case "$subcmd" in + update) + # Convert to fetch --all via gateway + update_args=() + for arg in "${args_after_globals[@]}"; do + case "$arg" in + remote|update) continue ;; + --prune|-p) update_args+=("--prune") ;; + esac + done + fetch_via_gateway "fetch" "origin" "$git_work_dir" "--all" "${update_args[@]}" + exit $? + ;; + set-url|add|remove|rm|rename|set-head|set-branches|prune) + show_remote_blocked_message + exit 1 + ;; + *) + # Read-only remote commands go through gateway + extra_args=() + for arg in "${args_after_globals[@]}"; do + [ "$arg" = "remote" ] && continue + extra_args+=("$arg") + done + execute_via_gateway "remote" "$git_work_dir" "${extra_args[@]}" + exit $? + ;; + esac + ;; + + # ========================================================================== + # Local operations - route through gateway execute endpoint + # ========================================================================== + config) + extra_args=() + is_global=false + for arg in "${args_after_globals[@]}"; do + [ "$arg" = "$cmd" ] && continue + if [ "$arg" = "--global" ]; then + is_global=true + fi + extra_args+=("$arg") + done + + if $is_global; then + exec "$REAL_GIT" config "${extra_args[@]}" + else + execute_via_gateway "$cmd" "$git_work_dir" "${extra_args[@]}" + exit $? + fi + ;; + + status|log|diff|show|branch|rev-parse) + extra_args=() + for arg in "${args_after_globals[@]}"; do + [ "$arg" = "$cmd" ] && continue + extra_args+=("$arg") + done + execute_via_gateway "$cmd" "$git_work_dir" "${extra_args[@]}" + exit $? + ;; + + add|commit|checkout|switch|reset|restore|stash|merge|rebase|cherry-pick|tag|clean) + extra_args=() + for arg in "${args_after_globals[@]}"; do + [ "$arg" = "$cmd" ] && continue + extra_args+=("$arg") + done + execute_via_gateway "$cmd" "$git_work_dir" "${extra_args[@]}" + exit $? + ;; + + # ========================================================================== + # Clone - special handling (creates new repo) + # ========================================================================== + clone) + echo "ERROR: git clone is not supported in the container." >&2 + echo "Repositories must be configured on the host before container launch." >&2 + exit 1 + ;; + + # ========================================================================== + # Init - blocked (container doesn't manage repos) + # ========================================================================== + init) + echo "ERROR: git init is not supported in the container." >&2 + echo "Repositories must be configured on the host." >&2 + exit 1 + ;; + + # ========================================================================== + # Help/version - can show locally + # ========================================================================== + --help|help|--version|version) + echo "Git wrapper for egg sandbox container (Gateway-Managed Architecture)" + echo "All git operations route through the gateway sidecar." + echo "" + echo "Supported operations:" + echo " Network: push, fetch, pull, ls-remote" + echo " Read: status, log, diff, show, branch, rev-parse, config" + echo " Write: add, commit, checkout, switch, reset, restore, stash," + echo " merge, rebase, cherry-pick, tag, clean" + echo "" + echo "Blocked operations: clone, init, remote set-url/add/remove" + echo "" + echo "Gateway URL: $GATEWAY_URL" + exit 0 + ;; + + # ========================================================================== + # Unknown commands - try to route through gateway + # ========================================================================== + *) + if [ -z "$cmd" ]; then + echo "Usage: git [args...]" + echo "Run 'git --help' for more information." + exit 1 + fi + + extra_args=() + for arg in "${args_after_globals[@]}"; do + [ "$arg" = "$cmd" ] && continue + extra_args+=("$arg") + done + execute_via_gateway "$cmd" "$git_work_dir" "${extra_args[@]}" + exit $? + ;; +esac diff --git a/uv.lock b/uv.lock index 0504e621a7..8497c4afcd 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,19 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + [[package]] name = "bandit" version = "1.9.3" @@ -345,6 +358,7 @@ source = { editable = "." } dependencies = [ { name = "cryptography" }, { name = "flask" }, + { name = "httpx" }, { name = "pyjwt" }, { name = "pyyaml" }, { name = "requests" }, @@ -370,6 +384,7 @@ requires-dist = [ { name = "bandit", marker = "extra == 'dev'", specifier = ">=1.7.0" }, { name = "cryptography", specifier = ">=41.0.0,<44.0.0" }, { name = "flask", specifier = ">=3.0.0,<4.0.0" }, + { name = "httpx", specifier = ">=0.27.0,<1.0.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.6.0" }, { name = "pyjwt", specifier = ">=2.8.0,<3.0.0" }, @@ -412,6 +427,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "identify" version = "2.6.16"