From 0abc2256e04bd47e553023b205d1806f47712b04 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Tue, 23 Jun 2026 17:19:34 -0400 Subject: [PATCH 1/3] feat(devcontainer): move untrusted installs to build time, firewall via root entrypoint Collapse the highly-trusted setup window. Previously post-create.sh ran a lot of networked installs (pixi, npm, Playwright browsers, the Claude curl|bash installer, Codex) while it had full network, passwordless sudo, and the credentials mounted, before the firewall came up. This restructures setup so no phase is simultaneously networked, credentialed, privileged, and unfirewalled. - Dockerfile: install Node (build-only), the Claude and Codex CLIs, and the pinned Playwright browser (chromium, v1.56.1) at build time -- where no credentials are mounted, so a poisoned dependency can't exfiltrate tokens. Also install bubblewrap + socat. Bake "no sudo for vscode" into the image (rm /etc/sudoers.d/vscode). Add a root ENTRYPOINT + CMD. - entrypoint.sh (new): runs as root at container start, fixes the .pixi volume ownership if needed, brings up the egress firewall (fail-closed), signals readiness, then execs the long-running command. - post-create.sh: now runs as unprivileged vscode AFTER the firewall is up (waits on the readiness flag). Only pixi install --locked, dev-install, and UI-test JS deps remain -- all hitting allowlisted endpoints only. No sudo, no non-allowlisted CDN access. - devcontainer.json: overrideCommand=false so our ENTRYPOINT/CMD run; containerUser=root (entrypoint) with remoteUser=vscode (lifecycle/shells); PLAYWRIGHT_BROWSERS_PATH for the baked-in browser. Co-Authored-By: Claude Opus 4.8 (1M context) --- .devcontainer/Dockerfile | 68 +++++++++++++++++++++------------ .devcontainer/devcontainer.json | 8 +++- .devcontainer/entrypoint.sh | 31 +++++++++++++++ .devcontainer/post-create.sh | 63 ++++++++++++------------------ 4 files changed, 105 insertions(+), 65 deletions(-) create mode 100755 .devcontainer/entrypoint.sh diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 34302a762..f26dca9d6 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,6 +1,7 @@ FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04 -# Install firewall dependencies and pixi +# System packages: firewall tooling (iptables/ipset/dns/jq/aggregate), curl, and +# the sandbox helpers bubblewrap + socat. RUN apt-get update && apt-get install -y --no-install-recommends \ iptables \ ipset \ @@ -8,35 +9,52 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ jq \ aggregate \ curl \ + ca-certificates \ + gnupg \ + bubblewrap \ + socat \ && rm -rf /var/lib/apt/lists/* -# Install Playwright browser dependencies -RUN apt-get update && apt-get install -y --no-install-recommends \ - libglib2.0-0t64 \ - libnspr4 \ - libnss3 \ - libdbus-1-3 \ - libatk1.0-0t64 \ - libatk-bridge2.0-0t64 \ - libcups2t64 \ - libxcb1 \ - libxkbcommon0 \ - libatspi2.0-0t64 \ - libx11-6 \ - libxcomposite1 \ - libxdamage1 \ - libxext6 \ - libxfixes3 \ - libxrandr2 \ - libgbm1 \ - libcairo2 \ - libpango-1.0-0 \ - libasound2t64 \ +# Node.js, build-time only: needed to install the Codex CLI and the Playwright +# browser at image-build time (when no credentials are mounted). The project +# itself uses pixi's own Node for builds at runtime. +RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ && rm -rf /var/lib/apt/lists/* # Install pixi system-wide RUN curl -fsSL https://pixi.sh/install.sh | PIXI_HOME=/usr/local bash -# Copy firewall init script +# Playwright browser + OS deps, pinned to the project's version, in a shared path +# readable by the vscode user. The config defines no projects, so only chromium +# is needed. Done at build time so the runtime (firewalled) phase never needs the +# dynamic Playwright CDN. +ENV PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright +RUN npx --yes playwright@1.56.1 install --with-deps chromium \ + && rm -rf /var/lib/apt/lists/* + +# Codex CLI (global). Build-time install -> no credentials present to leak. +RUN npm install -g @openai/codex + +# Claude Code (native installer) as the vscode user so it lands in +# /home/vscode/.local/bin and matches installMethod "native". +USER vscode +RUN curl -fsSL https://claude.ai/install.sh | HOME=/home/vscode bash +USER root + +# Firewall script + root entrypoint that brings it up before any workload runs. COPY init-firewall.sh /usr/local/bin/init-firewall.sh -RUN chmod +x /usr/local/bin/init-firewall.sh +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/init-firewall.sh /usr/local/bin/entrypoint.sh + +# Bake "no sudo for vscode" into the image: the agent runs unprivileged and can +# never escalate or modify the firewall. Privileged setup (firewall init, volume +# chown) happens in the root entrypoint instead. For maintenance, exec as root +# from the host: `podman exec -u root bash`. +RUN rm -f /etc/sudoers.d/vscode + +# Start as root so the entrypoint can set up the firewall, then exec the +# long-running command. The devcontainers CLI execs all lifecycle commands and +# shells as the unprivileged remoteUser (vscode). +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD ["sleep", "infinity"] diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b973676f2..f20fcae34 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -40,8 +40,14 @@ "remoteEnv": { "NODE_OPTIONS": "--max-old-space-size=2048", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", - "CLAUDE_CODE_DISABLE_ANALYTICS": "1" + "CLAUDE_CODE_DISABLE_ANALYTICS": "1", + "PLAYWRIGHT_BROWSERS_PATH": "/opt/ms-playwright" }, "postCreateCommand": "bash .devcontainer/post-create.sh", + // Start the container as root so the entrypoint can initialize the firewall + // and fix volume ownership; all lifecycle commands and shells still run as the + // unprivileged vscode user. overrideCommand=false lets our ENTRYPOINT/CMD run. + "overrideCommand": false, + "containerUser": "root", "remoteUser": "vscode" } diff --git a/.devcontainer/entrypoint.sh b/.devcontainer/entrypoint.sh new file mode 100755 index 000000000..0280113bf --- /dev/null +++ b/.devcontainer/entrypoint.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Root entrypoint: runs at container start, BEFORE any workload or agent. Brings +# up the egress firewall and fixes volume ownership, then execs the long-running +# command. The devcontainers CLI execs all lifecycle commands and shells as the +# unprivileged vscode user, which has no sudo (removed in the image). +set -euo pipefail + +WORKSPACE="/workspaces/fileglancer" +READY_FLAG="/run/fg-firewall-ready" + +rm -f "$READY_FLAG" + +# Fix ownership of the mounted .pixi volume if it isn't already the dev user's. +# On rootless Podman (keep-id) it's already 1000:1000 -> no-op. On Docker/Colima +# named volumes are created root-owned, so chown them to vscode. +if [ -d "$WORKSPACE/.pixi" ] && [ "$(stat -c %u "$WORKSPACE/.pixi")" != "1000" ]; then + echo "entrypoint: fixing ownership of $WORKSPACE/.pixi" + chown -R 1000:1000 "$WORKSPACE/.pixi" || true +fi + +# Bring up the egress allowlist firewall before the agent can run. Fail closed: +# if firewall setup fails, the entrypoint exits and the container does not start. +echo "entrypoint: initializing egress firewall" +/usr/local/bin/init-firewall.sh + +# Signal readiness so post-create (run by the CLI as vscode) waits for the +# firewall before doing any network access. +touch "$READY_FLAG" + +echo "entrypoint: setup complete, starting: $*" +exec "$@" diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 332af2510..badaeb9fa 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -1,50 +1,35 @@ #!/bin/bash set -e -# Fix ownership of .pixi volume (created as root by Docker) -sudo chown -R "$(id -u):$(id -g)" .pixi 2>/dev/null || true +# This phase runs as the unprivileged vscode user, AFTER the root entrypoint has +# brought up the egress firewall. It has NO sudo and NO access to non-allowlisted +# CDNs: Claude, Codex, the Playwright browser, and the firewall are all handled +# at image-build / entrypoint time. Only steps that need the mounted workspace +# remain, and they hit only allowlisted endpoints (pypi/conda/prefix.dev/npm). + +# Wait for the entrypoint's firewall to be in place before any network access. +echo "Waiting for egress firewall to come up..." +for _ in $(seq 1 60); do + [ -f /run/fg-firewall-ready ] && break + sleep 1 +done +if [ ! -f /run/fg-firewall-ready ]; then + echo "ERROR: firewall readiness flag not found; aborting setup" >&2 + exit 1 +fi -# Initialize pixi environment and install package dependencies -echo "Installing pixi environment..." -pixi install +# Install the pixi environment from the lockfile (no resolution drift). +echo "Installing pixi environment (locked)..." +pixi install --locked -# Install fileglancer in development mode (builds frontend + installs Python package) -echo "Running dev-install (this builds frontend and installs the package)..." +# Build the frontend and install the Python package in development mode. +echo "Running dev-install (frontend build + Python package)..." pixi run dev-install -# Install Playwright browsers for UI tests (before firewall, as CDN IPs are dynamic) -echo "Installing Playwright browsers..." +# Install UI-test JS deps (the @playwright/test package). The matching browser +# is already baked into the image at PLAYWRIGHT_BROWSERS_PATH. +echo "Installing UI-test dependencies..." pixi run node-install-ui-tests -cd frontend/ui-tests && pixi run npx playwright install - -# Install Claude Code via the native installer (before firewall, since claude.ai -# CDN isn't in the allowlist). Installs to ~/.local/bin/claude, matching the -# "installMethod: native" recorded in the bind-mounted ~/.claude.json from the host. -if ! [ -x "$HOME/.local/bin/claude" ]; then - echo "Installing Claude Code (native)..." - curl -fsSL https://claude.ai/install.sh | bash -fi - -# Initialize network firewall (restricts outbound to allowed domains) -# This must happen AFTER Playwright and Claude installs since their CDN IPs are dynamic -echo "Initializing network firewall..." -sudo /usr/local/bin/init-firewall.sh - -# Install Codex CLI globally via npm (provided by pixi) -if ! command -v codex &> /dev/null; then - echo "Installing Codex CLI..." - pixi run npm install -g @openai/codex -fi - -# Lock down the firewall: now that setup is complete, revoke the vscode user's -# passwordless sudo. The agent runs as unprivileged vscode, which cannot touch -# iptables/ipset without root, so the egress allowlist can no longer be flushed -# or bypassed from inside the container. NET_ADMIN/NET_RAW remain in the image -# but are unusable without root, so this neutralizes them for the agent. -# For maintenance you can still get a root shell from the HOST side: -# podman exec -u root bash (or: docker exec -u root ...) -echo "Revoking in-container sudo to lock the firewall..." -sudo rm -f /etc/sudoers.d/vscode echo "" echo "==========================================" From 87cb06365d38fda9b70e2f33434ff5e29cc7e31a Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Tue, 23 Jun 2026 17:46:29 -0400 Subject: [PATCH 2/3] fix(devcontainer): keep pixi's cache on the xfs .pixi volume The container rootfs (~/.cache) is fuse-overlayfs under rootless Podman, which pixi flags as a network filesystem and redirects per-run to an ephemeral /tmp dir (emitting a warning and losing the cache across rebuilds). Point PIXI_CACHE_DIR at the local xfs .pixi volume via containerEnv so it applies to every container process (entrypoint, postCreate, and all exec sessions); the cache is now quiet and persistent. Co-Authored-By: Claude Opus 4.8 (1M context) --- .devcontainer/devcontainer.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f20fcae34..23bcd5bb8 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -43,6 +43,14 @@ "CLAUDE_CODE_DISABLE_ANALYTICS": "1", "PLAYWRIGHT_BROWSERS_PATH": "/opt/ms-playwright" }, + "containerEnv": { + // Keep pixi's cache on the xfs .pixi volume so it applies to every process + // in the container (entrypoint, postCreate, and all exec sessions). The + // container rootfs (~/.cache) is fuse-overlayfs under rootless Podman, which + // pixi flags as a network FS and redirects per-run; the .pixi volume is + // local xfs and persists. ${containerWorkspaceFolder} = /workspaces/fileglancer. + "PIXI_CACHE_DIR": "${containerWorkspaceFolder}/.pixi/.pixi-cache" + }, "postCreateCommand": "bash .devcontainer/post-create.sh", // Start the container as root so the entrypoint can initialize the firewall // and fix volume ownership; all lifecycle commands and shells still run as the From a1d02f4497a07ef6964b5989d912058636277076 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Tue, 23 Jun 2026 17:53:40 -0400 Subject: [PATCH 3/3] docs(devcontainer): document rootless Podman, security model, and per-machine setup Rewrite docs/DevContainer.md to cover both the rootless-Podman support and the build-time/firewall hardening: - Runtime selection via FG_CONTAINER_RUNTIME and how dc.sh uses it. - Linux rootless Podman setup: podman + deps, subuid/subgid, the ~/.bashrc exports (FG_CONTAINER_RUNTIME=podman, PIXI_CACHE_DIR on local scratch), optional scratch-backed storage.conf, and ipset module persistence. - Security model: unprivileged agent (no sudo) with host root-shell for maintenance, credential-free build-time installs, root entrypoint that brings up the firewall first, the tamper-proof firewall (pinned DNS, no blanket port 22, no host subnet), and the keep-id credential mapping. - Container vs host pixi cache (PIXI_CACHE_DIR) and the baked-in Playwright browser. - Architecture of the .devcontainer/ files; updated GPU section for Podman CDI. Dropped the outdated standalone-CLI section. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/DevContainer.md | 292 ++++++++++++++++++++++++++----------------- 1 file changed, 176 insertions(+), 116 deletions(-) diff --git a/docs/DevContainer.md b/docs/DevContainer.md index d409c7286..62b2f19d0 100644 --- a/docs/DevContainer.md +++ b/docs/DevContainer.md @@ -1,30 +1,26 @@ # Dev Container Usage -The devcontainer provides a complete development environment for Fileglancer with Python 3.14, Node.js, and pixi, configured to run Claude Code and Codex with network isolation. +The devcontainer provides a complete development environment for Fileglancer with Python, Node.js, and pixi, configured to run Claude Code and Codex as an unprivileged user under network isolation. -## Prerequisites +The same `devcontainer.json` works with two container runtimes: **Docker/Colima** (the default, used on macOS) and **rootless Podman** (used on Linux for a stronger, daemon-less security posture). The runtime is selected per machine via the `FG_CONTAINER_RUNTIME` environment variable. -- Container runtime (see platform-specific setup below) -- Pixi (provides Node.js for the devcontainer CLI) -- Claude Code configuration at `~/.claude` (API keys and auth) -- Codex configuration at `~/.codex` (config and auth) +## How runtime selection works -### Linux (Docker) +The `container-*` pixi tasks call `.devcontainer/dc.sh`, a thin wrapper around the devcontainers CLI. It passes `--docker-path "$FG_CONTAINER_RUNTIME"` (default: `docker`), so: -Install Docker using your distribution's package manager or [Docker's official instructions](https://docs.docker.com/engine/install/). +- On macOS, leave `FG_CONTAINER_RUNTIME` unset — the tasks use Docker (typically backed by Colima). +- On Linux, set `FG_CONTAINER_RUNTIME=podman` — the tasks use rootless Podman, and `dc.sh` additionally routes Podman through a small shim and disables the CLI's uid-renumber step (see [Architecture](#architecture)). -Post-installation steps: -```bash -# Add yourself to the docker group -sudo usermod -aG docker $USER +Nothing in `devcontainer.json` is runtime-specific, so the same project config works on both. -# Log out and back in, or run: -newgrp docker +## Prerequisites -# Enable Docker on startup -sudo systemctl enable docker.service -sudo systemctl enable containerd.service -``` +- A container runtime (see platform-specific setup below) +- Pixi (provides Node.js for the devcontainer CLI) +- Claude Code configuration at `~/.claude` (auth) and `~/.claude.json` +- Codex configuration at `~/.codex` (config and auth) + +## Platform setup ### macOS (Colima) @@ -41,6 +37,8 @@ colima start --cpu 4 --memory 8 --disk 60 docker ps ``` +No `FG_CONTAINER_RUNTIME` is needed on macOS — the default (`docker`) targets Colima. + #### Manual vs Auto-Start | Aspect | `colima start` | `brew services start colima` | @@ -50,62 +48,166 @@ docker ps | **Battery/memory** | Saves resources when not developing | Constant background overhead | | **Startup** | Manual, ~10-20 seconds | Automatic on login | -**Recommendation:** Use `colima start` directly unless you use containers daily. Start when needed, stop when done: +**Recommendation:** Use `colima start` directly unless you use containers daily. Start when needed, stop when done (`colima stop`). If you prefer auto-start, use `brew services start colima`. + +To save your preferred settings so you don't need flags each time, create `~/.colima/default/colima.yaml`: + +```yaml +cpu: 4 +memory: 8 +disk: 60 +``` + +### Linux (rootless Podman) — recommended + +Rootless Podman runs the container without a root-owned daemon, so a container/agent compromise can at most act as your own user rather than host root. This is the recommended Linux setup. + +#### 1. Install Podman and rootless dependencies (sudo) ```bash -colima stop +sudo dnf install -y podman fuse-overlayfs slirp4netns containernetworking-plugins +# Debian/Ubuntu: sudo apt-get install -y podman fuse-overlayfs slirp4netns containernetworking-plugins ``` -If you prefer auto-start (for frequent container use): +#### 2. Grant a subordinate UID/GID range (sudo) + +Rootless Podman needs subuid/subgid ranges to map the container's `vscode` user. Check first: ```bash -brew services start colima +grep "^$(whoami):" /etc/subuid /etc/subgid ``` -To save your preferred settings so you don't need flags each time, create `~/.colima/default/colima.yaml`: +If there are no entries, add a small range. For a local account use `usermod`; for an LDAP/NIS account (not in local `/etc/passwd`) append directly: -```yaml -cpu: 4 -memory: 8 -disk: 60 +```bash +echo "$(whoami):100000:65536" | sudo tee -a /etc/subuid +echo "$(whoami):100000:65536" | sudo tee -a /etc/subgid +podman system migrate # pick up the new ranges ``` -## Security Features +Keep this range small (65536). Do not enlarge it to cover your host UID — on a shared host with high LDAP UIDs that would overlap real users' UIDs. The setup deliberately avoids needing that (see [Architecture](#architecture)). -### Network Firewall +#### 3. Per-user shell settings -The container uses iptables to restrict outbound network access to allowed domains only: +Add these to your `~/.bashrc` (Linux host only — leave both unset on macOS): -- **Anthropic**: api.anthropic.com, statsig.anthropic.com, sentry.io -- **OpenAI**: api.openai.com -- **GitHub**: All GitHub IP ranges (fetched from api.github.com/meta) -- **npm**: registry.npmjs.org -- **Python/Pixi/Conda**: pypi.org, files.pythonhosted.org, conda.anaconda.org, conda-mapping.prefix.dev, prefix.dev, repo.prefix.dev -- **VS Code**: marketplace.visualstudio.com, vscode.blob.core.windows.net +```bash +# Select rootless Podman for the container-* pixi tasks +export FG_CONTAINER_RUNTIME=podman + +# Keep the HOST pixi's cache off NFS. Required only if your home directory is on +# a network filesystem (NFS/SMB/etc.); pixi otherwise redirects its cache per-run +# and prints a warning. Point it at fast local storage instead. +export PIXI_CACHE_DIR=/scratch/$USER/pixi-cache +``` + +`FG_CONTAINER_RUNTIME=podman` makes `pixi run container-*` use Podman. `PIXI_CACHE_DIR` fixes the **host** pixi (the one that runs the `container-*` tasks); the **container** pixi is handled separately in `devcontainer.json` (see [pixi cache](#pixi-cache)). + +#### 4. Podman storage on network-home machines (optional) -To add more allowed domains, edit `.devcontainer/init-firewall.sh`. +If your home directory is on a network filesystem, point Podman's storage at fast local storage so image/layer operations are not slow. Create `~/.config/containers/storage.conf`: + +```toml +[storage] +driver = "overlay" +runroot = "/scratch/$USER/podman-run" +graphroot = "/scratch/$USER/podman-storage" + +[storage.options] +mount_program = "/usr/bin/fuse-overlayfs" +``` + +Make sure the target directory (e.g. `/scratch/$USER`) exists and is writable. On a machine with a roomy local home you can skip this and use Podman's defaults. + +#### 5. (Optional) Persist ipset kernel modules + +The firewall uses `ipset`. The modules are usually already loaded, but this ensures they survive a reboot (otherwise the firewall fails to initialize after a reboot): -To disable the firewall at runtime: ```bash -sudo iptables -F && sudo iptables -P INPUT ACCEPT && sudo iptables -P OUTPUT ACCEPT +printf 'ip_set\nxt_set\nip_set_hash_net\n' | sudo tee /etc/modules-load.d/ipset.conf ``` -To skip it entirely on container startup, comment out the firewall line in `.devcontainer/post-create.sh` and rebuild. +### Linux (Docker) — alternative -### Privacy Settings +If you prefer Docker on Linux, install it per [Docker's instructions](https://docs.docker.com/engine/install/), add yourself to the `docker` group, and leave `FG_CONTAINER_RUNTIME` unset. Note this uses a root-owned daemon and does not get the rootless isolation benefits above. -The container sets environment variables to disable telemetry: -- `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` -- `CLAUDE_CODE_DISABLE_ANALYTICS=1` +## Security model -## Host Mounts +The agent (`claude`/`codex`) runs with its approval gates disabled, so the container itself is the sandbox. The setup is structured so that no phase is simultaneously networked, credentialed, privileged, and unfirewalled. -The container mounts these directories from the host: -- `~/.claude` - Claude Code API keys and authentication -- `~/.codex` - Codex config and authentication +### Unprivileged agent (no sudo) + +The image removes the `vscode` user's passwordless sudo (`/etc/sudoers.d/vscode`). The agent runs as the unprivileged `vscode` user and therefore cannot modify the firewall, escalate, or alter root-owned files. All privileged setup happens in a root entrypoint instead (below). + +For maintenance you can still get a root shell from the **host**: + +```bash +podman exec -u root bash # rootless Podman +docker exec -u root bash # Docker/Colima +``` + +### Build-time installs (credential-free) + +Node, the Claude and Codex CLIs, and the pinned Playwright browser are installed at **image build time**, when no credentials are mounted. A poisoned dependency during these installs therefore cannot read your tokens. Only steps that need the mounted workspace remain at runtime, and they hit allowlisted endpoints only. + +### Root entrypoint brings up the firewall first + +The container starts as root and runs `.devcontainer/entrypoint.sh`, which fixes the `.pixi` volume ownership if needed, initializes the egress firewall (fail-closed — if it fails, the container does not start), signals readiness, and then drops to the long-running command. All lifecycle commands and shells run as the unprivileged `vscode` user. The `postCreate` step waits for the firewall to be up before doing any network access. + +### Network firewall (tamper-proof) + +`iptables` restricts outbound access to an allowlist (default-DROP otherwise): + +- **Anthropic**: api.anthropic.com, statsig.anthropic.com, statsig.com, sentry.io +- **OpenAI**: api.openai.com, chatgpt.com +- **GitHub**: all GitHub IP ranges (fetched from api.github.com/meta) +- **npm**: registry.npmjs.org +- **Python/Pixi/Conda**: pypi.org, files.pythonhosted.org, conda.anaconda.org, conda-mapping.prefix.dev, prefix.dev, repo.prefix.dev +- **VS Code**: marketplace.visualstudio.com, vscode.blob.core.windows.net, update.code.visualstudio.com +- **Fileglancer**: fileglancer.int.janelia.org, s3.janelia.org, neuroglancer-demo.appspot.com + +Hardening details: +- **DNS** is allowed only to the resolvers in `/etc/resolv.conf`, not to any host (closes arbitrary-resolver tunneling). +- **No blanket outbound SSH**: there is no "port 22 to anywhere" rule. SSH to GitHub still works because GitHub's ranges are in the allowlist; SSH to other hosts is blocked. +- **No host-subnet allow**: the local `/24` is not opened, preventing lateral movement to neighboring machines. +- Because `vscode` has no sudo, the agent cannot flush or weaken these rules from inside the container. + +To add allowed domains, edit `.devcontainer/init-firewall.sh` and rebuild. To disable the firewall (maintenance), comment out the `init-firewall.sh` call in `.devcontainer/entrypoint.sh` and rebuild, or flush it from a host root shell (`podman exec -u root iptables -F`). + +### Credential mapping (rootless Podman) + +The devcontainers CLI forces `--userns=keep-id`, which would map your host user to the same UID inside the container rather than to `vscode`. The Podman shim upgrades this to `keep-id:uid=1000,gid=1000` so the host user maps onto the container's `vscode` user (UID 1000), making the bind-mounted credentials readable/writable using only the small subuid range — no UID renumbering required. + +### Privacy settings + +The container disables telemetry via `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` and `CLAUDE_CODE_DISABLE_ANALYTICS=1`. + +## Host mounts + +The container mounts these from the host: + +- `~/.claude` — Claude Code config and auth (read-write) +- `~/.claude.json` — Claude Code state (read-write) +- `~/.codex` — Codex config and auth (read-write) +- `~/.gitconfig` — git config (read-only) +- the `fileglancer-pixi` named volume at `/.pixi` + +## Environment and caches + +### pixi cache + +The container rootfs (`~/.cache`) is fuse-overlayfs under rootless Podman, which pixi flags as a network filesystem and redirects per-run (with a warning, and losing the cache across rebuilds). `devcontainer.json` sets `PIXI_CACHE_DIR` (via `containerEnv`) to `/.pixi/.pixi-cache`, which is on the local xfs `.pixi` volume — so the in-container pixi cache is quiet and persistent. + +This is separate from the **host** `PIXI_CACHE_DIR` shell setting above: the two pixis run on different machines/filesystems and are configured independently. + +### Playwright + +The chromium browser is baked into the image at `/opt/ms-playwright` (pinned to the project's `@playwright/test` version), and `PLAYWRIGHT_BROWSERS_PATH` points there. UI tests run without downloading browsers at runtime. ## Quick Start +On Linux, ensure `FG_CONTAINER_RUNTIME=podman` is exported first (see [Linux setup](#linux-rootless-podman--recommended)). + ```bash # Build and start the container (rebuilds from scratch) pixi run container-rebuild @@ -113,10 +215,10 @@ pixi run container-rebuild # Get a shell inside the container pixi run container-shell -# Or run Claude Code directly +# Run Claude Code directly pixi run container-claude -# Or run Codex directly +# Run Codex directly pixi run container-codex ``` @@ -140,41 +242,16 @@ pixi run container-shell pixi run dev-launch # Starts on port 7878 ``` -### Run Claude Code - -```bash -# Run Claude Code directly in the container -pixi run container-claude - -# Or from inside a container shell -pixi run container-shell -pixi run claude --permission-mode auto -``` - -### Run Codex - -```bash -# Run Codex directly in the container -pixi run container-codex - -# Or from inside a container shell -pixi run container-shell -pixi run codex --full-auto -``` - ### Stop the Container ```bash -# Find the container ID -docker ps | grep fileglancer - -# Stop it -docker stop +podman ps | grep fileglancer # or: docker ps | grep fileglancer +podman stop # or: docker stop ``` ### Rebuild from Scratch -Use this after modifying Dockerfile or devcontainer.json: +Use this after modifying the Dockerfile, devcontainer.json, or the firewall/entrypoint scripts: ```bash pixi run container-rebuild @@ -190,47 +267,29 @@ pixi run container-rebuild | `pixi run dev-watch` | Watch frontend for changes | | `pixi run test-backend` | Run Python tests with coverage | | `pixi run test-frontend` | Run frontend tests | +| `pixi run test-ui` | Run Playwright E2E tests (browser is pre-installed) | | `pixi run node-check` | TypeScript type checking | -| `pixi run node-eslint-check` | Run ESLint | -| `pixi run node-prettier-check` | Run Prettier check | | `claude` | Claude Code CLI | | `codex` | Codex CLI | +Note: `sudo` is intentionally unavailable inside the container; use a host root shell for any privileged maintenance (see [Unprivileged agent](#unprivileged-agent-no-sudo)). + ## VS Code / Cursor -You can also open the project in VS Code or Cursor and use the "Reopen in Container" command for a GUI-based experience. +You can also open the project in VS Code or Cursor and use the "Reopen in Container" command for a GUI-based experience. On Linux with Podman, configure the editor's Dev Containers extension to use the `podman` path (Dev Containers: "Docker Path" / `dev.containers.dockerPath` = `podman`). -## Standalone CLI (without devcontainer) +## Architecture -You can run Claude Code or Codex in a container without using the devcontainer CLI: +Relevant files under `.devcontainer/`: -```bash -# Build the image (from repo root) -docker build -t fileglancer-dev .devcontainer/ - -# Run interactively -docker run \ - --cap-add=NET_ADMIN \ - --cap-add=NET_RAW \ - -v ~/.claude:/home/vscode/.claude \ - -v ~/.codex:/home/vscode/.codex \ - -v "$(pwd)":/workspace \ - -w /workspace \ - -e NODE_OPTIONS="--max-old-space-size=4096" \ - -e CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ - -e CLAUDE_CODE_DISABLE_ANALYTICS=1 \ - -p 7878:7878 \ - -it fileglancer-dev bash - -# Inside the container, initialize firewall and set up the project -sudo /usr/local/bin/init-firewall.sh -pixi install -pixi run dev-install -pixi run npm install -g @anthropic-ai/claude-code -pixi run npm install -g @openai/codex -pixi run claude --permission-mode auto -pixi run codex --full-auto -``` +- **`dc.sh`** — wrapper that selects the runtime from `FG_CONTAINER_RUNTIME` (default `docker`) and passes `--docker-path` to the devcontainers CLI. On the Podman path it routes through `podman-shim/podman` and adds `--update-remote-user-uid-default never`. +- **`podman-shim/podman`** — a shim (named `podman` so the CLI still detects Podman) that rewrites the CLI's forced `--userns=keep-id` to `--userns=keep-id:uid=1000,gid=1000`, mapping the host user onto `vscode`. Used only on the Podman path; Docker/Colima never sees it. +- **`Dockerfile`** — installs system packages, bubblewrap/socat, Node (build-time), the Claude/Codex CLIs, and the pinned Playwright browser; removes `vscode`'s sudo; sets the root entrypoint. +- **`entrypoint.sh`** — root entrypoint: fixes volume ownership, brings up the firewall, signals readiness, execs the long-running command. +- **`init-firewall.sh`** — builds the egress allowlist (run by the entrypoint as root). +- **`post-create.sh`** — runs as `vscode` after the firewall is up: `pixi install --locked`, `dev-install`, UI-test deps. No sudo, no non-allowlisted CDN access. + +`devcontainer.json` sets `overrideCommand: false` (so the image ENTRYPOINT/CMD run), `containerUser: root` (for the entrypoint), and `remoteUser: vscode` (for lifecycle commands and shells). ## GPU Support (Linux only) @@ -239,27 +298,29 @@ To enable GPU passthrough for CUDA workloads: ### 1. Install nvidia-container-toolkit ```bash -# Add NVIDIA repository curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \ sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \ sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list -# Install sudo apt-get update sudo apt-get install -y nvidia-container-toolkit ``` -### 2. Configure Docker runtime +### 2. Configure the runtime ```bash +# Docker sudo nvidia-ctk runtime configure --runtime=docker sudo systemctl restart docker + +# Podman (rootless): nvidia-ctk supports CDI; generate a CDI spec +sudo nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml ``` ### 3. Enable GPU in devcontainer -Edit `.devcontainer/devcontainer.json` to add `--gpus all` to `runArgs`: +Edit `.devcontainer/devcontainer.json` to add the GPU flag to `runArgs` (`--gpus all` for Docker, or `--device nvidia.com/gpu=all` for Podman CDI): ```json "runArgs": [ @@ -274,6 +335,5 @@ Edit `.devcontainer/devcontainer.json` to add `--gpus all` to `runArgs`: After rebuilding the container: ```bash -# Inside the container -nvidia-smi +nvidia-smi # inside the container ```