-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathclaude-sandbox.yaml
More file actions
283 lines (259 loc) · 13.4 KB
/
Copy pathclaude-sandbox.yaml
File metadata and controls
283 lines (259 loc) · 13.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
# claude-sandbox.yaml
#
# Lima template for a per-project Claude Code sandbox VM.
# Resource sizing is passed via `limactl start` flags, and the `claudevm`
# wrapper renders a per-instance copy of this file with an explicit `mounts:`
# list appended (project dir + config/secrets) before starting the VM, so
# this file stays generic across projects and the VM never falls back to
# Lima's default mounts (which include the host's home directory).
#
# Does NOT use `base: template:ubuntu-lts`. That composition merges list
# fields (like `mounts:`) additively rather than letting this file's own
# `mounts:` replace them -- Lima's stock ubuntu-lts template ships its own
# default `mounts: [{location: "~"}]` for standalone convenience use, and it
# was silently surviving alongside ours, exposing the whole host home
# directory (SSH keys included) inside every sandbox VM. The `images:`/
# `param:`/`minimumLimaVersion:` below are copied verbatim from that stock
# template instead, so this file is the sole source of `mounts:`. Since
# they're a manual copy (not inherited), refresh them occasionally as Ubuntu
# ships new point releases -- compare against Lima's own ubuntu-lts template.
#
# What this adds on top of stock Ubuntu:
# 1. iptables/ipset/dig installed, plus python3/pip3/venv (whatever Ubuntu
# 26.04 currently ships as its default, no PPA/version pinning)
# 2. DNS resolution pinned to fixed resolvers -- Quad9 (9.9.9.9) primary,
# Cloudflare (1.1.1.1) secondary -- instead of whatever the DHCP-provided
# resolver happens to be, so the firewall step below has a fixed, known
# destination to restrict DNS traffic to.
# 3. Default-deny OUTPOUND firewall (only loopback, DNS to the two resolvers
# above, and established connections allowed by default). IPv6 egress is
# blocked outright (loopback only) -- there's no AAAA/v6 counterpart to
# the allowlist mechanism below, so rather than leave v6 wide open, it's
# simply closed.
# 4. A refreshed allowlist of domains (read from /mnt/claudevm-config/allowlist.txt,
# which the wrapper mounts read-only from the host) resolved to IPs and
# added to an ipset, re-checked every 5 minutes via cron since many
# services (npm, GitHub, PyPI...) sit behind rotating CDN IPs. Anything
# not in the ipset gets an immediate ICMP "host prohibited" REJECT
# (fails in milliseconds, not a multi-minute hang) -- re-appended after
# every ipset refresh so it stays ordered after the ACCEPT rule.
# 5. ~/.gitconfig and ~/.vimrc seeded from the host's copies, if `claudevm
# new` found any (one-time only, so later in-VM edits stick).
# 6. Per-VM secrets (Claude token, GitHub token) wired up from
# /mnt/claudevm-secrets if the host provided them -- never your real
# host credentials.
minimumLimaVersion: 2.0.0
images:
# Try to use release-yyyyMMdd image if available. Note that release-yyyyMMdd will be removed after several months.
- location: "https://cloud-images.ubuntu.com/releases/resolute/release-20260720/ubuntu-26.04-server-cloudimg-amd64.img"
arch: "x86_64"
digest: "sha256:117816726abbdefc5ef3e38902e81a76f1c76c3610e709999d0885f9d5d9b477"
variant: "server"
- location: "https://cloud-images.ubuntu.com/releases/resolute/release-20260720/ubuntu-26.04-server-cloudimg-arm64.img"
arch: "aarch64"
digest: "sha256:7bcf159e29ad0000bfed9c57875908c39268f5ed1257f4958fa6a9f5f60edd54"
variant: "server"
- location: "https://cloud-images.ubuntu.com/releases/resolute/release-20260720/ubuntu-26.04-server-cloudimg-riscv64.img"
arch: "riscv64"
digest: "sha256:4167947f05d5cd23e790218bd6efc7e7db5ac046a3f3e206c6e9f71e4a006d05"
variant: "server"
- location: "https://cloud-images.ubuntu.com/releases/resolute/release-20260720/ubuntu-26.04-server-cloudimg-armhf.img"
arch: "armv7l"
digest: "sha256:5db9dafac1858a58df0387a322e89531b2a9ff3e39d76b94c7019e744fbb9367"
variant: "server"
- location: "https://cloud-images.ubuntu.com/releases/resolute/release-20260720/ubuntu-26.04-server-cloudimg-s390x.img"
arch: "s390x"
digest: "sha256:19c3b9ff0f9b33ae1985fd8896d4c5c3e830532fec1e85831a8c978ea5bb7305"
variant: "server"
- location: "https://cloud-images.ubuntu.com/releases/resolute/release-20260720/ubuntu-26.04-server-cloudimg-ppc64el.img"
arch: "ppc64le"
digest: "sha256:2612f7a36c5a657ced27197556a18ea47e4cc4f68630c2d0c840466eab274115"
variant: "server"
# Fallback to the latest release image.
# Hint: run `limactl prune` to invalidate the cache
- location: https://cloud-images.ubuntu.com/releases/resolute/release/ubuntu-26.04-server-cloudimg-amd64.img
arch: x86_64
variant: "server"
- location: https://cloud-images.ubuntu.com/releases/resolute/release/ubuntu-26.04-server-cloudimg-arm64.img
arch: aarch64
variant: "server"
- location: https://cloud-images.ubuntu.com/releases/resolute/release/ubuntu-26.04-server-cloudimg-riscv64.img
arch: riscv64
variant: "server"
- location: https://cloud-images.ubuntu.com/releases/resolute/release/ubuntu-26.04-server-cloudimg-armhf.img
arch: armv7l
variant: "server"
- location: https://cloud-images.ubuntu.com/releases/resolute/release/ubuntu-26.04-server-cloudimg-s390x.img
arch: s390x
variant: "server"
- location: https://cloud-images.ubuntu.com/releases/resolute/release/ubuntu-26.04-server-cloudimg-ppc64el.img
arch: ppc64le
variant: "server"
# Try to use release-yyyyMMdd minimal image if available.
- location: "https://cloud-images.ubuntu.com/minimal/releases/resolute/release-20260717/ubuntu-26.04-minimal-cloudimg-amd64.img"
arch: "x86_64"
digest: "sha256:fe1deeb97031e27f929dca2472bb4ef30c38e33fa43eb2f721b20f12db1d0da1"
variant: "minimal"
- location: "https://cloud-images.ubuntu.com/minimal/releases/resolute/release-20260717/ubuntu-26.04-minimal-cloudimg-arm64.img"
arch: "aarch64"
digest: "sha256:a8ef92cccd88427e74655cef83ac4ab72f64e961788fef4eae6a365975fe03ad"
variant: "minimal"
# Fallback minimal images
- location: "https://cloud-images.ubuntu.com/minimal/releases/resolute/release/ubuntu-26.04-minimal-cloudimg-amd64.img"
arch: "x86_64"
variant: "minimal"
- location: "https://cloud-images.ubuntu.com/minimal/releases/resolute/release/ubuntu-26.04-minimal-cloudimg-arm64.img"
arch: "aarch64"
variant: "minimal"
# # NOTE: Intel Mac with macOS prior to 15.5 requires setting vmType to qemu
# # https://github.com/lima-vm/lima/issues/3334
# vmType: qemu
# Do not require Lima NICs for systemd-networkd-wait-online: the first-boot
# rename to eth0 fails on 26.04 (LP: #2136392) and would block boot for 120s.
# Lima's cidata bootcmd renames the NIC and waits for it instead.
param:
internal_netplanOptional: "true"
provision:
# 1. Base packages
- mode: system
script: |
#!/bin/bash
set -eux
export DEBIAN_FRONTEND=noninteractive
apt-get update -y
apt-get install -y iptables ipset dnsutils cron python3 python3-pip python3-venv
systemctl enable --now cron
# 2. Pin DNS resolution to fixed, trusted resolvers (Quad9 primary, Cloudflare
# secondary). Must run before the firewall step locks DNS egress down to
# these same two IPs, so dig/apt/etc. keep resolving during provisioning.
- mode: system
script: |
#!/bin/bash
set -eu
mkdir -p /etc/systemd/resolved.conf.d
cat > /etc/systemd/resolved.conf.d/claudevm-dns.conf << 'EOF'
[Resolve]
DNS=9.9.9.9 1.1.1.1
FallbackDNS=
Domains=~.
EOF
systemctl restart systemd-resolved
# Also pin the default interface directly, so the DHCP-supplied resolver
# is overridden immediately rather than merely out-prioritized.
IFACE="$(ip route show default | awk '/default/ {print $5; exit}')"
if [ -n "$IFACE" ]; then
resolvectl dns "$IFACE" 9.9.9.9 1.1.1.1
resolvectl domain "$IFACE" "~."
fi
# 3. Default-deny egress policy (rebuilt fresh every boot)
- mode: system
script: |
#!/bin/bash
set -eu
iptables -F OUTPUT
iptables -P OUTPUT DROP
iptables -A OUTPUT -o lo -j ACCEPT
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -p udp --dport 53 -d 9.9.9.9 -j ACCEPT
iptables -A OUTPUT -p udp --dport 53 -d 1.1.1.1 -j ACCEPT
iptables -A OUTPUT -p tcp --dport 53 -d 9.9.9.9 -j ACCEPT
iptables -A OUTPUT -p tcp --dport 53 -d 1.1.1.1 -j ACCEPT
# IPv6: no allowlist mechanism exists for v6 (DNS is pinned to IPv4
# resolvers and the allowlist refresh only resolves A records), so
# deny all IPv6 egress except loopback rather than leave it wide open.
if command -v ip6tables >/dev/null 2>&1 && [ -d /proc/sys/net/ipv6 ]; then
ip6tables -F OUTPUT || true
ip6tables -P OUTPUT DROP || true
ip6tables -A OUTPUT -o lo -j ACCEPT || true
# REJECT (not DROP) so anything trying IPv6 first fails fast with an
# ICMPv6 "administratively prohibited" instead of waiting on a SYN
# timeout before falling back to IPv4. The DROP policy above stays
# as a backstop in case this rule is ever removed independently.
ip6tables -A OUTPUT -j REJECT --reject-with icmp6-adm-prohibited || true
fi
# 4. Install the allowlist-refresh script, run it once, and schedule it
- mode: system
script: |
#!/bin/bash
set -eu
cat > /usr/local/bin/update-firewall.sh << 'SCRIPT'
#!/bin/bash
set -eu
ALLOWLIST="/mnt/claudevm-config/allowlist.txt"
SET_NAME="claudevm-allowed"
ipset create "$SET_NAME" hash:ip timeout 300 -exist
ipset flush "$SET_NAME"
while IFS= read -r domain; do
domain="$(echo "$domain" | sed 's/#.*//' | xargs)"
[ -z "$domain" ] && continue
for ip in $(dig +short "$domain" A | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'); do
ipset add "$SET_NAME" "$ip" timeout 300 -exist
done
done < "$ALLOWLIST"
# Keep the catch-all REJECT last: delete it, ensure the ipset ACCEPT
# rule exists, then re-append REJECT -- self-correcting on every run
# regardless of prior chain state, so it can never end up ordered
# before the ACCEPT rule (which would silently break the allowlist).
iptables -D OUTPUT -j REJECT --reject-with icmp-host-prohibited 2>/dev/null || true
iptables -C OUTPUT -m set --match-set "$SET_NAME" dst -j ACCEPT 2>/dev/null || \
iptables -A OUTPUT -m set --match-set "$SET_NAME" dst -j ACCEPT
iptables -A OUTPUT -j REJECT --reject-with icmp-host-prohibited
SCRIPT
chmod +x /usr/local/bin/update-firewall.sh
echo '*/5 * * * * root /usr/local/bin/update-firewall.sh >>/var/log/update-firewall.log 2>&1' \
> /etc/cron.d/claudevm-firewall
/usr/local/bin/update-firewall.sh
# 5. Seed dotfiles from the host, if `claudevm new` copied them in. Only
# ever placed once (skipped if the guest already has one), so later
# in-VM edits survive reboots instead of being overwritten each time.
- mode: user
script: |
#!/bin/bash
set -eu
CONFIG=/mnt/claudevm-config
if [ -f "$CONFIG/gitconfig" ] && [ ! -f ~/.gitconfig ]; then
cp "$CONFIG/gitconfig" ~/.gitconfig
# A host-wide credential.helper (e.g. macOS's osxkeychain) won't
# exist on Linux; URL-scoped helpers (secrets' own github.com one,
# or others the host config may set) are untouched.
git config --global --unset credential.helper 2>/dev/null || true
fi
if [ -f "$CONFIG/vimrc" ] && [ ! -f ~/.vimrc ]; then
cp "$CONFIG/vimrc" ~/.vimrc
fi
# 6. Wire up per-VM secrets (Claude token, GitHub API token), scoped to
# this VM only, never your real host credentials. Runs on every boot.
- mode: user
script: |
#!/bin/bash
set -eu
SECRETS=/mnt/claudevm-secrets
# Copy secrets from the mounted files into the environment and other
# config files. Use /etc/environment instead of ~/.bashrc because VS
# Code's Remote-SSH extension host is a non-interactive background process
# and won't source bashrc.
# If a given token is no longer present, delete it from any persistent
# files.
# Claude auth
sudo sed -i '/^CLAUDE_CODE_OAUTH_TOKEN=/d' /etc/environment
if [ -f "$SECRETS/claude_oauth_token.txt" ]; then
TOKEN="$(cat "$SECRETS/claude_oauth_token.txt")"
echo "CLAUDE_CODE_OAUTH_TOKEN=\"$TOKEN\"" | sudo tee -a /etc/environment > /dev/null
fi
# Github auth
sudo sed -i '/^GH_TOKEN=/d' /etc/environment
git config --global --unset-all url."https://github.com/".insteadOf 2>/dev/null || true
git config --global --unset credential.https://github.com.helper 2>/dev/null || true
if [ -f "$SECRETS/gh_api_token.txt" ]; then
# GH_TOKEN: what `gh` and most GitHub API scripts expect.
GH_TOKEN="$(cat "$SECRETS/gh_api_token.txt")"
echo "GH_TOKEN=\"$GH_TOKEN\"" | sudo tee -a /etc/environment > /dev/null
# Make SSH-style remotes resolve over HTTPS instead, so existing
# git@github.com:... remotes work unmodified.
git config --global --add url."https://github.com/".insteadOf "git@github.com:"
git config --global --add url."https://github.com/".insteadOf "ssh://git@github.com/"
# Single-quoted $GH_TOKEN resolves per-invocation, so a rotated token
# takes effect without re-provisioning.
git config --global credential.https://github.com.helper \
'!f() { echo username=x-access-token; echo "password=$GH_TOKEN"; }; f'
fi