diff --git a/.env.example b/.env.example index b0c7c7c..6070a4f 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 @@ -50,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 4a503fa..0934a87 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,9 +43,15 @@ 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 +# - /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 \ @@ -59,9 +65,11 @@ 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 + && 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 @@ -69,12 +77,24 @@ 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 # 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/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..68b9c45 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,47 @@ 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"] + codex["codex\nCodex 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 @@ -18,6 +59,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 +92,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 +169,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 +180,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 ``` @@ -143,6 +207,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. @@ -159,6 +231,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. @@ -172,6 +283,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. @@ -197,17 +310,28 @@ 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`). 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. diff --git a/claude-entrypoint.sh b/claude-entrypoint.sh index 1607f89..8c83d2c 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) @@ -18,7 +15,28 @@ if [[ "${CLIDE_FIREWALL_DONE:-0}" != "1" ]]; then export CLIDE_FIREWALL_DONE=1 fi -node <<'NODE' +# 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'); const configPath = `${process.env.HOME}/.claude.json`; 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..47d18cc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,12 +6,15 @@ 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: - ALL cap_add: - NET_ADMIN + - SETUID + - SETGID # Prevent processes from gaining new privileges via setuid/setgid binaries. security_opt: - no-new-privileges:true @@ -21,6 +24,7 @@ x-base: &base pids_limit: 512 volumes: - ${PROJECT_DIR:-..}:/workspace + - claude-data:/home/clide/.claude stdin_open: true tty: true @@ -66,3 +70,14 @@ services: <<: *base entrypoint: ["/usr/local/bin/claude-entrypoint.sh"] command: [] + + # Codex CLI (OpenAI) + codex: + <<: *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: diff --git a/firewall.sh b/firewall.sh index 8baac79..6506cd8 100644 --- a/firewall.sh +++ b/firewall.sh @@ -30,11 +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.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 ─────────────────────────────────────────────────────────────────── @@ -95,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