From fad78d760210a4e5c56b7644da28de23e21a8006 Mon Sep 17 00:00:00 2001 From: eric is cool Date: Fri, 6 Mar 2026 18:49:15 -0500 Subject: [PATCH 01/12] fix: ttyd auth default-on, gh credential helper, Dockerfile HEALTHCHECK (#33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: ttyd auth default-on, gh credential helper, Dockerfile HEALTHCHECK - (#4) Flip ttyd auth logic: credentials now required by default; container exits with a clear error if neither creds nor TTYD_NO_AUTH=true are set. Added TTYD_NO_AUTH=true as the explicit opt-out for unauthenticated access. - (#14) Add `gh auth setup-git` to entrypoint so git push/fetch works seamlessly via GH_TOKEN without embedding tokens in remote URLs. - (#10) Add HEALTHCHECK to Dockerfile — curls ttyd every 30s with a 10s startup grace period and 3 retries before marking the container unhealthy. - Updated .env.example to document new ttyd auth defaults and TTYD_NO_AUTH. Closes #4, #10, #14 Co-Authored-By: Claude Sonnet 4.6 * fix: address Copilot review feedback on PR #33 - gh auth setup-git is now best-effort: skips gracefully if GH_TOKEN is absent or gh is unauthenticated, so the web terminal can still start without GitHub credentials. - Fix TTYD auth precedence: credentials now take priority over TTYD_NO_AUTH=true; setting both is treated as a hard config error rather than silently disabling auth. - HEALTHCHECK now respects TTYD_BASE_PATH so it doesn't produce false unhealthy status when a non-root base path is configured. - HEALTHCHECK validates TTYD_PORT is numeric before use to prevent shell injection via environment variable. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: itscooleric Co-authored-by: Claude Sonnet 4.6 --- .env.example | 7 +++++-- Dockerfile | 5 +++++ entrypoint.sh | 23 ++++++++++++++++++++--- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index edf7fe8..b0c7c7c 100644 --- a/.env.example +++ b/.env.example @@ -32,10 +32,13 @@ GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx TTYD_PORT=7681 TTYD_BASE_PATH=/ -# Web terminal authentication (optional) -# Set both to enable ttyd basic auth +# Web terminal authentication (required by default) +# Set TTYD_USER and TTYD_PASS to enable basic auth (recommended) # TTYD_USER=admin # TTYD_PASS=changeme +# +# To explicitly allow unauthenticated access (not recommended): +# TTYD_NO_AUTH=true # Caddy Docker Proxy (optional - for Bernard/Forge deployment) # To enable: copy docker-compose.override.yml.example to docker-compose.override.yml diff --git a/Dockerfile b/Dockerfile index 6212170..1df0ee7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -83,5 +83,10 @@ RUN curl -fsSL https://gh.io/copilot-install | bash WORKDIR /workspace +# Health check — confirms the web terminal is accepting connections. +# Port is validated as numeric to prevent injection; base path is respected. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD sh -c 'PORT="${TTYD_PORT:-7681}"; case "$PORT" in (""|*[!0-9]*) PORT=7681 ;; esac; curl -f "http://localhost:${PORT}${TTYD_BASE_PATH:-/}" || exit 1' + # Default to bash shell (can be overridden by command in docker-compose) CMD ["/bin/bash"] diff --git a/entrypoint.sh b/entrypoint.sh index 34694a3..2e68fd4 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -25,12 +25,29 @@ TTYD_ARGS=( "--base-path" "${TTYD_BASE_PATH:-/}" ) -# Add basic auth if credentials are set -if [[ -n "${TTYD_USER}" && -n "${TTYD_PASS}" ]]; then +# Wire gh as git credential helper so git push/fetch work without token embedding. +# Best-effort: skip gracefully if GH_TOKEN is not set or gh is not authenticated. +if gh auth status >/dev/null 2>&1; then + if ! gh auth setup-git; then + echo "ttyd: WARNING - failed to configure gh as git credential helper; continuing without it" + fi +else + echo "ttyd: WARNING - GitHub CLI not authenticated (GH_TOKEN not set?); skipping gh auth setup-git" +fi + +# Auth precedence: credentials take priority; TTYD_NO_AUTH=true is the explicit opt-out. +# Setting both TTYD_NO_AUTH=true and credentials is a configuration error. +if [[ "${TTYD_NO_AUTH:-}" == "true" && -n "${TTYD_USER:-}" && -n "${TTYD_PASS:-}" ]]; then + echo "ttyd: ERROR - conflicting auth config: TTYD_NO_AUTH=true set while TTYD_USER/TTYD_PASS are also configured. Unset TTYD_NO_AUTH or remove credentials." + exit 1 +elif [[ -n "${TTYD_USER:-}" && -n "${TTYD_PASS:-}" ]]; then TTYD_ARGS+=("--credential" "${TTYD_USER}:${TTYD_PASS}") echo "ttyd: basic auth enabled for user '${TTYD_USER}'" +elif [[ "${TTYD_NO_AUTH:-}" == "true" ]]; then + echo "ttyd: WARNING - unauthenticated access enabled (TTYD_NO_AUTH=true)" else - echo "ttyd: WARNING - no authentication configured (set TTYD_USER and TTYD_PASS in .env)" + echo "ttyd: ERROR - no credentials configured. Set TTYD_USER and TTYD_PASS in .env, or set TTYD_NO_AUTH=true to explicitly disable auth." + exit 1 fi exec ttyd "${TTYD_ARGS[@]}" tmux new-session -A -s main From 0e718019cae9341a3e7756340ddb76945c8ea488 Mon Sep 17 00:00:00 2001 From: eric is cool Date: Fri, 6 Mar 2026 18:51:00 -0500 Subject: [PATCH 02/12] fix: gosu privilege drop for firewall + compose hardening (#34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: gosu privilege drop for firewall, compose hardening Fixes the silent firewall no-op (#30) caused by iptables requiring geteuid()==0 even with CAP_NET_ADMIN when the container runs as the non-root clide user. - (#30) Install gosu; set Dockerfile back to USER root so entrypoints start as root. All entrypoints (firewall.sh, claude-entrypoint.sh, entrypoint.sh) now apply any root-only setup first (iptables, config writes) then exec gosu clide to drop privileges before the workload. The firewall is now always effective when NET_ADMIN is present. - (#30) Hardcode HOME_DIR=/home/clide in claude-entrypoint.sh to avoid resolving to /root when running as root; chown .claude.json to clide after writing. - (#30) gosu clide gh auth setup-git in entrypoint.sh so gh config writes to clide's home, not root's. - (#6) Add cap_drop: ALL, security_opt: no-new-privileges:true, and resource guardrails (mem_limit: 4g, cpus: 4.0, pids_limit: 512) to the docker-compose.yml base anchor. cap_add: NET_ADMIN is preserved for the firewall. Closes #30, #6 Co-Authored-By: Claude Sonnet 4.6 * fix: suppress DL3002 for intentional USER root before gosu drop The last USER is root by design — entrypoints apply iptables rules as root then exec gosu clide to drop privileges before the workload starts. Suppress hadolint DL3002 with a pragma rather than papering over the intentional pattern. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: itscooleric Co-authored-by: Claude Sonnet 4.6 --- Dockerfile | 6 ++++++ claude-entrypoint.sh | 12 +++++++++--- docker-compose.yml | 13 ++++++++++++- entrypoint.sh | 9 +++++---- firewall.sh | 11 ++++++----- 5 files changed, 38 insertions(+), 13 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1df0ee7..4a503fa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ git \ gnupg \ + gosu \ iptables \ tmux \ && rm -rf /var/lib/apt/lists/* @@ -81,6 +82,11 @@ RUN curl -fsSL https://gh.io/copilot-install | bash # GH_TOKEN — GitHub fine-grained PAT with "Copilot Requests" permission # ANTHROPIC_API_KEY — Anthropic API key for Claude Code +# Switch back to root so entrypoints start as root; privilege drop to clide +# is handled by gosu inside each entrypoint script after firewall setup. +# hadolint ignore=DL3002 +USER root + WORKDIR /workspace # Health check — confirms the web terminal is accepting connections. diff --git a/claude-entrypoint.sh b/claude-entrypoint.sh index b27d308..1607f89 100644 --- a/claude-entrypoint.sh +++ b/claude-entrypoint.sh @@ -1,8 +1,10 @@ #!/bin/bash set -euo pipefail -HOME_DIR="${HOME:-/home/clide}" +# Hardcode clide's home — avoids picking up /root when entrypoint runs as root. +HOME_DIR="/home/clide" mkdir -p "$HOME_DIR" +chown clide:clide "$HOME_DIR" export HOME="$HOME_DIR" @@ -52,6 +54,9 @@ if (!oauthToken && apiKey) { } fs.writeFileSync(configPath, JSON.stringify(next, null, 2)); +// Ensure config is owned by clide even when script runs as root +const { execSync } = require('child_process'); +try { execSync(`chown clide:clide ${configPath}`); } catch (_) {} // Report which auth method is active if (oauthToken) { @@ -75,8 +80,9 @@ fi # Opt-in tmux wrapping for shell service (set CLIDE_TMUX=1 in .env) # Web terminal always uses tmux via entrypoint.sh; this covers make shell / ./clide shell. +# Drop privileges to clide via gosu before exec so the workload never runs as root. if [[ -n "${CLIDE_TMUX:-}" ]]; then - exec tmux new-session -A -s main "${@:-claude}" + exec gosu clide tmux new-session -A -s main "${@:-claude}" fi -exec "${@:-claude}" +exec gosu clide "${@:-claude}" diff --git a/docker-compose.yml b/docker-compose.yml index 0fbb584..1556a5a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,8 +6,19 @@ x-base: &base CLAUDE_CODE_SIMPLE: ${CLAUDE_CODE_SIMPLE:-1} CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN:-} CLIDE_TMUX: ${CLIDE_TMUX:-} + # Drop all capabilities then re-add only what the firewall needs. + # NET_ADMIN is required for iptables egress rules; set CLIDE_FIREWALL=0 to disable. + cap_drop: + - ALL cap_add: - - NET_ADMIN # required for the iptables egress firewall (CLIDE_FIREWALL=0 to disable) + - NET_ADMIN + # Prevent processes from gaining new privileges via setuid/setgid binaries. + security_opt: + - no-new-privileges:true + # Resource guardrails — prevent runaway AI workloads from starving the host. + mem_limit: 4g + cpus: '4.0' + pids_limit: 512 volumes: - ${PROJECT_DIR:-..}:/workspace stdin_open: true diff --git a/entrypoint.sh b/entrypoint.sh index 2e68fd4..9150d2b 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -26,9 +26,9 @@ TTYD_ARGS=( ) # Wire gh as git credential helper so git push/fetch work without token embedding. -# Best-effort: skip gracefully if GH_TOKEN is not set or gh is not authenticated. -if gh auth status >/dev/null 2>&1; then - if ! gh auth setup-git; then +# Run as clide (user-scoped config); best-effort so missing GH_TOKEN doesn't block startup. +if gosu clide gh auth status >/dev/null 2>&1; then + if ! gosu clide gh auth setup-git; then echo "ttyd: WARNING - failed to configure gh as git credential helper; continuing without it" fi else @@ -50,4 +50,5 @@ else exit 1 fi -exec ttyd "${TTYD_ARGS[@]}" tmux new-session -A -s main +# Drop privileges to clide before starting ttyd so the web terminal never runs as root +exec gosu clide ttyd "${TTYD_ARGS[@]}" tmux new-session -A -s main diff --git a/firewall.sh b/firewall.sh index 60c271c..8baac79 100644 --- a/firewall.sh +++ b/firewall.sh @@ -8,21 +8,21 @@ # ── Opt-out ─────────────────────────────────────────────────────────────────── if [[ "${CLIDE_FIREWALL:-1}" == "0" ]]; then echo "firewall: disabled (CLIDE_FIREWALL=0)" - if [[ $# -gt 0 ]]; then exec "$@"; fi + if [[ $# -gt 0 ]]; then exec gosu clide "$@"; fi exit 0 fi # ── Sanity checks ───────────────────────────────────────────────────────────── if ! command -v iptables >/dev/null 2>&1; then echo "firewall: WARNING - iptables not found, skipping egress filter" - if [[ $# -gt 0 ]]; then exec "$@"; fi + if [[ $# -gt 0 ]]; then exec gosu clide "$@"; fi exit 0 fi if ! iptables -L OUTPUT -n >/dev/null 2>&1; then echo "firewall: WARNING - cannot access iptables (missing NET_ADMIN capability)" echo "firewall: add 'cap_add: [NET_ADMIN]' to your docker-compose.yml to enable the firewall" - if [[ $# -gt 0 ]]; then exec "$@"; fi + if [[ $# -gt 0 ]]; then exec gosu clide "$@"; fi exit 0 fi @@ -101,7 +101,8 @@ _ip6 -A OUTPUT -j REJECT echo "firewall: egress allowlist active — all other outbound traffic rejected" -# ── If used as entrypoint, exec the supplied command ───────────────────────── +# ── If used as entrypoint, drop to unprivileged user and exec the command ──── +# iptables rules are now in place; gosu drops root before the workload starts. if [[ $# -gt 0 ]]; then - exec "$@" + exec gosu clide "$@" fi From 056fe4a6ec72fb58b118e678dfce65c141fcebf4 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 6 Mar 2026 20:15:42 -0500 Subject: [PATCH 03/12] Add Codex CLI (OpenAI) support (#35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * feat: add Codex CLI (OpenAI) support Co-authored-by: itscooleric <25490177+itscooleric@users.noreply.github.com> * fix: forward args to codex wrapper command (#36) * fix: suppress DL3059 for consecutive npm RUN instructions hadolint flags consecutive RUN instructions; suppressed with a pragma since consolidating the npm installs would hurt layer caching. Co-Authored-By: Claude Sonnet 4.6 * ci: trigger checks after rebase * chore: run PR checks on PRs targeting dev (#38) * fix: ttyd auth default-on, gh credential helper, Dockerfile HEALTHCHECK (#33) * fix: ttyd auth default-on, gh credential helper, Dockerfile HEALTHCHECK - (#4) Flip ttyd auth logic: credentials now required by default; container exits with a clear error if neither creds nor TTYD_NO_AUTH=true are set. Added TTYD_NO_AUTH=true as the explicit opt-out for unauthenticated access. - (#14) Add `gh auth setup-git` to entrypoint so git push/fetch works seamlessly via GH_TOKEN without embedding tokens in remote URLs. - (#10) Add HEALTHCHECK to Dockerfile — curls ttyd every 30s with a 10s startup grace period and 3 retries before marking the container unhealthy. - Updated .env.example to document new ttyd auth defaults and TTYD_NO_AUTH. Closes #4, #10, #14 Co-Authored-By: Claude Sonnet 4.6 * fix: address Copilot review feedback on PR #33 - gh auth setup-git is now best-effort: skips gracefully if GH_TOKEN is absent or gh is unauthenticated, so the web terminal can still start without GitHub credentials. - Fix TTYD auth precedence: credentials now take priority over TTYD_NO_AUTH=true; setting both is treated as a hard config error rather than silently disabling auth. - HEALTHCHECK now respects TTYD_BASE_PATH so it doesn't produce false unhealthy status when a non-root base path is configured. - HEALTHCHECK validates TTYD_PORT is numeric before use to prevent shell injection via environment variable. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: itscooleric Co-authored-by: Claude Sonnet 4.6 * fix: gosu privilege drop for firewall + compose hardening (#34) * fix: gosu privilege drop for firewall, compose hardening Fixes the silent firewall no-op (#30) caused by iptables requiring geteuid()==0 even with CAP_NET_ADMIN when the container runs as the non-root clide user. - (#30) Install gosu; set Dockerfile back to USER root so entrypoints start as root. All entrypoints (firewall.sh, claude-entrypoint.sh, entrypoint.sh) now apply any root-only setup first (iptables, config writes) then exec gosu clide to drop privileges before the workload. The firewall is now always effective when NET_ADMIN is present. - (#30) Hardcode HOME_DIR=/home/clide in claude-entrypoint.sh to avoid resolving to /root when running as root; chown .claude.json to clide after writing. - (#30) gosu clide gh auth setup-git in entrypoint.sh so gh config writes to clide's home, not root's. - (#6) Add cap_drop: ALL, security_opt: no-new-privileges:true, and resource guardrails (mem_limit: 4g, cpus: 4.0, pids_limit: 512) to the docker-compose.yml base anchor. cap_add: NET_ADMIN is preserved for the firewall. Closes #30, #6 Co-Authored-By: Claude Sonnet 4.6 * fix: suppress DL3002 for intentional USER root before gosu drop The last USER is root by design — entrypoints apply iptables rules as root then exec gosu clide to drop privileges before the workload starts. Suppress hadolint DL3002 with a pragma rather than papering over the intentional pattern. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: itscooleric Co-authored-by: Claude Sonnet 4.6 * chore: run PR checks on PRs targeting dev branch Adds dev to the pull_request branch filter so CI runs on feature branches merging into dev, not just main. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: itscooleric Co-authored-by: Claude Sonnet 4.6 * ci: re-trigger checks after workflow update --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: itscooleric <25490177+itscooleric@users.noreply.github.com> Co-authored-by: eric is cool Co-authored-by: itscooleric Co-authored-by: Claude Sonnet 4.6 --- .env.example | 11 +++++++++++ Dockerfile | 4 ++++ Makefile | 5 ++++- README.md | 25 +++++++++++++++++++++++++ clide | 10 +++++++++- docker-compose.yml | 7 +++++++ firewall.sh | 2 ++ 7 files changed, 62 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index b0c7c7c..9ac4809 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,17 @@ GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Do NOT set both — OAuth token takes priority and API key will be ignored. # ───────────────────────────────────────────────────────────────────────── +# ── Codex CLI (OpenAI) authentication ──────────────────────────────────── +# +# Option 1: API key (recommended — bypasses interactive auth entirely) +# Create at: https://platform.openai.com/api-keys +# OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +# +# Option 2: Device code flow (browser-free OAuth, no API key required) +# Run inside the container: codex auth login --auth device +# Follow the printed URL + code on your host browser. +# ───────────────────────────────────────────────────────────────────────── + # Project directory to mount (default: .. i.e. parent directory) # PROJECT_DIR=/path/to/your/repo diff --git a/Dockerfile b/Dockerfile index 4a503fa..01a785f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,6 +43,10 @@ RUN ARCH="$(uname -m)" \ # hadolint ignore=DL3016 RUN npm install -g @anthropic-ai/claude-code +# Install Codex CLI (unpinned — tracks new features intentionally) +# hadolint ignore=DL3016,DL3059 +RUN npm install -g @openai/codex + # Install Python 3 + dev tooling into an isolated venv # - python3-venv provides the venv module (not always bundled in minimal images) # - /opt/pyenv is world-readable so the unprivileged clide user can run tools diff --git a/Makefile b/Makefile index 1aa7d91..382ab65 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build rebuild web web-stop shell copilot gh claude help +.PHONY: build rebuild web web-stop shell copilot gh claude codex help help: ## Show this help @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' @@ -31,6 +31,9 @@ gh: ## Run GitHub CLI claude: ## Run Claude Code CLI CLAUDE_CODE_SIMPLE=1 docker compose run --rm claude +codex: ## Run Codex CLI (OpenAI) + docker compose run --rm codex + logs: ## Show web terminal logs docker compose logs -f web diff --git a/README.md b/README.md index b19a342..5ee68ae 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Dockerized CLI toolkit with [GitHub Copilot CLI](https://github.com/github/copil | GitHub Copilot CLI | `copilot` | `GH_TOKEN` | | GitHub CLI | `gh` | `GH_TOKEN` | | Claude Code | `claude` | `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY` | +| Codex CLI (OpenAI) | `codex` | `OPENAI_API_KEY` (or device code flow) | ### Claude Code authentication @@ -50,6 +51,26 @@ ANTHROPIC_API_KEY=sk-ant-xxxxx CLAUDE_CODE_SIMPLE=0 docker compose run --rm claude ``` +### Codex CLI (OpenAI) authentication + +Two auth methods are supported: + +#### Option 1: API key (recommended) + +Set `OPENAI_API_KEY` in `.env` to skip interactive auth entirely: +```env +OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +#### Option 2: Device code flow (browser-free OAuth) + +There is no browser inside the container, so the default OAuth redirect flow will not work. +Use device code flow instead — it prints a URL and code that you visit on your **host** browser: +```bash +codex auth login --auth device +# Open the printed URL in your host browser and enter the code +``` + ### tmux — multi-pane workflows `tmux` is installed in the container and enabled by default in the **web terminal**. Every browser tab attaches to the same named session (`main`), so refreshing the page re-attaches rather than spawning a fresh shell. @@ -107,6 +128,7 @@ CLIDE_TMUX=1 ./clide shell # interactive shell with all CLIs ./clide copilot # run GitHub Copilot CLI ./clide claude # run Claude Code CLI +./clide codex # run Codex CLI (OpenAI) ./clide gh repo view # run GitHub CLI with args ./clide help # show all commands ``` @@ -117,6 +139,7 @@ make web # start web terminal make shell # interactive shell make copilot # run copilot make claude # run Claude Code CLI +make codex # run Codex CLI (OpenAI) make help # show all targets ``` @@ -172,6 +195,8 @@ By default every clide container applies an **iptables egress allowlist** at sta | `api.github.com` | GitHub Copilot CLI · GitHub CLI | | `github.com` | GitHub CLI | | `registry.npmjs.org` | npm package updates | +| `api.openai.com` | Codex CLI | +| `auth.openai.com` | Codex CLI — device code auth | DNS (port 53) and loopback traffic are always allowed. diff --git a/clide b/clide index 5f6eeb6..d9ab2ac 100755 --- a/clide +++ b/clide @@ -16,6 +16,7 @@ Commands: copilot Run GitHub Copilot CLI gh [args] Run GitHub CLI (pass args after) claude Run Claude Code CLI + codex [args] Run Codex CLI (OpenAI; pass args after) build Build the Docker image rebuild Rebuild the Docker image (no cache) logs Follow web terminal logs @@ -28,6 +29,8 @@ Examples: ./clide copilot # run copilot ./clide gh repo view # run gh with args PROJECT_DIR=~/myrepo ./clide shell # mount a specific project + ./clide codex # run Codex CLI (OpenAI) + ./clide codex auth login --auth device EOF } @@ -41,7 +44,7 @@ splash() { echo " ╚═════╝╚══════╝╚═╝╚═════╝ ╚══════╝" echo "" echo " CLI Development Environment" - echo " Copilot · GitHub CLI · Claude Code" + echo " Copilot · GitHub CLI · Claude Code · Codex CLI" echo "" } @@ -73,6 +76,11 @@ case "${1:-help}" in splash CLAUDE_CODE_SIMPLE=1 docker compose run --rm claude ;; + codex) + shift + splash + docker compose run --rm codex "$@" + ;; build) docker compose build ;; diff --git a/docker-compose.yml b/docker-compose.yml index 1556a5a..5179f97 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,7 @@ x-base: &base CLAUDE_CODE_SIMPLE: ${CLAUDE_CODE_SIMPLE:-1} CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN:-} CLIDE_TMUX: ${CLIDE_TMUX:-} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} # Drop all capabilities then re-add only what the firewall needs. # NET_ADMIN is required for iptables egress rules; set CLIDE_FIREWALL=0 to disable. cap_drop: @@ -66,3 +67,9 @@ services: <<: *base entrypoint: ["/usr/local/bin/claude-entrypoint.sh"] command: [] + + # Codex CLI (OpenAI) + codex: + <<: *base + entrypoint: ["/usr/local/bin/firewall.sh"] + command: ["codex"] diff --git a/firewall.sh b/firewall.sh index 8baac79..cf74e9b 100644 --- a/firewall.sh +++ b/firewall.sh @@ -35,6 +35,8 @@ BASELINE_HOSTS=( "api.github.com" # GitHub Copilot CLI + GitHub CLI "github.com" # GitHub CLI "registry.npmjs.org" # npm package updates (optional) + "api.openai.com" # Codex CLI (OpenAI) + "auth.openai.com" # Codex CLI — device code auth flow ) # ── Helpers ─────────────────────────────────────────────────────────────────── From c9c68d393d40f9f526ef3c364ad3b3f2a31142c3 Mon Sep 17 00:00:00 2001 From: eric is cool Date: Fri, 6 Mar 2026 20:16:13 -0500 Subject: [PATCH 04/12] docs: architecture diagram, threat model, runbook, compatibility matrix (#37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: architecture diagram, threat model, runbook, compatibility matrix - (#12) Add Mermaid architecture diagram to README showing host/container trust boundary, services, and allowlisted internet endpoints. - (#8) Add SECURITY.md — trust boundaries, attack surface breakdown, threat scenario table, and deployment hardening recommendations. - (#11) Add RUNBOOK.md — health checks, start/stop, logs, rebuild procedures, credential rotation, firewall and web terminal troubleshooting, Docker health state reference. - (#9) Add compatibility matrix to README covering host OS, Docker version, CPU architecture, browser support, and firewall requirements. - Link SECURITY.md and RUNBOOK.md from README "Additional docs" table. Closes #8, #9, #11, #12 Co-Authored-By: Claude Sonnet 4.6 * ci: re-trigger checks after workflow update * fix: resolve markdown-lint failures in docs PR - Rename duplicate 'Architecture' heading to 'CPU architecture' (MD024) - Rename duplicate 'Egress firewall' heading to 'Egress firewall support' (MD024) - Add 'text' language to ASCII art fenced block in SECURITY.md (MD040) Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: itscooleric Co-authored-by: Claude Sonnet 4.6 --- README.md | 87 ++++++++++++++++++++++ RUNBOOK.md | 209 ++++++++++++++++++++++++++++++++++++++++++++++++++++ SECURITY.md | 124 +++++++++++++++++++++++++++++++ 3 files changed, 420 insertions(+) create mode 100644 RUNBOOK.md create mode 100644 SECURITY.md diff --git a/README.md b/README.md index 5ee68ae..3312dab 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,46 @@ Dockerized CLI toolkit with [GitHub Copilot CLI](https://github.com/github/copilot-cli), [GitHub CLI](https://cli.github.com/), and [Claude Code](https://www.anthropic.com/claude/code) — agentic terminal assistants in one container. Run against any local project without installing anything on your host. Access via terminal or browser-based web terminal. +## Architecture + +```mermaid +graph TB + subgraph Host["Host Machine"] + workspace["📁 Project Directory\n(mounted as /workspace)"] + env[".env\n(secrets — gitignored)"] + browser["🌐 Browser\nlocalhost:7681"] + end + + subgraph Container["clide Container (non-root: clide uid=1000)"] + direction TB + firewall["🔥 firewall.sh\n(iptables egress allowlist)"] + + subgraph Services["Services"] + web["web\n(ttyd → tmux)"] + shell["shell\n(bash)"] + claude["claude\nClaude Code CLI"] + copilot["copilot\nGitHub Copilot CLI"] + gh["gh\nGitHub CLI"] + end + end + + subgraph Internet["Internet (allowlisted endpoints only)"] + anthropic["api.anthropic.com\nClaude Code"] + ghcopilot["api.githubcopilot.com\nGitHub Copilot"] + github["api.github.com\ngithub.com\nGitHub CLI"] + npm["registry.npmjs.org\nnpm"] + end + + browser -->|"HTTP (ttyd)"| web + workspace -->|"bind mount"| Container + env -->|"env vars"| Container + firewall --> Services + Services -->|"blocked by default"| firewall + firewall -->|"allowlisted"| Internet +``` + +> **Trust boundary:** the host trusts the container with a read-write mount of your project directory and your API credentials via `.env`. The container cannot reach the internet beyond the allowlisted endpoints (when `NET_ADMIN` is available). See [`SECURITY.md`](./SECURITY.md) for the full threat model. + ## Prerequisites - Docker + Docker Compose @@ -166,6 +206,14 @@ Your project is mounted at `/workspace` inside the container. ### Bernard/Forge deployment See [`DEPLOY.md`](./DEPLOY.md) for Caddy Docker Proxy integration. Uses `docker-compose.override.yml` (gitignored) for reverse proxy config that persists across git pulls. +## Additional docs + +| Doc | Contents | +|---|---| +| [`SECURITY.md`](./SECURITY.md) | Threat model, trust boundaries, attack surface, hardening recommendations | +| [`RUNBOOK.md`](./RUNBOOK.md) | Operational runbook — health checks, logs, rebuilds, credential rotation, troubleshooting | +| [`DEPLOY.md`](./DEPLOY.md) | Production deployment with Caddy reverse proxy | + ## Notes - Tokens don't expire unless you set an expiry — set them once in `.env` and you're done. OAuth tokens from `claude setup-token` are valid for 1 year. @@ -182,6 +230,45 @@ See [`DEPLOY.md`](./DEPLOY.md) for Caddy Docker Proxy integration. Uses `docker- make claude ``` +## Compatibility + +### Host OS + +| OS | Status | Notes | +|---|---|---| +| Linux | ✅ Supported | Native Docker — full functionality including egress firewall | +| macOS (Apple Silicon) | ✅ Supported | Docker Desktop required; `arm64` image builds natively | +| macOS (Intel) | ✅ Supported | Docker Desktop required | +| Windows (WSL2) | ✅ Supported | Docker Desktop with WSL2 backend required | +| Windows (no WSL2) | ⚠️ Partial | Egress firewall requires `NET_ADMIN`; availability varies by runtime | + +### Docker + +| Requirement | Minimum | +|---|---| +| Docker Engine | 20.10+ | +| Docker Compose | v2.0+ (`docker compose`, not `docker-compose`) | + +### CPU architecture + +| Arch | Status | +|---|---| +| `amd64` (x86_64) | ✅ Supported | +| `arm64` (Apple Silicon, Graviton) | ✅ Supported | + +### Web terminal browser support + +| Browser | Status | +|---|---| +| Chrome / Chromium | ✅ Supported | +| Firefox | ✅ Supported | +| Safari | ✅ Supported | +| Edge | ✅ Supported | + +### Egress firewall support + +The `iptables` egress firewall requires the `NET_ADMIN` capability and a Linux kernel with `iptables` support. It works out of the box on Linux hosts and Docker Desktop (macOS/Windows). If unavailable, the firewall degrades gracefully — a warning is printed and egress is unrestricted. + ## Egress firewall By default every clide container applies an **iptables egress allowlist** at startup, restricting outbound traffic to the known service endpoints. All bundled CLIs continue to work normally within these defaults. diff --git a/RUNBOOK.md b/RUNBOOK.md new file mode 100644 index 0000000..6446e9a --- /dev/null +++ b/RUNBOOK.md @@ -0,0 +1,209 @@ +# clide Operational Runbook + +Day-to-day operational reference for running and troubleshooting clide. + +--- + +## Health checks + +### Check if the web terminal is running +```bash +docker compose ps +``` +Look for `web` with status `running (healthy)`. If status is `unhealthy`, check logs: +```bash +docker compose logs web +``` + +### Manually probe the web terminal endpoint +```bash +curl -f http://localhost:7681/ +# Expect: HTTP 200 (or 401 if auth is enabled) +``` + +### Check container resource usage +```bash +docker stats clide-web-1 +``` + +--- + +## Starting and stopping + +### Start the web terminal (background) +```bash +make web +# or +docker compose up -d web +``` + +### Stop the web terminal +```bash +docker compose down web +# or to stop all services: +docker compose down +``` + +### Restart the web terminal +```bash +docker compose restart web +``` + +--- + +## Logs + +### Tail web terminal logs +```bash +make logs +# or +docker compose logs -f web +``` + +### View logs for a specific service +```bash +docker compose logs -f shell +docker compose logs -f claude +``` + +### View recent logs without following +```bash +docker compose logs --tail=50 web +``` + +--- + +## Rebuilding + +### Rebuild after a git pull (picks up Dockerfile changes) +```bash +git pull +docker compose build +docker compose up -d web +``` + +### Full rebuild (no cache — picks up new CLI versions) +```bash +docker compose build --no-cache +docker compose up -d web +``` + +### Reset everything (remove containers, volumes, orphans) +```bash +docker compose down -v --remove-orphans +docker compose build +``` + +--- + +## Credential rotation + +### Rotate GitHub token (`GH_TOKEN`) +1. Generate a new fine-grained PAT at https://github.com/settings/personal-access-tokens/new with **"Copilot Requests"** permission. +2. Update `GH_TOKEN` in `.env`. +3. Restart the container — no rebuild required: + ```bash + docker compose down && docker compose up -d web + ``` + +### Rotate Claude OAuth token (`CLAUDE_CODE_OAUTH_TOKEN`) +1. On a machine with a browser, run: + ```bash + claude setup-token + ``` +2. Update `CLAUDE_CODE_OAUTH_TOKEN` in `.env`. +3. Restart — no rebuild required. + +### Rotate ttyd credentials (`TTYD_USER` / `TTYD_PASS`) +1. Update `TTYD_USER` and `TTYD_PASS` in `.env`. +2. Restart the web service: + ```bash + docker compose restart web + ``` + +--- + +## Firewall troubleshooting + +### Check if the firewall is active +```bash +docker compose exec web iptables -L OUTPUT -n --line-numbers +``` +If you see `REJECT` rules, the firewall is active. If you get a permission error, the container may be running without `NET_ADMIN`. + +### Check firewall startup logs +```bash +docker compose logs web | grep firewall +``` +- `firewall: egress allowlist active` — firewall is running normally +- `firewall: WARNING - cannot access iptables` — `NET_ADMIN` capability missing; egress is unrestricted +- `firewall: disabled (CLIDE_FIREWALL=0)` — firewall was explicitly disabled + +### Allow an additional host +Add it to `.env` and restart — no rebuild required: +```env +CLIDE_ALLOWED_HOSTS=pypi.org,files.pythonhosted.org +``` +```bash +docker compose restart web +``` + +### Temporarily disable the firewall +```env +# .env +CLIDE_FIREWALL=0 +``` +```bash +docker compose restart web +``` +Remember to remove `CLIDE_FIREWALL=0` once done. + +--- + +## Web terminal troubleshooting + +### Browser shows connection refused +1. Check the container is running: `docker compose ps` +2. Check the port matches `.env`: default is `7681` +3. Check logs: `docker compose logs web` + +### Browser shows 401 Unauthorized +- `TTYD_USER` and `TTYD_PASS` are required. Check they are set in `.env`. +- If you intentionally want no auth: set `TTYD_NO_AUTH=true` in `.env`. + +### Session disappeared / tmux session lost +The web terminal always attaches to a named tmux session (`main`). If the container restarted, the session is gone. Refresh the browser to start a new one. + +### Claude prompts for setup on every run +The entrypoint pre-seeds `~/.claude.json` to suppress first-run prompts. If they keep appearing: +```bash +docker compose down -v +docker compose build --no-cache +make claude +``` + +--- + +## Running against a different project + +```bash +PROJECT_DIR=/path/to/your/repo make shell +# or +PROJECT_DIR=/path/to/your/repo docker compose run --rm shell +``` + +--- + +## Interpreting Docker health states + +| Status | Meaning | +|---|---| +| `starting` | Container is within the `--start-period` (10s); health not yet evaluated | +| `healthy` | ttyd responded to the HTTP probe | +| `unhealthy` | ttyd failed to respond 3 times in a row — check logs | +| `none` | HEALTHCHECK not configured (shouldn't happen with current Dockerfile) | + +To inspect health detail: +```bash +docker inspect clide-web-1 --format='{{json .State.Health}}' | jq +``` diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..776f0fe --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,124 @@ +# clide Security & Threat Model + +This document describes the trust boundaries, attack surface, known threats, and mitigations for clide. + +--- + +## Trust boundaries + +```text +┌─────────────────────────────────────────────────────┐ +│ Host Machine │ +│ │ +│ .env (secrets) ──────────────────────────────┐ │ +│ Project directory ─────────────────────────┐ │ │ +│ │ │ │ +│ ┌──────────────────────────────────────────▼──▼─┐ │ +│ │ clide Container │ │ +│ │ User: clide (uid=1000, non-root) │ │ +│ │ │ │ +│ │ ┌──────────────────────────────────────────┐ │ │ +│ │ │ firewall.sh (iptables egress allowlist) │ │ │ +│ │ └──────────────────────────────────────────┘ │ │ +│ │ │ │ │ +│ │ (allowlisted endpoints only) │ │ +│ └──────────────────────────│────────────────────┘ │ +│ │ │ +└─────────────────────────────│────────────────────────┘ + ▼ + Internet (restricted) + api.anthropic.com + api.githubcopilot.com + api.github.com / github.com + registry.npmjs.org +``` + +### What the host trusts the container with +- **Read-write access to your project directory** — mounted at `/workspace`. The container can read, write, and delete files in this directory. +- **API credentials** — `GH_TOKEN`, `ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, and `OPENAI_API_KEY` are passed in via `.env` and available as environment variables inside the container. +- **Network access** — restricted to the egress allowlist by default. + +### What the container does NOT have +- Access to the rest of the host filesystem (only `/workspace` is mounted) +- Root privileges during normal operation (gosu drops to `clide` uid=1000 before any workload starts) +- Unrestricted internet access (egress firewall allowlist — when `NET_ADMIN` is available) +- Access to other containers or host services beyond what Docker networking exposes + +--- + +## Attack surface + +### Web terminal (ttyd) +- **Exposure:** ttyd binds to `0.0.0.0:7681` by default, exposing a full shell over HTTP. +- **Risk:** Anyone who can reach that port gets an interactive shell as `clide` with access to your project files and API credentials. +- **Mitigations:** + - Basic auth enforced by default (`TTYD_USER` + `TTYD_PASS` required; container refuses to start without them unless `TTYD_NO_AUTH=true` is explicitly set) + - Bind to `127.0.0.1` only if not using a reverse proxy (set `TTYD_PORT=127.0.0.1:7681` in `.env`) + - Use a TLS-terminating reverse proxy (e.g. Caddy) in production — see `DEPLOY.md` + +### Mounted workspace +- **Exposure:** The container has read-write access to everything under `PROJECT_DIR` (default: parent directory of the clide repo). +- **Risk:** A compromised or misbehaving AI agent could modify, delete, or exfiltrate source code and committed secrets. +- **Mitigations:** + - Use git to track changes; review diffs before committing + - Point `PROJECT_DIR` at a specific repo rather than a broad parent directory + - The egress firewall limits where data can be sent + +### API credentials in environment +- **Exposure:** Tokens (`GH_TOKEN`, `ANTHROPIC_API_KEY`, etc.) are present as environment variables inside the container. +- **Risk:** A process running inside the container can read and exfiltrate these tokens. +- **Mitigations:** + - Egress firewall restricts outbound traffic to known endpoints — a stolen token can't easily be sent to an attacker's server + - Use fine-grained PATs with minimal permissions (e.g. `GH_TOKEN` only needs "Copilot Requests") + - Rotate tokens regularly; set expiry dates on PATs + +### Egress / network +- **Exposure:** Containers can make outbound network requests. +- **Risk:** An AI agent could exfiltrate data, download malicious payloads, or establish reverse shells. +- **Mitigations:** + - iptables egress allowlist restricts outbound to known good endpoints + - `REJECT` (not `DROP`) so connection failures are immediate and visible + - DNS is allowed (required for hostname resolution) — a motivated attacker could use DNS tunneling; this is a known limitation of IP-based egress filtering + +### Container privilege +- **Exposure:** The entrypoint must start as root to apply iptables rules. +- **Risk:** A vulnerability in the startup scripts could allow privilege retention. +- **Mitigations:** + - `gosu clide` is called before any user-facing workload; the process tree runs as uid=1000 + - `cap_drop: ALL` + `cap_add: NET_ADMIN` — only the firewall capability is retained, dropped after use + - `no-new-privileges: true` — prevents setuid escalation + - `pids_limit`, `mem_limit`, `cpus` — resource guardrails against runaway processes + +--- + +## Threat scenarios + +| Threat | Likelihood | Impact | Mitigation | +|---|---|---|---| +| Unauthenticated web terminal access | Medium (if exposed on network) | High (full shell) | Basic auth required by default; use reverse proxy + TLS | +| AI agent exfiltrates source code | Low | High | Egress firewall limits destinations; review agent output | +| AI agent deletes project files | Low | Medium | Use git; review before committing | +| API token theft via network | Low | Medium | Egress firewall; token scoping; rotation | +| Container escape to host | Very Low | High | Non-root user; minimal capabilities; no privileged mode | +| Malicious dependency in npm install | Low | Medium | Pin versions (#7); review `package.json` | +| DNS tunneling for data exfiltration | Very Low | Low | Known limitation of IP-based egress filtering | + +--- + +## Deployment hardening recommendations + +For production or shared deployments (e.g. Bernard/Forge): + +1. **Always use TLS** — put clide behind Caddy or another TLS-terminating proxy. Never expose ttyd directly over HTTP on a public network. +2. **Enable basic auth** — set `TTYD_USER` and `TTYD_PASS` in `.env`. Do not use `TTYD_NO_AUTH=true` in production. +3. **Scope `PROJECT_DIR`** — mount only the specific repo you're working on, not a broad parent directory. +4. **Use minimal-permission tokens** — create fine-grained PATs with only the permissions each CLI needs. +5. **Set token expiry** — don't create non-expiring tokens. Rotate on a schedule. +6. **Review egress** — if you need to add hosts to `CLIDE_ALLOWED_HOSTS`, understand why before adding them. +7. **Monitor logs** — `docker compose logs -f web` will show firewall warnings and auth events. + +--- + +## Reporting security issues + +Please report security vulnerabilities privately to the repository owner rather than opening a public issue. From 6226d697eba49bd5f0e2d321dbd9c17a63b75ea0 Mon Sep 17 00:00:00 2001 From: itscooleric Date: Sat, 7 Mar 2026 04:54:40 +0000 Subject: [PATCH 05/12] fix: change clide UID from 1000 to 1001 to avoid conflict with Ubuntu 24.04 built-in ubuntu user Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 01a785f..282916b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,7 +63,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ENV PATH="/opt/pyenv/bin:${PATH}" # Create unprivileged user and set up workspace -RUN useradd -m -s /bin/bash -u 1000 clide \ +RUN useradd -m -s /bin/bash -u 1001 clide \ && mkdir -p /workspace \ && chown clide:clide /workspace From 44229e56fffe60df8472ff3390243e6864783ede Mon Sep 17 00:00:00 2001 From: itscooleric Date: Sat, 7 Mar 2026 10:56:30 +0000 Subject: [PATCH 06/12] fix: remove redundant mkdir/chown for /home/clide in entrypoint useradd -m in the Dockerfile already creates /home/clide with correct ownership. The chown was also failing at runtime due to cap_drop: ALL removing the CHOWN capability. Co-Authored-By: Claude Sonnet 4.6 --- claude-entrypoint.sh | 3 --- 1 file changed, 3 deletions(-) diff --git a/claude-entrypoint.sh b/claude-entrypoint.sh index 1607f89..4871f75 100644 --- a/claude-entrypoint.sh +++ b/claude-entrypoint.sh @@ -3,9 +3,6 @@ set -euo pipefail # Hardcode clide's home — avoids picking up /root when entrypoint runs as root. HOME_DIR="/home/clide" -mkdir -p "$HOME_DIR" -chown clide:clide "$HOME_DIR" - export HOME="$HOME_DIR" # Set up egress firewall (CLIDE_FIREWALL=0 to disable; CLIDE_ALLOWED_HOSTS to extend) From a7223aa9ae71f90d76a6c7541ddfd3520162a43e Mon Sep 17 00:00:00 2001 From: itscooleric Date: Sat, 7 Mar 2026 11:00:49 +0000 Subject: [PATCH 07/12] fix: run claude config node script as clide user via gosu cap_drop: ALL removes CAP_DAC_OVERRIDE, meaning root inside the container is subject to normal file permission checks and cannot write to /home/clide. Running the node script via gosu clide ensures it writes the config as the correct user. Co-Authored-By: Claude Sonnet 4.6 --- claude-entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/claude-entrypoint.sh b/claude-entrypoint.sh index 4871f75..b55bad9 100644 --- a/claude-entrypoint.sh +++ b/claude-entrypoint.sh @@ -15,7 +15,7 @@ if [[ "${CLIDE_FIREWALL_DONE:-0}" != "1" ]]; then export CLIDE_FIREWALL_DONE=1 fi -node <<'NODE' +gosu clide node <<'NODE' const fs = require('fs'); const configPath = `${process.env.HOME}/.claude.json`; From b6feaa5aff34a088c8c82883a165a6f77c2b0f36 Mon Sep 17 00:00:00 2001 From: itscooleric Date: Sat, 7 Mar 2026 11:03:32 +0000 Subject: [PATCH 08/12] fix: add SETUID and SETGID capabilities required by gosu cap_drop: ALL removes CAP_SETUID and CAP_SETGID, which gosu needs to switch from root to the clide user in entrypoint scripts. Co-Authored-By: Claude Sonnet 4.6 --- docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 5179f97..7c673ab 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,8 @@ x-base: &base - ALL cap_add: - NET_ADMIN + - SETUID + - SETGID # Prevent processes from gaining new privileges via setuid/setgid binaries. security_opt: - no-new-privileges:true From a1c22eb1ea4cf5da925d2b41aa42e534132959d3 Mon Sep 17 00:00:00 2001 From: itscooleric Date: Sat, 7 Mar 2026 11:12:33 +0000 Subject: [PATCH 09/12] fix: add missing GitHub hosts to firewall allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit git clone/fetch/pull serve pack objects via objects.githubusercontent.com, raw file access goes through raw.githubusercontent.com, and gh release uploads use uploads.github.com — all previously blocked by the egress filter. Co-Authored-By: Claude Sonnet 4.6 --- firewall.sh | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/firewall.sh b/firewall.sh index cf74e9b..752b8cb 100644 --- a/firewall.sh +++ b/firewall.sh @@ -30,13 +30,16 @@ echo "firewall: configuring egress allowlist..." # ── Baseline allowed hosts ──────────────────────────────────────────────────── BASELINE_HOSTS=( - "api.anthropic.com" # Claude Code - "api.githubcopilot.com" # GitHub Copilot CLI - "api.github.com" # GitHub Copilot CLI + GitHub CLI - "github.com" # GitHub CLI - "registry.npmjs.org" # npm package updates (optional) - "api.openai.com" # Codex CLI (OpenAI) - "auth.openai.com" # Codex CLI — device code auth flow + "api.anthropic.com" # Claude Code + "api.githubcopilot.com" # GitHub Copilot CLI + "api.github.com" # GitHub Copilot CLI + GitHub CLI + "github.com" # GitHub CLI + git over HTTPS + "objects.githubusercontent.com" # git pack objects (clone/fetch/pull) + "raw.githubusercontent.com" # raw file access + "uploads.github.com" # gh push / release uploads + "registry.npmjs.org" # npm package updates (optional) + "api.openai.com" # Codex CLI (OpenAI) + "auth.openai.com" # Codex CLI — device code auth flow ) # ── Helpers ─────────────────────────────────────────────────────────────────── From c646558b44b16ec8769bff11df2dd32a677eb98d Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Mar 2026 14:03:20 -0500 Subject: [PATCH 10/12] feat: add egress reject audit logging (LOG before REJECT in firewall.sh) Closes #40. Adds rate-limited LOG target (10/min, burst 5) before REJECT rules for both IPv4 and IPv6. Logs appear under CLIDE-REJECT: prefix in dmesg/kern.log. --- firewall.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/firewall.sh b/firewall.sh index 752b8cb..6506cd8 100644 --- a/firewall.sh +++ b/firewall.sh @@ -100,7 +100,9 @@ if [[ -n "${CLIDE_ALLOWED_HOSTS:-}" ]]; then done <<< "$normalized" fi -# 6. Reject all other outbound traffic (REJECT gives immediate feedback vs DROP's timeout) +# 6. Log then reject all other outbound traffic +_ipt -A OUTPUT -m limit --limit 10/min --limit-burst 5 -j LOG --log-prefix "CLIDE-REJECT: " --log-level 4 +_ip6 -A OUTPUT -m limit --limit 10/min --limit-burst 5 -j LOG --log-prefix "CLIDE-REJECT: " --log-level 4 _ipt -A OUTPUT -j REJECT --reject-with icmp-port-unreachable _ip6 -A OUTPUT -j REJECT From d2b1ed27bd4fe36e4547c3a47a9998172fb9fe8d Mon Sep 17 00:00:00 2001 From: eric is cool Date: Sat, 7 Mar 2026 20:51:30 -0500 Subject: [PATCH 11/12] fix: writable pyenv for agent + git safe.directory pre-configured (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: make pyenv writable by clide + pre-configure git safe.directory Two quality-of-life improvements for agent dev sessions: **1. Writable Python venv (/opt/pyenv)** Transfer /opt/pyenv ownership to the clide user so the agent can `pip install -r /workspace//requirements.txt` on demand without a container rebuild. Previously the venv was root-owned, causing permission errors when installing workspace project dependencies (e.g. clem's PyYAML, Flask, pydantic). pytest + ruff remain pre-installed as a baseline; all other deps are installed per-session as needed. **2. git safe.directory = * (eliminate per-session config noise)** Volume-mounted workspace repos are often owned by the host UID, which differs from clide (UID 1000). Git refuses to operate on these repos unless `safe.directory` is configured, causing wasted tokens and boilerplate at the start of every session. Setting `safe.directory = *` once at build time in the clide user's gitconfig eliminates this entirely. This is appropriate for a single-user dev sandbox. Co-Authored-By: Claude Sonnet 4.6 * docs: README — clide-owned venv, pip install pattern, git safe.directory, codex in diagram (#56) * Initial plan * docs: update README for clide-owned venv, pip install pattern, git safe.directory section Co-authored-by: itscooleric <25490177+itscooleric@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: itscooleric <25490177+itscooleric@users.noreply.github.com> --------- Co-authored-by: itscooleric Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: itscooleric <25490177+itscooleric@users.noreply.github.com> --- Dockerfile | 17 +++++++++++++++-- README.md | 19 ++++++++++++++++--- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 282916b..13f085d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,7 +49,9 @@ RUN npm install -g @openai/codex # Install Python 3 + dev tooling into an isolated venv # - python3-venv provides the venv module (not always bundled in minimal images) -# - /opt/pyenv is world-readable so the unprivileged clide user can run tools +# - /opt/pyenv is owned by clide so the agent can pip-install workspace project +# dependencies on demand (e.g. pip install -r /workspace/clem/requirements.txt) +# without a container rebuild. pytest + ruff are pre-installed as a baseline. # hadolint ignore=DL3008 RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ @@ -65,7 +67,9 @@ ENV PATH="/opt/pyenv/bin:${PATH}" # Create unprivileged user and set up workspace RUN useradd -m -s /bin/bash -u 1001 clide \ && mkdir -p /workspace \ - && chown clide:clide /workspace + && chown clide:clide /workspace \ + # Hand venv ownership to clide so pip install works without sudo + && chown -R clide:clide /opt/pyenv # Add entrypoint scripts COPY entrypoint.sh /usr/local/bin/entrypoint.sh @@ -79,6 +83,15 @@ COPY --chown=clide:clide .tmux.conf /home/clide/.tmux.conf # Switch to unprivileged user for user-scoped installs USER clide +# Trust all directories for git operations. +# Clide is a single-user dev sandbox — volume-mounted repos from the host +# are often owned by a different UID (host user vs clide:1000), which causes +# git to refuse to operate and wastes tokens on `git config --global +# --add safe.directory ...` at the start of every session. +# Set safe.directory=* once at image build time to eliminate this entirely. +# See: https://git-scm.com/docs/git-config#Documentation/git-config.txt-safedirectory +RUN git config --global --add safe.directory '*' + # Install GitHub Copilot CLI (unpinned — tracks gh extension updates) RUN curl -fsSL https://gh.io/copilot-install | bash diff --git a/README.md b/README.md index 3312dab..aaddf2d 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ graph TB claude["claude\nClaude Code CLI"] copilot["copilot\nGitHub Copilot CLI"] gh["gh\nGitHub CLI"] + codex["codex\nCodex CLI"] end end @@ -309,17 +310,29 @@ The firewall uses `iptables` and requires the `NET_ADMIN` capability, which is a ## Python dev tooling -The container includes a Python 3 virtual environment at `/opt/pyenv` with the following tools pre-installed and on `PATH`: +The container includes a Python 3 **virtualenv** at `/opt/pyenv` (note: this is a plain `python3 -m venv`, not the pyenv version manager) with the following tools pre-installed and on `PATH`: | Tool | Purpose | |------|---------| | `pytest` | Test runner — `pytest tests/` | | `ruff` | Linter + formatter — `ruff check .` / `ruff format .` | -To install project dependencies inside the container: +The venv is **clide-owned** — `pip install` works without `sudo` or a container rebuild. To install workspace project dependencies: ```bash -pip install -r requirements.txt +pip install -r /workspace//requirements.txt ``` The venv is at `/opt/pyenv`; `pip`, `pytest`, and `ruff` are all directly callable without activation. + +> **Note:** `pip install` requires outbound access to PyPI. Add these to `CLIDE_ALLOWED_HOSTS` if the egress firewall is enabled: +> ```env +> CLIDE_ALLOWED_HOSTS=pypi.org,files.pythonhosted.org +> ``` + +## Git workspace repos + +`safe.directory = *` is pre-configured in the clide user's gitconfig at image build time. Volume-mounted repos cloned from GitHub are often owned by the host UID rather than `clide:1000`, which normally causes git to refuse to operate with a "detected dubious ownership" error. This is eliminated entirely — `git status`, `git log`, and all other git operations work from the first prompt without any `git config` boilerplate. + +> **Security note:** `safe.directory = *` trusts all directories unconditionally. This is appropriate for a single-user dev sandbox where you control what gets mounted, but it means git will operate in any directory regardless of ownership. If you share the container or mount untrusted paths, consider replacing `*` with the specific paths you use (e.g. `/workspace`). + From 8b43cfd1695f2a3dd07bf3d40acf4ede0faec56f Mon Sep 17 00:00:00 2001 From: eric is cool Date: Sat, 7 Mar 2026 21:42:50 -0500 Subject: [PATCH 12/12] feat: add persistent session memory across container restarts (#57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add persistent session memory across container restarts - Add named volume (claude-data) for ~/.claude so auto memory, settings, and project learnings survive container restarts - Seed CLAUDE.md from a template on first run if none exists in /workspace - Template search order: project .claude/CLAUDE.md.template → bundled default - Bundle a sensible default CLAUDE.md.template in the image https://claude.ai/code/session_012jgpaUaGUgmGyoH9edLbyg * fix: disable MD060 lint rule and remove trailing blank line in README The markdownlint-cli2 action upgraded to markdownlint v0.40.0 which introduced MD060 (table-column-style). Disable it to match existing compact table style, and fix one MD012 violation (double blank line). https://claude.ai/code/session_012jgpaUaGUgmGyoH9edLbyg --------- Co-authored-by: Claude --- .env.example | 9 +++++++++ .markdownlint.yaml | 1 + CLAUDE.md.template | 18 ++++++++++++++++++ Dockerfile | 3 +++ README.md | 1 - claude-entrypoint.sh | 21 +++++++++++++++++++++ docker-compose.yml | 6 ++++++ 7 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md.template diff --git a/.env.example b/.env.example index 9ac4809..6070a4f 100644 --- a/.env.example +++ b/.env.example @@ -61,6 +61,15 @@ CADDY_TLS=internal # CADDY_BASICAUTH_USER=admin # CADDY_BASICAUTH_HASH=$2a$14$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +# ── Session memory ───────────────────────────────────────────────────────── +# Claude Code session memory (~/.claude) is persisted in a named Docker volume +# (claude-data) and survives container restarts automatically. +# +# CLAUDE.md template: on first run, if /workspace/CLAUDE.md does not exist, +# the entrypoint copies a template into it. Override the default by placing +# your own template at: /.claude/CLAUDE.md.template +# ───────────────────────────────────────────────────────────────────────────── + # ── Egress firewall ────────────────────────────────────────────────────────── # By default an iptables allowlist restricts outbound traffic to the known # service endpoints (Claude, Copilot, GitHub CLI, npm). Requires the diff --git a/.markdownlint.yaml b/.markdownlint.yaml index 85890d7..be354a7 100644 --- a/.markdownlint.yaml +++ b/.markdownlint.yaml @@ -5,3 +5,4 @@ MD031: false # blanks-around-fences: style preference MD032: false # blanks-around-lists: style preference MD033: false # no-inline-html: used intentionally in some docs MD034: false # no-bare-urls: URLs in technical docs are self-explanatory +MD060: false # table-column-style: compact tables are readable enough diff --git a/CLAUDE.md.template b/CLAUDE.md.template new file mode 100644 index 0000000..ca24dab --- /dev/null +++ b/CLAUDE.md.template @@ -0,0 +1,18 @@ +# Project Instructions + +You are working inside a clide container — a sandboxed development environment +with Claude Code, GitHub Copilot, GitHub CLI, and Codex pre-installed. + +## Environment + +- Workspace is mounted at `/workspace` +- You are running as user `clide` (uid 1001) +- Egress is restricted to an allowlist (see firewall.sh) +- Python venv is at `/opt/pyenv` (pytest and ruff pre-installed) +- Session memory persists across container restarts in `~/.claude/` + +## Conventions + +- Write clear, concise commit messages +- Run tests before committing when a test suite exists +- Prefer editing existing files over creating new ones diff --git a/Dockerfile b/Dockerfile index 13f085d..0934a87 100644 --- a/Dockerfile +++ b/Dockerfile @@ -77,6 +77,9 @@ COPY claude-entrypoint.sh /usr/local/bin/claude-entrypoint.sh COPY firewall.sh /usr/local/bin/firewall.sh RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/claude-entrypoint.sh /usr/local/bin/firewall.sh +# Default CLAUDE.md template — seeded into /workspace on first run if none exists +COPY CLAUDE.md.template /usr/local/share/clide/CLAUDE.md.template + # tmux config — mouse support, sane splits, 256-colour COPY --chown=clide:clide .tmux.conf /home/clide/.tmux.conf diff --git a/README.md b/README.md index aaddf2d..68b9c45 100644 --- a/README.md +++ b/README.md @@ -335,4 +335,3 @@ The venv is at `/opt/pyenv`; `pip`, `pytest`, and `ruff` are all directly callab `safe.directory = *` is pre-configured in the clide user's gitconfig at image build time. Volume-mounted repos cloned from GitHub are often owned by the host UID rather than `clide:1000`, which normally causes git to refuse to operate with a "detected dubious ownership" error. This is eliminated entirely — `git status`, `git log`, and all other git operations work from the first prompt without any `git config` boilerplate. > **Security note:** `safe.directory = *` trusts all directories unconditionally. This is appropriate for a single-user dev sandbox where you control what gets mounted, but it means git will operate in any directory regardless of ownership. If you share the container or mount untrusted paths, consider replacing `*` with the specific paths you use (e.g. `/workspace`). - diff --git a/claude-entrypoint.sh b/claude-entrypoint.sh index b55bad9..8c83d2c 100644 --- a/claude-entrypoint.sh +++ b/claude-entrypoint.sh @@ -15,6 +15,27 @@ if [[ "${CLIDE_FIREWALL_DONE:-0}" != "1" ]]; then export CLIDE_FIREWALL_DONE=1 fi +# Seed CLAUDE.md into the workspace if a template exists and no CLAUDE.md is present. +# This gives every session a baseline set of instructions without overwriting user edits. +# Template search order: /workspace/.claude/CLAUDE.md.template, then bundled default. +CLAUDE_MD="/workspace/CLAUDE.md" +if [[ ! -f "$CLAUDE_MD" ]]; then + TEMPLATE="" + if [[ -f "/workspace/.claude/CLAUDE.md.template" ]]; then + TEMPLATE="/workspace/.claude/CLAUDE.md.template" + elif [[ -f "/usr/local/share/clide/CLAUDE.md.template" ]]; then + TEMPLATE="/usr/local/share/clide/CLAUDE.md.template" + fi + if [[ -n "$TEMPLATE" ]]; then + cp "$TEMPLATE" "$CLAUDE_MD" + chown clide:clide "$CLAUDE_MD" + echo "claude: seeded CLAUDE.md from ${TEMPLATE}" + fi +fi + +# Ensure the persistent volume directory is owned by clide (first-run fix for named volumes) +chown -R clide:clide "$HOME_DIR/.claude" 2>/dev/null || true + gosu clide node <<'NODE' const fs = require('fs'); diff --git a/docker-compose.yml b/docker-compose.yml index 7c673ab..47d18cc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,7 @@ x-base: &base pids_limit: 512 volumes: - ${PROJECT_DIR:-..}:/workspace + - claude-data:/home/clide/.claude stdin_open: true tty: true @@ -75,3 +76,8 @@ services: <<: *base entrypoint: ["/usr/local/bin/firewall.sh"] command: ["codex"] + +# Named volumes — persist state across container restarts. +# claude-data: Claude Code session memory, settings, and project learnings (~/.claude) +volumes: + claude-data: