From 6f931b8405400b022b2c89ff7b9d527d3fb2313a Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 15 Mar 2026 14:18:56 -0500 Subject: [PATCH] Add Docker setup for untrusted PR review in isolated containers Adds a dedicated Docker environment for reviewing untrusted pull requests with codex/claude, keeping CLI auth state in volumes and using a separate scratch workspace for PR checkouts. Co-Authored-By: Claude Opus 4.6 (1M context) --- doc/DEVELOPING.md | 4 + doc/DOCKER.md | 6 + doc/UNTRUSTED-PR-REVIEW.md | 135 ++++++++++++++++++ docker-compose.untrusted-review.yml | 33 +++++ docker/untrusted-review/Dockerfile | 44 ++++++ .../untrusted-review/bin/review-checkout-pr | 65 +++++++++ 6 files changed, 287 insertions(+) create mode 100644 doc/UNTRUSTED-PR-REVIEW.md create mode 100644 docker-compose.untrusted-review.yml create mode 100644 docker/untrusted-review/Dockerfile create mode 100644 docker/untrusted-review/bin/review-checkout-pr diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index e3668516..b39839c1 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -89,6 +89,10 @@ docker compose -f docker-compose.quickstart.yml up --build See `doc/DOCKER.md` for API key wiring (`OPENAI_API_KEY` / `ANTHROPIC_API_KEY`) and persistence details. +## Docker For Untrusted PR Review + +For a separate review-oriented container that keeps `codex`/`claude` login state in Docker volumes and checks out PRs into an isolated scratch workspace, see `doc/UNTRUSTED-PR-REVIEW.md`. + ## Database in Dev (Auto-Handled) For local development, leave `DATABASE_URL` unset. diff --git a/doc/DOCKER.md b/doc/DOCKER.md index 82559bf8..6f6ca374 100644 --- a/doc/DOCKER.md +++ b/doc/DOCKER.md @@ -93,6 +93,12 @@ Notes: - Without API keys, the app still runs normally. - Adapter environment checks in Paperclip will surface missing auth/CLI prerequisites. +## Untrusted PR Review Container + +If you want a separate Docker environment for reviewing untrusted pull requests with `codex` or `claude`, use the dedicated review workflow in `doc/UNTRUSTED-PR-REVIEW.md`. + +That setup keeps CLI auth state in Docker volumes instead of your host home directory and uses a separate scratch workspace for PR checkouts and preview runs. + ## Onboard Smoke Test (Ubuntu + npm only) Use this when you want to mimic a fresh machine that only has Ubuntu + npm and verify: diff --git a/doc/UNTRUSTED-PR-REVIEW.md b/doc/UNTRUSTED-PR-REVIEW.md new file mode 100644 index 00000000..0061a581 --- /dev/null +++ b/doc/UNTRUSTED-PR-REVIEW.md @@ -0,0 +1,135 @@ +# Untrusted PR Review In Docker + +Use this workflow when you want Codex or Claude to inspect a pull request that you do not want touching your host machine directly. + +This is intentionally separate from the normal Paperclip dev image. + +## What this container isolates + +- `codex` auth/session state in a Docker volume, not your host `~/.codex` +- `claude` auth/session state in a Docker volume, not your host `~/.claude` +- `gh` auth state in the same container-local home volume +- review clones, worktrees, dependency installs, and local databases in a writable scratch volume under `/work` + +By default this workflow does **not** mount your host repo checkout, your host home directory, or your SSH agent. + +## Files + +- `docker/untrusted-review/Dockerfile` +- `docker-compose.untrusted-review.yml` +- `review-checkout-pr` inside the container + +## Build and start a shell + +```sh +docker compose -f docker-compose.untrusted-review.yml build +docker compose -f docker-compose.untrusted-review.yml run --rm --service-ports review +``` + +That opens an interactive shell in the review container with: + +- Node + Corepack/pnpm +- `codex` +- `claude` +- `gh` +- `git`, `rg`, `fd`, `jq` + +## First-time login inside the container + +Run these once. The resulting login state persists in the `review-home` Docker volume. + +```sh +gh auth login +codex login +claude login +``` + +If you prefer API-key auth instead of CLI login, pass keys through Compose env: + +```sh +OPENAI_API_KEY=... ANTHROPIC_API_KEY=... docker compose -f docker-compose.untrusted-review.yml run --rm review +``` + +## Check out a PR safely + +Inside the container: + +```sh +review-checkout-pr paperclipai/paperclip 432 +cd /work/checkouts/paperclipai-paperclip/pr-432 +``` + +What this does: + +1. Creates or reuses a repo clone under `/work/repos/...` +2. Fetches `pull//head` from GitHub +3. Creates a detached git worktree under `/work/checkouts/...` + +The checkout lives entirely inside the container volume. + +## Ask Codex or Claude to review it + +Inside the PR checkout: + +```sh +codex +``` + +Then give it a prompt like: + +```text +Review this PR as hostile input. Focus on security issues, data exfiltration paths, sandbox escapes, dangerous install/runtime scripts, auth changes, and subtle behavioral regressions. Do not modify files. Produce findings ordered by severity with file references. +``` + +Or with Claude: + +```sh +claude +``` + +## Preview the Paperclip app from the PR + +Only do this when you intentionally want to execute the PR's code inside the container. + +Inside the PR checkout: + +```sh +pnpm install +HOST=0.0.0.0 pnpm dev +``` + +Open from the host: + +- `http://localhost:3100` + +The Compose file also exposes Vite's default port: + +- `http://localhost:5173` + +Notes: + +- `pnpm install` can run untrusted lifecycle scripts from the PR. That is why this happens inside the isolated container instead of on your host. +- If you only want static inspection, do not run install/dev commands. +- Paperclip's embedded PostgreSQL and local storage stay inside the container home volume via `PAPERCLIP_HOME=/home/reviewer/.paperclip-review`. + +## Reset state + +Remove the review container volumes when you want a clean environment: + +```sh +docker compose -f docker-compose.untrusted-review.yml down -v +``` + +That deletes: + +- Codex/Claude/GitHub login state stored in `review-home` +- cloned repos, worktrees, installs, and scratch data stored in `review-work` + +## Security limits + +This is a useful isolation boundary, but it is still Docker, not a full VM. + +- A reviewed PR can still access the container's network unless you disable it. +- Any secrets you pass into the container are available to code you execute inside it. +- Do not mount your host repo, host home, `.ssh`, or Docker socket unless you are intentionally weakening the boundary. +- If you need a stronger boundary than this, use a disposable VM instead of Docker. diff --git a/docker-compose.untrusted-review.yml b/docker-compose.untrusted-review.yml new file mode 100644 index 00000000..ff11148a --- /dev/null +++ b/docker-compose.untrusted-review.yml @@ -0,0 +1,33 @@ +services: + review: + build: + context: . + dockerfile: docker/untrusted-review/Dockerfile + init: true + tty: true + stdin_open: true + working_dir: /work + environment: + HOME: "/home/reviewer" + CODEX_HOME: "/home/reviewer/.codex" + CLAUDE_HOME: "/home/reviewer/.claude" + PAPERCLIP_HOME: "/home/reviewer/.paperclip-review" + OPENAI_API_KEY: "${OPENAI_API_KEY:-}" + ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}" + GITHUB_TOKEN: "${GITHUB_TOKEN:-}" + ports: + - "${REVIEW_PAPERCLIP_PORT:-3100}:3100" + - "${REVIEW_VITE_PORT:-5173}:5173" + volumes: + - review-home:/home/reviewer + - review-work:/work + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + tmpfs: + - /tmp:mode=1777,size=1g + +volumes: + review-home: + review-work: diff --git a/docker/untrusted-review/Dockerfile b/docker/untrusted-review/Dockerfile new file mode 100644 index 00000000..c8b1f432 --- /dev/null +++ b/docker/untrusted-review/Dockerfile @@ -0,0 +1,44 @@ +FROM node:lts-trixie-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + fd-find \ + gh \ + git \ + jq \ + less \ + openssh-client \ + procps \ + ripgrep \ + && rm -rf /var/lib/apt/lists/* + +RUN ln -sf /usr/bin/fdfind /usr/local/bin/fd + +RUN corepack enable \ + && npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest + +RUN useradd --create-home --shell /bin/bash reviewer + +ENV HOME=/home/reviewer \ + CODEX_HOME=/home/reviewer/.codex \ + CLAUDE_HOME=/home/reviewer/.claude \ + PAPERCLIP_HOME=/home/reviewer/.paperclip-review \ + PNPM_HOME=/home/reviewer/.local/share/pnpm \ + PATH=/home/reviewer/.local/share/pnpm:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +WORKDIR /work + +COPY --chown=reviewer:reviewer docker/untrusted-review/bin/review-checkout-pr /usr/local/bin/review-checkout-pr + +RUN chmod +x /usr/local/bin/review-checkout-pr \ + && mkdir -p /work \ + && chown -R reviewer:reviewer /work + +USER reviewer + +EXPOSE 3100 5173 + +CMD ["bash", "-l"] diff --git a/docker/untrusted-review/bin/review-checkout-pr b/docker/untrusted-review/bin/review-checkout-pr new file mode 100644 index 00000000..abca98ad --- /dev/null +++ b/docker/untrusted-review/bin/review-checkout-pr @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: review-checkout-pr [checkout-dir] + +Examples: + review-checkout-pr paperclipai/paperclip 432 + review-checkout-pr https://github.com/paperclipai/paperclip.git 432 +EOF +} + +if [[ $# -lt 2 || $# -gt 3 ]]; then + usage >&2 + exit 1 +fi + +normalize_repo_slug() { + local raw="$1" + raw="${raw#git@github.com:}" + raw="${raw#ssh://git@github.com/}" + raw="${raw#https://github.com/}" + raw="${raw#http://github.com/}" + raw="${raw%.git}" + printf '%s\n' "${raw#/}" +} + +repo_slug="$(normalize_repo_slug "$1")" +pr_number="$2" + +if [[ ! "$repo_slug" =~ ^[^/]+/[^/]+$ ]]; then + echo "Expected GitHub repo slug like owner/repo or a GitHub repo URL, got: $1" >&2 + exit 1 +fi + +if [[ ! "$pr_number" =~ ^[0-9]+$ ]]; then + echo "PR number must be numeric, got: $pr_number" >&2 + exit 1 +fi + +repo_key="${repo_slug//\//-}" +mirror_dir="/work/repos/${repo_key}" +checkout_dir="${3:-/work/checkouts/${repo_key}/pr-${pr_number}}" +pr_ref="refs/remotes/origin/pr/${pr_number}" + +mkdir -p "$(dirname "$mirror_dir")" "$(dirname "$checkout_dir")" + +if [[ ! -d "$mirror_dir/.git" ]]; then + if command -v gh >/dev/null 2>&1; then + gh repo clone "$repo_slug" "$mirror_dir" -- --filter=blob:none + else + git clone --filter=blob:none "https://github.com/${repo_slug}.git" "$mirror_dir" + fi +fi + +git -C "$mirror_dir" fetch --force origin "pull/${pr_number}/head:${pr_ref}" + +if [[ -e "$checkout_dir" ]]; then + printf '%s\n' "$checkout_dir" + exit 0 +fi + +git -C "$mirror_dir" worktree add --detach "$checkout_dir" "$pr_ref" >/dev/null +printf '%s\n' "$checkout_dir"