@bongos/core 1.19.1049 → 1.19.1051

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,69 +0,0 @@
1
- # Example Dev Box — the OTB builder environment as code (ADR 0031 §3).
2
- # Base Ubuntu 24.04 ships Python 3.12 (matches CI's art-pipeline cert) + a non-root
3
- # `vscode` sudo user. Node 22 / gh / Claude Code arrive as version-pinned features
4
- # (devcontainer.json); this Dockerfile covers the OS bits features don't: firewall
5
- # tooling + the art pipeline's system/Python deps. Builds identically on a DO box
6
- # and a laptop (x64/arm64). Full design + reconciliations: ./README.md.
7
-
8
- # Pinned to the multi-arch (amd64+arm64) image-index digest, not just the mutable
9
- # tag, so an upstream tag mutation / supply-chain swap can't silently replace the
10
- # box foundation. devcontainer-lock.json pins the features; this pins the base.
11
- # Bump the digest deliberately when intentionally moving to a newer base.
12
- FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04@sha256:d94c97dd9cacf183d0a6fd12a8e87b526e9e928307674ae9c94139139c0c6eae
13
-
14
- ARG TZ="Etc/UTC"
15
- ENV TZ="${TZ}"
16
- ENV DEVCONTAINER=true
17
- # Persisted shell history (see the volume mount in devcontainer.json).
18
- ENV HISTFILE=/commandhistory/.bash_history
19
-
20
- # --- OS packages --------------------------------------------------------------
21
- # Firewall tooling (iptables/ipset/iproute2 + dnsutils for `dig`), jq for the
22
- # GitHub-meta parse, the Python 3.12 toolchain, and the sharp from-source net.
23
- RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
24
- && apt-get install -y --no-install-recommends \
25
- iptables ipset iproute2 dnsutils \
26
- jq curl ca-certificates \
27
- python3 python3-pip python3-venv python3-dev \
28
- build-essential pkg-config \
29
- && apt-get clean && rm -rf /var/lib/apt/lists/*
30
-
31
- # --- Python venv for the art pipeline (PEP 668 safe) --------------------------
32
- # Ubuntu 24.04 marks the system Python "externally managed", so a global
33
- # `pip install` is refused. A dedicated venv on PATH gives the art pipeline a
34
- # clean, writable 3.12 interpreter where `python3` "just works" — matching the
35
- # isolated interpreter CI gets from actions/setup-python. Deps themselves are
36
- # installed from the repo's pinned art/pipeline/requirements.txt at first-run
37
- # (post-create.sh), so the set stays manifest-driven and reproducible.
38
- ENV OTB_VENV=/opt/otb-venv
39
- RUN python3 -m venv "${OTB_VENV}" \
40
- && "${OTB_VENV}/bin/pip" install --no-cache-dir --upgrade pip \
41
- && chown -R vscode:vscode "${OTB_VENV}"
42
- # Prepend the venv to PATH for every shell + lifecycle command.
43
- ENV PATH="${OTB_VENV}/bin:${PATH}"
44
-
45
- # --- Firewall + health scripts ------------------------------------------------
46
- # NB the install path: the Claude Code feature ALSO ships its own
47
- # /usr/local/bin/init-firewall.sh, and features install AFTER this Dockerfile —
48
- # so installing ours at that path would be silently overwritten by the feature's
49
- # (which lacks our GDS/Gemini/PyPI allow-list). We install ours as
50
- # otb-init-firewall.sh and point postStart at it; the feature's copy sits unused.
51
- COPY init-firewall.sh /usr/local/bin/otb-init-firewall.sh
52
- COPY healthcheck.sh /usr/local/bin/healthcheck.sh
53
- RUN chmod +x /usr/local/bin/otb-init-firewall.sh /usr/local/bin/healthcheck.sh
54
-
55
- # Persisted-history dir owned by the runtime user.
56
- RUN mkdir -p /commandhistory && chown -R vscode:vscode /commandhistory
57
-
58
- # --- Build-time integrity assertion -------------------------------------------
59
- # Fail the BUILD (not some later mystery at runtime) if the toolchain the box
60
- # promises isn't actually present. This is half of "doctor.js as health check":
61
- # the image-level half, asserting the box was built right. The builder-level
62
- # half (session/auth/git) runs as scripts/gds/doctor.js at attach time.
63
- RUN /usr/local/bin/healthcheck.sh --build || \
64
- (echo "BUILD FAILED: toolchain integrity check did not pass" && exit 1)
65
-
66
- # Runtime health check for the DO-box-as-service case (#598): a parked/recreated
67
- # box can be probed for "is the toolchain intact" without a GDS session.
68
- HEALTHCHECK --interval=1m --timeout=15s --start-period=30s --retries=3 \
69
- CMD /usr/local/bin/healthcheck.sh || exit 1
@@ -1,73 +0,0 @@
1
- # The Example Dev Box (`.devcontainer/`)
2
-
3
- This folder is **the box defined as code** — [ADR 0031 §3](../docs/adr/0031-cloud-dev-environments-for-builders.md). It is the single environment every OTB builder runs Claude Code's *worker* in, whether they reach it from Claude Desktop → SSH, from the web → Remote Control, or locally via Docker Desktop. The same spec builds and runs identically on a DigitalOcean box and on a laptop.
4
-
5
- > Recall the mental model ([ADR 0031 "mental model"](../docs/adr/0031-cloud-dev-environments-for-builders.md), and [the onboarding primer](../docs/onboarding/primer.md)): the **screen** is your device, the **brain** is Anthropic's servers, and this is the **worker** — where the repo lives and the work happens.
6
-
7
- ## What's here
8
-
9
- | File | Role |
10
- |---|---|
11
- | `devcontainer.json` | The box spec: base build, version-pinned features (Node 22, GitHub CLI, Claude Code), firewall capabilities, persisted volumes, lifecycle hooks. |
12
- | `Dockerfile` | Ubuntu 24.04 base + the OS-level bits features don't cover: firewall tooling, the art-pipeline system deps, and a PEP-668-safe Python venv. |
13
- | `init-firewall.sh` | The **default-deny outbound firewall** (ADR 0031 §6). Runs at every container start. Allow-lists only the hosts the toolchain needs. |
14
- | `post-create.sh` | First-run provisioning: folds in `setup-builder.sh`, installs art deps, verifies the stack. |
15
- | `healthcheck.sh` | Image-level toolchain integrity check — the "is the box built right?" half of doctor-as-health-check. |
16
-
17
- ## The toolchain it guarantees
18
-
19
- - **Node 22** (`package.json` requires `>=22`) — via the `node` devcontainer feature.
20
- - **Python 3.12** — native to the Ubuntu 24.04 base, installed into a venv at `/opt/otb-venv`.
21
- - **sharp ^0.34** — the game/art native dep; prebuilt binaries on linux x64 **and** arm64, so no compile.
22
- - **The five art-pipeline libs** — Pillow, numpy, scipy, scikit-learn, certifi (from the pinned `art/pipeline/requirements.txt`).
23
- - **Claude Code + GitHub CLI + git** — via features.
24
-
25
- ## Lifecycle
26
-
27
- 1. **build** — Dockerfile lays down the OS deps + venv, then asserts the toolchain (`healthcheck.sh --build`) so a broken box fails the build, not a later mystery.
28
- 2. **postCreate** (once) — `post-create.sh`: brings up the **firewall first** (so the dependency installs below run behind default-deny — no open-egress window during provisioning), then `setup-builder.sh --no-auth --skip-keys` (toolchain check + `npm ci` + `.env.local`), then `pip install` the art deps into the venv, then verify sharp + the art libs load.
29
- 3. **postStart** (every start) — `sudo otb-init-firewall.sh` re-applies the default-deny egress firewall (iptables state resets each container start).
30
- 4. **postAttach** (every attach) — `node scripts/gds/doctor.js` runs the *builder* health check (session/auth/git/env). Non-fatal, so a fresh, not-yet-authed box still attaches.
31
-
32
- On first connect you finish two runtime steps the box can't do for you: authenticate the GDS CLI (`node scripts/gds/setup.js` or `/builder-setup`) and, if you run the art pipeline, drop a Google AI Studio key in `~/.config/otb/env` (a persisted volume — survives rebuilds).
33
-
34
- ## The firewall allow-list
35
-
36
- Default-deny outbound. Only these egress targets are permitted (keep it tight — every addition is attack surface):
37
-
38
- | Target | Why |
39
- |---|---|
40
- | Anthropic API + telemetry | the brain (tokens), Claude Code |
41
- | GitHub (published meta ranges) | `git` push/pull, `gh` |
42
- | npm registry | `npm ci` |
43
- | `example.com` + `staging.` | the GDS API (`/api/gds/*` CLI) |
44
- | `generativelanguage.googleapis.com` | the pixel-art pipeline (Gemini) |
45
- | `pypi.org` + `files.pythonhosted.org` | `pip install` the art deps |
46
-
47
- The firewall comes up **before the dependency installs** (in postCreate) and is **re-applied on every start** (postStart), so there is no open-egress window during provisioning — `npm ci` and `pip` run behind the allow-list (npm/PyPI/GitHub are on it). It **fails closed** by default: if it can't apply rules or verification fails, container start fails. The one documented escape hatch is `FIREWALL_OPTIONAL=1`, for a laptop Docker host that won't grant `NET_ADMIN` — there the builder is already root of their own machine, so egress lockdown is advisory (ADR 0031 §6 "honest limit"). Using it emits a loud, greppable `SECURITY AUDIT` line in the container logs. The DO box (#598) leaves it enforcing.
48
-
49
- ## Running it
50
-
51
- - **Laptop (Docker Desktop):** open the repo in VS Code → "Reopen in Container", or `devcontainer up --workspace-folder .`. Apple-silicon (arm64) and Intel (x64) both work — sharp ships prebuilt binaries for each, and `node_modules` is a container-isolated volume so the host's macOS binaries never collide with the container's Linux ones.
52
- - **DigitalOcean box:** the per-builder provisioning (#598) builds this same spec on the box and exposes it over SSH / `claude rc`. Nothing in this folder is DO-specific — that's the portability the ADR commits to.
53
-
54
- ### Known macOS-laptop caveat (does not affect the DO box)
55
-
56
- On a macOS Docker Desktop bind mount, VirtioFS can intermittently return `EDEADLK` ("Resource deadlock avoided") when reading repo files, especially while the host is heavily accessing the same folder. The symptom seen in testing was the postCreate **art-deps `pip install` failing** to read `art/pipeline/requirements.txt` (the box otherwise built fine — Node, Python, `sharp`, the firewall, and the runtime health check all passed). post-create.sh retries and is non-fatal, so the box still provisions. If art deps don't install: rebuild the container, or switch **Docker Desktop → Settings → General → file sharing to "gRPC FUSE"**. The DO box uses a native filesystem (a real clone, no bind mount), so this never occurs there.
57
-
58
- ## Deliberate reconciliations (where this differs from a naïve reading of the ADR)
59
-
60
- - **Python 3.12, not "3.10".** ADR 0031 §3 says "Python 3.10"; that's a floor. CI (`portability-smoke.yml`) certifies the art pipeline on **3.12**, so the box uses 3.12 to *reproduce* CI rather than approximate it. 3.12 satisfies the `>=3.10` requirement in `setup-builder.sh`.
61
- - **`sharp` "build deps" without system libvips.** The ADR says "sharp build deps." sharp ^0.34 uses prebuilt binaries with libvips **bundled**, so no compile happens on x64/arm64. We install `build-essential` + `pkg-config` as the from-source safety net the ADR intends, but deliberately do **not** install system `libvips-dev` — a mismatched system libvips is a known footgun; sharp prefers its own.
62
- - **Allow-list is wider than the ADR's "Anthropic, GitHub, npm".** That parenthetical describes the *Anthropic feature's* defaults. Our box legitimately needs the GDS API + Gemini + PyPI or the CLI and art pipeline break behind the firewall. The list stays minimal and each entry is justified above.
63
- - **Base = Ubuntu 24.04 devcontainers image** (not Anthropic's reference `node:20`) — for the Node 22 + Python 3.12 parity above and a ready-made non-root `vscode` sudo user. The Claude Code *feature* is still Anthropic's, satisfying "the Anthropic Claude Code container feature."
64
-
65
- ## Out of scope (separate ADR-0031 tasks)
66
-
67
- This task is the **foundation only**. It does not provision boxes, lock builders to one box, gate by rank, or wire billing:
68
-
69
- - **#597** — move prod deploy to GitHub Actions (no builder box holds the droplet key).
70
- - **#598** — per-builder DO box provisioning + auto-suspend (depends on this).
71
- - **#599** — Desktop managed-settings + the always-on `claude rc` service.
72
- - **#600** — rank-gated source access via the box.
73
- - **#602** — cost passthrough (org-funded starter → self-fund).
@@ -1,19 +0,0 @@
1
- {
2
- "features": {
3
- "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {
4
- "version": "1.0.5",
5
- "resolved": "ghcr.io/anthropics/devcontainer-features/claude-code@sha256:cfc2e7d3e9fd3b9b01f8d5cb158508a884c8c0ede2e23ed10f32dea5d4ffe69a",
6
- "integrity": "sha256:cfc2e7d3e9fd3b9b01f8d5cb158508a884c8c0ede2e23ed10f32dea5d4ffe69a"
7
- },
8
- "ghcr.io/devcontainers/features/github-cli:1": {
9
- "version": "1.1.0",
10
- "resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671",
11
- "integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671"
12
- },
13
- "ghcr.io/devcontainers/features/node:1": {
14
- "version": "1.7.1",
15
- "resolved": "ghcr.io/devcontainers/features/node@sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6",
16
- "integrity": "sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6"
17
- }
18
- }
19
- }
@@ -1,93 +0,0 @@
1
- {
2
- // Example Dev Box — the OTB builder environment as code (ADR 0031 §3).
3
- //
4
- // This is the single box spec every builder runs, reached via Claude Desktop
5
- // → SSH or the web → Remote Control (ADR 0031 §1). It builds identically on a
6
- // DigitalOcean box and on a laptop via Docker Desktop. The pre-distributed
7
- // Desktop "managed settings" that lock a non-technical builder to exactly this
8
- // box are a separate task (#599); this file is the box itself.
9
- "name": "Example Dev Box",
10
-
11
- "build": {
12
- "dockerfile": "Dockerfile",
13
- // Context is this .devcontainer/ folder (the default). The Dockerfile only
14
- // COPYs the two scripts that live here; nothing from the repo root is needed
15
- // at build time (npm ci / pip install run at postCreate against the mounted
16
- // workspace), so the COPY paths stay relative to this folder.
17
- "context": ".",
18
- "args": {
19
- // Honor the host timezone when set, else UTC.
20
- "TZ": "${localEnv:TZ:Etc/UTC}"
21
- }
22
- },
23
-
24
- // Node 22, the GitHub CLI, and Claude Code arrive as version-pinned features
25
- // rather than hand-rolled in the Dockerfile (the idiomatic, reproducible path).
26
- // The Claude Code feature also installs Anthropic's default-deny egress posture
27
- // tooling; our init-firewall.sh (postStart) is the OTB-tuned allow-list on top.
28
- "features": {
29
- "ghcr.io/devcontainers/features/node:1": { "version": "22" },
30
- "ghcr.io/devcontainers/features/github-cli:1": {},
31
- "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {}
32
- },
33
-
34
- // NET_ADMIN + NET_RAW let the postStart firewall manage iptables/ipset. Without
35
- // these the firewall fails closed (or warns if FIREWALL_OPTIONAL=1) — see
36
- // init-firewall.sh. Docker Desktop and a DO Docker host both honor cap-add.
37
- "runArgs": ["--cap-add=NET_ADMIN", "--cap-add=NET_RAW"],
38
-
39
- // The devcontainers Ubuntu base ships a non-root `vscode` user with passwordless
40
- // sudo (needed for the postStart firewall). Run as that user, not root.
41
- "remoteUser": "vscode",
42
-
43
- "containerEnv": {
44
- "DEVCONTAINER": "true",
45
- // Where the art-pipeline Python venv lives (the Dockerfile prepends its bin
46
- // to PATH); post-create.sh and healthcheck.sh reference it.
47
- "OTB_VENV": "/opt/otb-venv",
48
- // A modest Node heap headroom for the dev server + art pipeline.
49
- "NODE_OPTIONS": "--max-old-space-size=4096"
50
- },
51
-
52
- // Standardize the in-container repo path (matches Anthropic's reference) so
53
- // docs/scripts can assume /workspace regardless of the host folder name.
54
- "workspaceFolder": "/workspace",
55
- "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
56
-
57
- // Named volumes (NOT binds — nothing secret lands in the repo or image, per
58
- // ADR 0022):
59
- // - node_modules: a container-ISOLATED volume so the host's native binaries
60
- // (e.g. a macOS-built `sharp`) never leak into this linux box, and vice
61
- // versa. This is what makes "runs identically on a laptop" true — without
62
- // it, bind-mounting the repo from macOS breaks sharp in the container.
63
- // - the Claude Code login/config (so a rebuild doesn't force re-login),
64
- // - shell history (quality-of-life),
65
- // - ~/.config/otb (the per-builder Gemini key + GDS session survive rebuilds).
66
- "mounts": [
67
- "source=otb-node-modules-${devcontainerId},target=/workspace/node_modules,type=volume",
68
- "source=otb-claude-config-${devcontainerId},target=/home/vscode/.claude,type=volume",
69
- "source=otb-bashhistory-${devcontainerId},target=/commandhistory,type=volume",
70
- "source=otb-config-${devcontainerId},target=/home/vscode/.config/otb,type=volume"
71
- ],
72
-
73
- // Lifecycle:
74
- // postCreate — once, after the repo is mounted: fold in setup-builder.sh,
75
- // install art deps, verify the stack (post-create.sh).
76
- // postStart — every start: bring up the default-deny firewall.
77
- // postAttach — every attach: run the BUILDER health check (session/auth/git).
78
- // Non-fatal (|| true) so a not-yet-authed fresh box still attaches.
79
- "postCreateCommand": "bash .devcontainer/post-create.sh",
80
- "postStartCommand": "sudo /usr/local/bin/otb-init-firewall.sh",
81
- "postAttachCommand": "node scripts/gds/doctor.js || true",
82
- "waitFor": "postCreateCommand",
83
-
84
- "customizations": {
85
- "vscode": {
86
- "extensions": [
87
- "anthropic.claude-code",
88
- "dbaeumer.vscode-eslint",
89
- "esbenp.prettier-vscode"
90
- ]
91
- }
92
- }
93
- }
@@ -1,84 +0,0 @@
1
- #!/usr/bin/env bash
2
- # healthcheck.sh — image-level toolchain integrity check for the Example
3
- # Dev Box (ADR 0031 §3).
4
- #
5
- # This is the IMAGE half of "doctor.js as the health check": it answers "was the
6
- # box built correctly?" using only what the image itself provides — no GDS
7
- # session, no repo checkout, no network. The BUILDER half ("is *this builder*
8
- # authed, hooked, and clean?") is scripts/gds/doctor.js, run at attach time.
9
- #
10
- # Two modes:
11
- # --build run as the final Dockerfile RUN step (fail the build on a bad box).
12
- # (default) the runtime HEALTHCHECK / a recreated-box probe (#598).
13
- #
14
- # Both check the same stable, image-level toolchain. Repo-level facts (sharp in
15
- # node_modules, the art libs in the venv) are installed at first-run by
16
- # post-create.sh and verified there — they are intentionally NOT gated here so
17
- # the container never flaps "unhealthy" during provisioning.
18
- #
19
- # NOTE on ordering: Node, the GitHub CLI, and Claude Code are installed by
20
- # devcontainer *features*, which run AFTER the Dockerfile is built. So the
21
- # --build assertion can't see Node — it checks only what the Dockerfile itself
22
- # provides (the Python venv + firewall tooling). The Node check runs at runtime,
23
- # where features are present.
24
-
25
- set -uo pipefail
26
-
27
- MODE="${1:-runtime}"
28
- fails=0
29
-
30
- ok() { echo " ok $*"; }
31
- bad() { echo " FAIL $*"; fails=$((fails + 1)); }
32
-
33
- # Node >= 22 — runtime only (feature-installed after the Dockerfile build).
34
- if [ "$MODE" != "--build" ]; then
35
- if command -v node >/dev/null 2>&1; then
36
- nver="$(node --version 2>/dev/null)"
37
- nmaj="$(echo "$nver" | sed 's/^v//' | cut -d. -f1)"
38
- if [[ "$nmaj" =~ ^[0-9]+$ ]] && [ "$nmaj" -ge 22 ]; then
39
- ok "node $nver (>= 22)"
40
- else
41
- bad "node $nver found, need >= 22"
42
- fi
43
- else
44
- bad "node not on PATH"
45
- fi
46
- else
47
- echo " -- node check deferred to runtime (feature-installed after the Dockerfile)"
48
- fi
49
-
50
- # Python >= 3.12 (the venv interpreter — must be first on PATH)
51
- if command -v python3 >/dev/null 2>&1; then
52
- pyver="$(python3 -c 'import sys; print("%d.%d" % sys.version_info[:2])' 2>/dev/null || echo "?")"
53
- pymaj="${pyver%%.*}"; pymin="${pyver#*.}"
54
- if [[ "$pymaj" =~ ^[0-9]+$ ]] && [[ "$pymin" =~ ^[0-9]+$ ]] \
55
- && { [ "$pymaj" -gt 3 ] || { [ "$pymaj" -eq 3 ] && [ "$pymin" -ge 12 ]; }; }; then
56
- ok "python ${pyver} (>= 3.12) [$(command -v python3)]"
57
- else
58
- bad "python ${pyver} found, need >= 3.12"
59
- fi
60
- # The venv must be the active interpreter so the art pipeline's deps resolve.
61
- if [ -n "${OTB_VENV:-}" ] && [ "$(command -v python3)" = "${OTB_VENV}/bin/python3" ]; then
62
- ok "art-pipeline venv active (${OTB_VENV})"
63
- else
64
- bad "expected venv python at ${OTB_VENV:-<unset>}/bin/python3, got $(command -v python3)"
65
- fi
66
- else
67
- bad "python3 not on PATH"
68
- fi
69
-
70
- # Firewall tooling present (the default-deny boundary depends on it).
71
- for tool in iptables ipset dig jq; do
72
- if command -v "$tool" >/dev/null 2>&1; then
73
- ok "$tool present"
74
- else
75
- bad "$tool missing (firewall/init would fail)"
76
- fi
77
- done
78
-
79
- if [ "$fails" -eq 0 ]; then
80
- [ "$MODE" = "--build" ] && echo "toolchain integrity: OK (build)"
81
- exit 0
82
- fi
83
- echo "toolchain integrity: ${fails} problem(s)"
84
- exit 1
@@ -1,234 +0,0 @@
1
- #!/usr/bin/env bash
2
- # init-firewall.sh — default-deny outbound firewall for the Example Dev Box.
3
- #
4
- # ADR 0031 §3 + §6: the box is an org-controlled, egress-restricted surface. This
5
- # script flips the container to default-DROP and then allow-lists ONLY the hosts
6
- # the OTB toolchain legitimately needs. Everything else is blocked, so a builder
7
- # session (or a compromised dependency) cannot quietly exfiltrate to an arbitrary
8
- # host.
9
- #
10
- # Mechanism (mirrors Anthropic's reference init-firewall.sh):
11
- # 1. Resolve every allow-listed host to IPs WHILE egress is still open.
12
- # 2. Stuff them into an `ipset` (hash:net so GitHub's CIDR ranges fit).
13
- # 3. Flip INPUT/OUTPUT/FORWARD to DROP, then re-allow loopback, established
14
- # connections, DNS, SSH, and OUTPUT to the ipset.
15
- # 4. Verify: a blocked host must fail; an allow-listed host must succeed.
16
- #
17
- # Allow-list (and WHY each is here — keep this tight, every addition is egress):
18
- # - GitHub (api.github.com/meta ranges) ......... git push/pull, `gh`
19
- # - npm registry (registry.npmjs.org) ............ `npm ci`
20
- # - Anthropic API (api.anthropic.com + telemetry) the brain (tokens), Claude Code
21
- # - THIS instance's own API host(s) .............. the GDS API (/api/gds/* CLI)
22
- # (resolved from config/branding.json at runtime — task 1002757; a hardcoded
23
- # founder host allow-listed someone else's server and BLOCKED the builder's own)
24
- # - generativelanguage.googleapis.com ............ the pixel-art pipeline (Gemini) [OTB]
25
- # - pypi.org + files.pythonhosted.org ............ `pip install` art deps [OTB]
26
- #
27
- # Runs as root via `sudo` from the devcontainer postStartCommand. Needs the
28
- # NET_ADMIN + NET_RAW capabilities (granted by runArgs in devcontainer.json).
29
- #
30
- # Fail posture: FAIL-CLOSED by default. If the rules cannot be applied or the
31
- # verification fails, the script exits non-zero. Set FIREWALL_OPTIONAL=1 to
32
- # downgrade a cap/permission failure to a loud warning that still lets the
33
- # container boot — intended ONLY for a laptop Docker host that won't grant
34
- # NET_ADMIN (where the builder is already root of their own machine, so egress
35
- # lockdown is advisory anyway — see ADR 0031 §6 "honest limit"). The DO box
36
- # (#598) sets FIREWALL_OPTIONAL unset, so it always enforces.
37
-
38
- set -euo pipefail
39
- IFS=$'\n\t'
40
-
41
- OPTIONAL="${FIREWALL_OPTIONAL:-0}"
42
-
43
- # Hosts to allow-list by name (resolved via dig at runtime). GitHub is handled
44
- # separately via its published meta ranges. Keep this list minimal.
45
- ALLOWED_HOSTS=(
46
- "registry.npmjs.org"
47
- "api.anthropic.com"
48
- "statsig.anthropic.com"
49
- "sentry.io"
50
- "generativelanguage.googleapis.com"
51
- "pypi.org"
52
- "files.pythonhosted.org"
53
- # THIS instance's own hosts are appended below from its branding pack.
54
- )
55
-
56
- # Append the instance's OWN API hosts, read from config/branding.json at runtime
57
- # (task 1002757). Was two hardcoded founder hosts, which allow-listed someone
58
- # else's server and left the builder's own GDS API blocked — the devcontainer
59
- # then failed every /builder-* call for a reason nothing in the log explained.
60
- # GDS_ALLOW_HOSTS (space/comma separated) overrides for a non-standard setup.
61
- REPO_ROOT_FW="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
62
- INSTANCE_HOSTS="${GDS_ALLOW_HOSTS:-}"
63
- if [ -z "$INSTANCE_HOSTS" ]; then
64
- INSTANCE_HOSTS="$(node -e '
65
- const d = (require(process.argv[1]).branding().domains) || {};
66
- const hosts = new Set();
67
- // Only *_ORIGIN-shaped values yield a host, and only a real public FQDN
68
- // counts: the neutral starter pack carries localhost:3000 and bare subdomain
69
- // PREFIXES ("sandbox-", "term-"), none of which is an allow-listable host.
70
- for (const v of Object.values(d)) {
71
- if (typeof v !== "string" || !v.trim()) continue;
72
- let host = null;
73
- try { host = new URL(v).hostname; } catch { host = v.trim(); }
74
- if (!host) continue;
75
- if (!/^(?=.{1,253}$)[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i.test(host)) continue;
76
- if (/^localhost$/i.test(host)) continue;
77
- hosts.add(host.toLowerCase());
78
- }
79
- process.stdout.write([...hosts].join(" "));
80
- ' "$REPO_ROOT_FW/src/branding" 2>/dev/null || true)"
81
- fi
82
- for h in ${INSTANCE_HOSTS//,/ }; do
83
- [ -n "$h" ] && ALLOWED_HOSTS+=("$h")
84
- done
85
-
86
- log() { echo "[firewall] $*"; }
87
- die() {
88
- # Honor the documented escape hatch: a cap/permission failure on a laptop
89
- # host downgrades to a warning instead of bricking container startup.
90
- # The bypass is env-driven, so it can't be access-controlled from inside the
91
- # script — the mitigation is a LOUD, greppable audit line so any use is
92
- # visible in container logs / log aggregation. On the org DO box this must
93
- # never be set; if this banner appears there, investigate.
94
- if [ "$OPTIONAL" = "1" ]; then
95
- log "==================== SECURITY AUDIT ===================="
96
- log "egress firewall DISABLED via FIREWALL_OPTIONAL=1"
97
- log " reason: $*"
98
- log " the box is running WITHOUT default-deny egress (laptop-only escape hatch)"
99
- log "======================================================="
100
- exit 0
101
- fi
102
- log "ERROR: $*"
103
- log "If this host genuinely cannot grant NET_ADMIN (e.g. a restricted laptop Docker),"
104
- log "re-run with FIREWALL_OPTIONAL=1 to boot without egress lockdown."
105
- exit 1
106
- }
107
-
108
- # Pre-flight: we must be root and have the iptables/ipset tooling + caps.
109
- if [ "$(id -u)" -ne 0 ]; then
110
- die "must run as root (expected: sudo $0)"
111
- fi
112
- command -v iptables >/dev/null 2>&1 || die "iptables not installed"
113
- command -v ipset >/dev/null 2>&1 || die "ipset not installed"
114
- command -v dig >/dev/null 2>&1 || die "dig (dnsutils) not installed"
115
-
116
- # A NET_ADMIN probe: try a harmless list. If the kernel refuses, we lack the cap.
117
- if ! iptables -L >/dev/null 2>&1; then
118
- die "cannot manage iptables (NET_ADMIN capability missing?)"
119
- fi
120
-
121
- log "resolving allow-list while egress is still open…"
122
-
123
- # Fresh ipset. hash:net so we can store both single IPs and CIDR ranges.
124
- # create -exist + flush (not destroy+create): on a manual re-run an iptables
125
- # OUTPUT rule may still reference the set, which makes `destroy` fail. Flushing
126
- # empties it in place while keeping the object valid.
127
- ipset create allowed-domains hash:net -exist
128
- ipset flush allowed-domains
129
-
130
- add_ip() {
131
- # Validate it looks like an IPv4 (or CIDR) before adding, so a bad DNS answer
132
- # can't inject garbage into the set.
133
- local ip="$1"
134
- if [[ "$ip" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}(/[0-9]{1,2})?$ ]]; then
135
- ipset add allowed-domains "$ip" 2>/dev/null || true
136
- fi
137
- }
138
-
139
- # --- GitHub: use the published meta ranges (covers github.com, api, git, web) -
140
- log " + GitHub meta ranges"
141
- gh_meta="$(curl -fsS --max-time 20 https://api.github.com/meta || true)"
142
- if [ -n "$gh_meta" ] && command -v jq >/dev/null 2>&1; then
143
- # .web + .api + .git are the egress-relevant range groups.
144
- while read -r cidr; do
145
- [ -n "$cidr" ] && add_ip "$cidr"
146
- done < <(echo "$gh_meta" | jq -r '(.web // []) + (.api // []) + (.git // []) | .[]' 2>/dev/null | grep -E '^[0-9]+\.' || true)
147
- else
148
- # Fallback: resolve the hostnames directly if the meta API is unavailable.
149
- log " (meta API unavailable — falling back to direct resolution)"
150
- for h in github.com api.github.com codeload.github.com objects.githubusercontent.com; do
151
- while read -r ip; do add_ip "$ip"; done < <(dig +short +noall +answer A "$h" 2>/dev/null | grep -E '^[0-9]+\.' || true)
152
- done
153
- fi
154
-
155
- # --- Everything else: resolve by name -----------------------------------------
156
- for host in "${ALLOWED_HOSTS[@]}"; do
157
- log " + $host"
158
- while read -r ip; do
159
- add_ip "$ip"
160
- done < <(dig +short +noall +answer A "$host" 2>/dev/null | grep -E '^[0-9]+\.' || true)
161
- done
162
-
163
- # --- Host networking: keep the container reachable from / to its Docker host --
164
- # Allow the host gateway + the container's own /24 so SSH in, the dev server,
165
- # and DNS-to-the-Docker-resolver keep working after we flip to DROP.
166
- host_ip="$(ip route | awk '/default/ {print $3; exit}')"
167
- if [ -n "${host_ip:-}" ]; then
168
- host_net="$(echo "$host_ip" | sed 's/\.[0-9]*$/.0\/24/')"
169
- log " + host network ${host_net}"
170
- add_ip "$host_net"
171
- fi
172
-
173
- n="$(ipset list allowed-domains | grep -cE '^[0-9]+\.' || true)"
174
- log "allow-list built: ${n} entries"
175
- [ "$n" -gt 0 ] || die "allow-list is empty — refusing to flip to default-DROP (would sever everything)"
176
-
177
- log "applying default-deny policy…"
178
-
179
- # Flush prior rules so re-runs are idempotent.
180
- iptables -F
181
- iptables -X 2>/dev/null || true
182
-
183
- # Loopback — always.
184
- iptables -A INPUT -i lo -j ACCEPT
185
- iptables -A OUTPUT -o lo -j ACCEPT
186
-
187
- # Established/related return traffic.
188
- iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
189
- iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
190
-
191
- # DNS — keep resolution working at runtime (long-running procs re-resolve).
192
- iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
193
- iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT
194
-
195
- # SSH — the Desktop/Remote-Control on-ramp (ADR 0031 §1).
196
- iptables -A INPUT -p tcp --dport 22 -j ACCEPT
197
- iptables -A OUTPUT -p tcp --dport 22 -j ACCEPT
198
-
199
- # The actual allow-list: outbound only to resolved, vetted IPs.
200
- iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
201
-
202
- # Default DROP for everything not explicitly allowed above.
203
- iptables -P INPUT DROP
204
- iptables -P FORWARD DROP
205
- iptables -P OUTPUT DROP
206
-
207
- log "verifying…"
208
-
209
- # A blocked host MUST fail (proves the deny is real).
210
- if curl -fsS --max-time 6 https://example.com >/dev/null 2>&1; then
211
- die "verification FAILED: example.com is reachable but should be blocked"
212
- fi
213
- log " ✓ blocked host (example.com) is unreachable"
214
-
215
- # An allow-listed host MUST succeed (proves we didn't sever everything).
216
- if ! curl -fsS --max-time 10 https://api.github.com/zen >/dev/null 2>&1; then
217
- die "verification FAILED: api.github.com is unreachable but should be allowed"
218
- fi
219
- log " ✓ allow-listed host (api.github.com) is reachable"
220
-
221
- # Our own GDS API must be reachable (the CLI depends on it). Non-fatal warn:
222
- # the host may be mid-deploy; the firewall correctness is already proven above.
223
- # The instance's OWN first API host (task 1002757 — was a founder literal, so this
224
- # probe reported a stranger's health as the container's connectivity).
225
- GDS_PROBE_HOST="$(printf '%s\n' ${INSTANCE_HOSTS//,/ } | head -n 1)"
226
- if [ -z "$GDS_PROBE_HOST" ]; then
227
- log " · no instance API host resolved from the branding pack — skipping the GDS reachability probe"
228
- elif curl -fsS --max-time 10 "https://$GDS_PROBE_HOST/healthz" 2>/dev/null | grep -q '^ok$'; then
229
- log " ✓ GDS API ($GDS_PROBE_HOST) is reachable"
230
- else
231
- log " ! GDS API healthz did not return ok (host may be redeploying) — allow rule is in place"
232
- fi
233
-
234
- log "default-deny firewall active."