Skip to content

Commit 392428c

Browse files
authored
Merge pull request #43 from gatezh/devcontainer
feat: add devcontainer for this repo (default + sandbox)
2 parents 0735255 + 409b467 commit 392428c

5 files changed

Lines changed: 405 additions & 0 deletions

File tree

‎.devcontainer/Dockerfile‎

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# ═══════════════════════════════════════════════════════════════════════════════
2+
# Devcontainer for the devcontainer-images repo — two build targets:
3+
# default — full dev environment with passwordless sudo
4+
# sandbox — network-restricted environment with firewall packages
5+
#
6+
# rtk and ralphex fetch latest versions from GitHub releases at build time.
7+
# git-delta is pinned (upstream changed release asset naming in v0.19.0).
8+
#
9+
# Build:
10+
# docker build --target default -t devcontainer-images:default .
11+
# docker build --target sandbox -t devcontainer-images:sandbox .
12+
# ═══════════════════════════════════════════════════════════════════════════════
13+
14+
# ─── BASE ─────────────────────────────────────────────────────────────────────
15+
FROM node:24-trixie-slim AS base
16+
17+
ARG GIT_DELTA_VERSION=0.18.2
18+
19+
# System packages (each justified — see plan doc for rationale)
20+
RUN apt-get update && apt-get install -y --no-install-recommends \
21+
ca-certificates \
22+
curl \
23+
fish \
24+
fzf \
25+
gh \
26+
git \
27+
jq \
28+
less \
29+
sudo \
30+
&& apt-get clean && rm -rf /var/lib/apt/lists/*
31+
32+
ENV DEVCONTAINER=true
33+
34+
# Create workspace and config directories with proper ownership
35+
RUN mkdir -p /workspace /home/node/.claude /home/node/.local/share/fish \
36+
&& chown -R node:node /workspace /home/node/.claude /home/node/.local
37+
38+
WORKDIR /workspace
39+
40+
# Install git-delta (pinned — v0.19.0 dropped arm64 .deb)
41+
RUN ARCH=$(dpkg --print-architecture) \
42+
&& curl -fsSL -o git-delta.deb \
43+
"https://github.com/dandavison/delta/releases/download/${GIT_DELTA_VERSION}/git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" \
44+
&& dpkg -i git-delta.deb \
45+
&& rm git-delta.deb
46+
47+
# Install rtk (latest release — token optimizer for Claude Code)
48+
# amd64: musl binary (no gnu variant published), arm64: gnu binary
49+
RUN ARCH=$(dpkg --print-architecture) \
50+
&& VERSION=$(curl -s https://api.github.com/repos/rtk-ai/rtk/releases/latest | jq -r .tag_name | sed 's/^v//') \
51+
&& if [ "$VERSION" = "null" ] || [ -z "$VERSION" ]; then echo "ERROR: Failed to fetch rtk version (GitHub API rate limit?)" && exit 1; fi \
52+
&& case "${ARCH}" in \
53+
amd64) RTK_ARCH='x86_64-unknown-linux-musl' ;; \
54+
arm64) RTK_ARCH='aarch64-unknown-linux-gnu' ;; \
55+
*) echo "Unsupported architecture: ${ARCH}" && exit 1 ;; \
56+
esac \
57+
&& curl -fsSL -o rtk.tar.gz \
58+
"https://github.com/rtk-ai/rtk/releases/download/v${VERSION}/rtk-${RTK_ARCH}.tar.gz" \
59+
&& tar -xzf rtk.tar.gz \
60+
&& install -m 755 rtk /usr/local/bin/rtk \
61+
&& rm -rf rtk rtk.tar.gz
62+
63+
# Install ralphex (latest release — Claude Code skill manager)
64+
# Tarball includes completions/ dir — install fish completion for shell integration
65+
RUN ARCH=$(dpkg --print-architecture) \
66+
&& VERSION=$(curl -s https://api.github.com/repos/umputun/ralphex/releases/latest | jq -r .tag_name | sed 's/^v//') \
67+
&& if [ "$VERSION" = "null" ] || [ -z "$VERSION" ]; then echo "ERROR: Failed to fetch ralphex version (GitHub API rate limit?)" && exit 1; fi \
68+
&& curl -fsSL -o ralphex.tar.gz \
69+
"https://github.com/umputun/ralphex/releases/download/v${VERSION}/ralphex_${VERSION}_linux_${ARCH}.tar.gz" \
70+
&& tar -xzf ralphex.tar.gz \
71+
&& install -m 755 ralphex /usr/local/bin/ralphex \
72+
&& mkdir -p /home/node/.config/fish/completions \
73+
&& cp completions/ralphex.fish /home/node/.config/fish/completions/ 2>/dev/null || true \
74+
&& rm -rf ralphex ralphex.tar.gz completions/
75+
76+
# Fix ownership of .config created by ralphex completions install above
77+
RUN chown -R node:node /home/node/.config
78+
79+
# ── Non-root user setup ──────────────────────────────────────────────────────
80+
USER node
81+
82+
ENV SHELL=/usr/bin/fish
83+
ENV EDITOR="code --wait"
84+
ENV VISUAL="code --wait"
85+
86+
# ── Starship prompt ──────────────────────────────────────────────────────────
87+
USER root
88+
RUN curl -sS https://starship.rs/install.sh | sh -s -- --yes
89+
USER node
90+
RUN mkdir -p /home/node/.config/fish \
91+
&& starship preset no-runtime-versions -o /home/node/.config/starship.toml \
92+
&& printf '%s\n' 'set -g fish_greeting' 'starship init fish | source' > /home/node/.config/fish/config.fish
93+
94+
# ── Claude Code CLI ──────────────────────────────────────────────────────────
95+
USER root
96+
RUN npm install -g @anthropic-ai/claude-code
97+
USER node
98+
99+
# ─── DEFAULT — full dev environment ───────────────────────────────────────────
100+
FROM base AS default
101+
102+
USER root
103+
RUN echo "node ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/node-nopasswd \
104+
&& chmod 0440 /etc/sudoers.d/node-nopasswd
105+
USER node
106+
107+
# ─── SANDBOX — network-restricted environment ─────────────────────────────────
108+
FROM base AS sandbox
109+
110+
USER root
111+
RUN apt-get update && apt-get install -y --no-install-recommends \
112+
iptables \
113+
ipset \
114+
iproute2 \
115+
dnsutils \
116+
aggregate \
117+
&& apt-get clean && rm -rf /var/lib/apt/lists/*
118+
119+
# Firewall sudo rule — the script itself is bind-mounted from claude-sandbox/
120+
RUN echo "node ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh" > /etc/sudoers.d/node-firewall \
121+
&& chmod 0440 /etc/sudoers.d/node-firewall
122+
USER node
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// For format details, see https://aka.ms/devcontainer.json
2+
{
3+
"name": "Sandbox",
4+
"build": {
5+
"context": "..",
6+
"dockerfile": "../Dockerfile",
7+
"target": "sandbox"
8+
},
9+
// Capabilities required for iptables firewall setup
10+
"capAdd": ["NET_ADMIN", "NET_RAW"],
11+
"init": true,
12+
"remoteUser": "node",
13+
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
14+
"workspaceFolder": "/workspace",
15+
"customizations": {
16+
"vscode": {
17+
"extensions": [
18+
// **AI Agents**
19+
"anthropic.claude-code",
20+
21+
// **General**
22+
"redhat.vscode-yaml",
23+
"bierner.markdown-preview-github-styles",
24+
"ms-azuretools.vscode-docker"
25+
],
26+
"settings": {
27+
"terminal.integrated.defaultProfile.linux": "fish",
28+
"terminal.integrated.profiles.linux": {
29+
"fish": { "path": "fish" },
30+
"bash": { "path": "bash", "icon": "terminal-bash" }
31+
},
32+
"extensions.ignoreRecommendations": true
33+
}
34+
}
35+
},
36+
"mounts": [
37+
"source=devcontainer-images-sandbox-config-${devcontainerId},target=/home/node/.claude,type=volume",
38+
"source=devcontainer-images-sandbox-fish-${devcontainerId},target=/home/node/.local/share/fish,type=volume",
39+
// Mount firewall script into the expected path
40+
"source=${localWorkspaceFolder}/.devcontainer/claude-sandbox/init-firewall.sh,target=/usr/local/bin/init-firewall.sh,type=bind"
41+
],
42+
"containerEnv": {
43+
"TZ": "${localEnv:TZ:America/Edmonton}",
44+
"CLAUDE_CONFIG_DIR": "/home/node/.claude",
45+
// Sandbox blocks OAuth login — inject token from host
46+
"CLAUDE_CODE_OAUTH_TOKEN": "${localEnv:CLAUDE_CODE_OAUTH_TOKEN}"
47+
},
48+
// Plugins before firewall (network still open during postCreateCommand)
49+
"postCreateCommand": "sudo chown -R node /home/node/.claude && bash /workspace/.devcontainer/init-plugins.sh",
50+
// Firewall locks down the network
51+
"postStartCommand": "sudo /usr/local/bin/init-firewall.sh",
52+
"waitFor": "postStartCommand"
53+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
#!/bin/bash
2+
set -euo pipefail
3+
IFS=$'\n\t'
4+
5+
# 1. Extract Docker DNS info BEFORE any flushing
6+
DOCKER_DNS_RULES=$(iptables-save -t nat | grep "127\.0\.0\.11" || true)
7+
8+
# Flush existing rules and delete existing ipsets
9+
iptables -F
10+
iptables -X
11+
iptables -t nat -F
12+
iptables -t nat -X
13+
iptables -t mangle -F
14+
iptables -t mangle -X
15+
ipset destroy allowed-domains 2>/dev/null || true
16+
17+
# 2. Selectively restore ONLY internal Docker DNS resolution
18+
if [ -n "$DOCKER_DNS_RULES" ]; then
19+
echo "Restoring Docker DNS rules..."
20+
iptables -t nat -N DOCKER_OUTPUT 2>/dev/null || true
21+
iptables -t nat -N DOCKER_POSTROUTING 2>/dev/null || true
22+
echo "$DOCKER_DNS_RULES" | xargs -L 1 iptables -t nat
23+
else
24+
echo "No Docker DNS rules to restore"
25+
fi
26+
27+
# Allow DNS, SSH, and localhost before restrictions
28+
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
29+
iptables -A INPUT -p udp --sport 53 -j ACCEPT
30+
iptables -A OUTPUT -p tcp --dport 22 -j ACCEPT
31+
iptables -A INPUT -p tcp --sport 22 -m state --state ESTABLISHED -j ACCEPT
32+
iptables -A INPUT -i lo -j ACCEPT
33+
iptables -A OUTPUT -o lo -j ACCEPT
34+
35+
# Create ipset with CIDR support
36+
ipset create allowed-domains hash:net
37+
38+
# Fetch GitHub IP ranges
39+
echo "Fetching GitHub IP ranges..."
40+
gh_ranges=$(curl -s https://api.github.com/meta)
41+
if [ -z "$gh_ranges" ]; then
42+
echo "ERROR: Failed to fetch GitHub IP ranges"
43+
exit 1
44+
fi
45+
46+
if ! echo "$gh_ranges" | jq -e '.web and .api and .git' >/dev/null; then
47+
echo "ERROR: GitHub API response missing required fields"
48+
exit 1
49+
fi
50+
51+
echo "Processing GitHub IPs..."
52+
while read -r cidr; do
53+
if [[ ! "$cidr" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/[0-9]{1,2}$ ]]; then
54+
echo "ERROR: Invalid CIDR range from GitHub meta: $cidr"
55+
exit 1
56+
fi
57+
echo "Adding GitHub range $cidr"
58+
ipset add -exist allowed-domains "$cidr"
59+
done < <(echo "$gh_ranges" | jq -r '(.web + .api + .git)[]' | aggregate -q)
60+
61+
# ── Allowed domains ──────────────────────────────────────────────────
62+
for domain in \
63+
"registry.npmjs.org" \
64+
"api.anthropic.com" \
65+
"sentry.io" \
66+
"statsig.anthropic.com" \
67+
"statsig.com" \
68+
"marketplace.visualstudio.com" \
69+
"vscode.blob.core.windows.net" \
70+
"update.code.visualstudio.com"; do
71+
echo "Resolving $domain..."
72+
ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}')
73+
if [ -z "$ips" ]; then
74+
echo "ERROR: Failed to resolve $domain"
75+
exit 1
76+
fi
77+
78+
while read -r ip; do
79+
if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
80+
echo "ERROR: Invalid IP from DNS for $domain: $ip"
81+
exit 1
82+
fi
83+
echo "Adding $ip for $domain"
84+
ipset add -exist allowed-domains "$ip"
85+
done < <(echo "$ips")
86+
done
87+
# ─────────────────────────────────────────────────────────────────────
88+
89+
# Allow host network
90+
HOST_IP=$(ip route | grep default | cut -d" " -f3)
91+
if [ -z "$HOST_IP" ]; then
92+
echo "ERROR: Failed to detect host IP"
93+
exit 1
94+
fi
95+
96+
HOST_NETWORK=$(echo "$HOST_IP" | sed "s/\.[0-9]*$/.0\/24/")
97+
echo "Host network detected as: $HOST_NETWORK"
98+
99+
iptables -A INPUT -s "$HOST_NETWORK" -j ACCEPT
100+
iptables -A OUTPUT -d "$HOST_NETWORK" -j ACCEPT
101+
102+
# Default DROP, allow established, allow allowlist, reject rest
103+
iptables -P INPUT DROP
104+
iptables -P FORWARD DROP
105+
iptables -P OUTPUT DROP
106+
107+
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
108+
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
109+
iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
110+
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
111+
112+
echo "Firewall configuration complete"
113+
echo "Verifying firewall rules..."
114+
if curl --connect-timeout 5 https://example.com >/dev/null 2>&1; then
115+
echo "ERROR: Firewall verification failed - was able to reach https://example.com"
116+
exit 1
117+
else
118+
echo "Firewall verification passed - unable to reach https://example.com as expected"
119+
fi
120+
121+
if ! curl --connect-timeout 5 https://api.github.com/zen >/dev/null 2>&1; then
122+
echo "ERROR: Firewall verification failed - unable to reach https://api.github.com"
123+
exit 1
124+
else
125+
echo "Firewall verification passed - able to reach https://api.github.com as expected"
126+
fi

‎.devcontainer/devcontainer.json‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// For format details, see https://aka.ms/devcontainer.json
2+
{
3+
"name": "Default",
4+
"build": {
5+
"dockerfile": "Dockerfile",
6+
"target": "default"
7+
},
8+
"init": true,
9+
"remoteUser": "node",
10+
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
11+
"workspaceFolder": "/workspace",
12+
"customizations": {
13+
"vscode": {
14+
"extensions": [
15+
// **AI Agents**
16+
// Claude Code - AI assistant
17+
"anthropic.claude-code",
18+
19+
// **General**
20+
// YAML - YAML support (for workflows and docker-compose)
21+
"redhat.vscode-yaml",
22+
// Markdown Preview Github Styles - Markdown preview
23+
"bierner.markdown-preview-github-styles",
24+
// Docker - Dockerfile support
25+
"ms-azuretools.vscode-docker"
26+
],
27+
"settings": {
28+
"terminal.integrated.defaultProfile.linux": "fish",
29+
"terminal.integrated.profiles.linux": {
30+
"fish": { "path": "fish" },
31+
"bash": { "path": "bash", "icon": "terminal-bash" }
32+
},
33+
// Suppress extension recommendation prompts
34+
"extensions.ignoreRecommendations": true
35+
}
36+
}
37+
},
38+
"mounts": [
39+
// Persist Claude Code configuration between container rebuilds
40+
"source=devcontainer-images-claude-config-${devcontainerId},target=/home/node/.claude,type=volume",
41+
// Persist fish shell history between container rebuilds
42+
"source=devcontainer-images-fish-data-${devcontainerId},target=/home/node/.local/share/fish,type=volume"
43+
],
44+
"containerEnv": {
45+
"TZ": "${localEnv:TZ:America/Edmonton}",
46+
"CLAUDE_CONFIG_DIR": "/home/node/.claude"
47+
},
48+
// Initialize Claude Code plugins (runs once when container is created)
49+
"postCreateCommand": "sudo chown -R node /home/node/.claude && bash /workspace/.devcontainer/init-plugins.sh",
50+
"waitFor": "postCreateCommand"
51+
}

0 commit comments

Comments
 (0)