Skip to content

Commit 70abb3e

Browse files
committed
fix: make the firewall self-verify fail closed and match exact rules
Two defects that compounded: the verify file was written before the VERIFY_OK gate, and the Lima readiness probe treats its existence as 'firewall is up' — so a failed verification still produced a VM that limactl start reported ready. In the container sandbox the same exit 1 aborted entrypoint.sh and the container died; the equivalent here is to withhold the readiness signal, so the file is now written only after every check passes. The checks themselves could not be trusted either: the gateway-reject check grepped the human-readable listing for 'owner UID match <agent uid>', which the agent-to-Squid ACCEPT rule also contains, so it reported the reject as present whether or not it existed. The proxy check had the identical weakness. Both now match whole rule lines from iptables -S, and the suite asserts those specs directly rather than trusting the guest's self-report. sandbox-boot.sh traps ERR and touches a failure marker the probe watches, turning a broken boot from a silent 300s timeout into an immediate failure that names the step. Verified by patching one check in a copy of the live script to expect a nonexistent rule: exit 1, the specific failure named, no verify file written; and the probe exits non-zero in 0s with the marker present.
1 parent 0301db1 commit 70abb3e

6 files changed

Lines changed: 174 additions & 45 deletions

File tree

docs/superpowers/reviews/2026-07-31-code-review-findings.md

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ correctness angle, then an independent adversarial verifier per candidate
1010
location. 35 agents; all ten findings below survived verification as
1111
`CONFIRMED`. Line references were re-checked against the working tree after the
1212
review completed.
13-
**Status:** findings 6, 7 and 8 are **fixed** in `7b5209b`; finding 2 is **closed
14-
by removal**. The rest are open. Each section below carries its own status line.
13+
**Status:** findings 4, 5, 6, 7 and 8 are **fixed**; finding 2 is **closed by
14+
removal**. Findings 1, 3, 9 and 10 are open. Each section below carries its own
15+
status line; `git log` has the commits.
1516

1617
**Plus one issue the review did not surface.** `.sandbox-secrets.yaml` was read
1718
from the agent-writable workspace and its `source:` values executed **on the
@@ -36,8 +37,9 @@ must do differently.
3637
Most findings are **regressions relative to the Docker sandbox**, not novel
3738
bugs: places where porting a control from `entrypoint.sh` into
3839
`sandbox-boot.service` silently dropped a property the container version had.
39-
The two most serious still-open ones (1 and 4) are both of that shape. Finding 9
40-
is a decision rather than a defect and is marked as such.
40+
Findings 1 and 4 were the clearest cases — an unfiltered-egress window and a
41+
firewall check that stopped being fatal. Finding 9 is a decision rather than a
42+
defect and is marked as such.
4143

4244
---
4345

@@ -116,7 +118,20 @@ group to use from the GID that is actually present rather than assuming the name
116118
## 4. Firewall self-verification fails open instead of closed
117119

118120
**`internal/guest/files/scripts/init-firewall.sh:251`** (verify file written) vs
119-
**`:254`** (`VERIFY_OK` gate) — severity: high
121+
**`:254`** (`VERIFY_OK` gate) — severity: high — **FIXED**
122+
123+
*Fix as applied:* the verify file is written only after every check passes, so its
124+
existence is a real success signal and a failed verification withholds the
125+
readiness signal entirely — the equivalent of the container dying. It also gained
126+
a `VERIFY=ok` first line. Because the probe would otherwise only notice by timing
127+
out after 300 s, `sandbox-boot.sh` now traps `ERR` and touches
128+
`/run/sandbox-boot-failed`, which the probe watches so a broken boot fails
129+
immediately and names the step.
130+
131+
*Verified empirically,* not just by reading: patching one `has_rule` check in a
132+
copy of the live script to expect a nonexistent rule produced `exit=1`, the
133+
message `the DNS-tunneling UDP drop is missing`, and no verify file. The probe
134+
loop with the marker present exited non-zero in 0 s rather than 300 s.
120135

121136
The script writes `/run/firewall-verify` **before** it evaluates `VERIFY_OK` and
122137
exits 1. The Lima readiness probe only checks that the file *exists*. In the
@@ -130,13 +145,23 @@ success, and `code-vm` runs Claude in a VM whose firewall failed its own
130145
verification. Nothing host-side reads the file's *contents*; only the
131146
informational `status` and `firewall` commands do.
132147

133-
*Fix direction:* write the verify file only after `VERIFY_OK` passes (and have
134-
the probe or `ensureRunning` treat a failed `sandbox-boot.service` as fatal).
135-
This restores the fail-closed property without weakening any check.
148+
*Note:* the fix was to withhold the readiness signal, never to relax a check.
136149

137150
## 5. The gateway-REJECT check is satisfied by the Squid ACCEPT rule
138151

139-
**`internal/guest/files/scripts/init-firewall.sh:236`** — severity: medium
152+
**`internal/guest/files/scripts/init-firewall.sh:236`** — severity: medium — **FIXED**
153+
154+
*Fix as applied:* every check now matches a whole rule line from `iptables -S`
155+
via `grep -qxF`, so no other rule can satisfy it. The proxy-egress check had the
156+
identical weakness and was fixed with it. The suite additionally asserts the four
157+
rule specs directly against `iptables -S`, independent of the guest's
158+
self-report — the previous assertions only confirmed that the guest said "yes".
159+
160+
One thing worth recording: the first version of this fix routed each check
161+
through a helper called in a command substitution, where `VERIFY_OK=false` would
162+
have been set in a subshell and discarded — the verification would have passed
163+
unconditionally, reintroducing finding 4 while appearing to fix finding 5. The
164+
checks are spelled out individually for that reason.
140165

141166
`gw_reject` greps for any line containing `owner UID match $AGENT_UID`. The
142167
agent-to-Squid ACCEPT rule appended earlier (`:192`) matches that string too, so
@@ -257,14 +282,13 @@ loud warning.
257282

258283
## Remaining triage order
259284

260-
Findings 6, 7 and 8 are fixed; 2 is closed by removal. What is left, in the order
261-
I would take it:
285+
Findings 4, 5, 6, 7 and 8 are fixed; 2 is closed by removal. What is left, in the
286+
order I would take it:
262287

263-
1. **4** and **5** — self-verify defects; small, self-contained, and they restore
264-
guarantees the suite currently only appears to check.
265-
2. **3** — one-line-class fix that prevents an unusable VM for a large class of
288+
1. **3** — one-line-class fix that prevents an unusable VM for a large class of
266289
hosts (any host user whose primary GID collides with a stock guest group).
267-
3. **1** — drop `-l` from `run_as_agent`; removes an unfiltered-egress channel.
268-
4. **10** — re-add the boot-time API reachability warning.
269-
5. **9** — decide whether CI should run the VM suite now that GitHub runners
290+
Most likely of the remaining four to bite a real user.
291+
2. **1** — drop `-l` from `run_as_agent`; removes an unfiltered-egress channel.
292+
3. **10** — re-add the boot-time API reachability warning.
293+
4. **9** — decide whether CI should run the VM suite now that GitHub runners
270294
expose KVM.

internal/guest/files/lima/code-sandbox.yaml.tpl

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,24 @@ provision:
7272
exec /usr/local/lib/sandbox/provision-system.sh
7373

7474
probes:
75-
# init-firewall.sh writes /run/firewall-verify last. Waiting on it makes
76-
# `limactl start` return only once the egress firewall is actually up, so a
77-
# code-vm session can never land in an unfiltered VM.
75+
# init-firewall.sh writes /run/firewall-verify last, and only once its own
76+
# checks have passed. Waiting on it makes `limactl start` return solely when the
77+
# egress firewall is verified up, so a code-vm session can never land in an
78+
# unfiltered VM. sandbox-boot.sh touches the failure marker on any error, which
79+
# turns a broken boot into an immediate failure instead of a silent timeout.
7880
- mode: readiness
7981
description: sandbox boot sequence to finish
8082
script: |
8183
#!/bin/bash
8284
set -eu
83-
timeout 300s bash -c 'until [ -f /run/firewall-verify ]; do sleep 2; done'
85+
timeout 300s bash -c 'until [ -f /run/firewall-verify ]; do
86+
if [ -f /run/sandbox-boot-failed ]; then
87+
echo "the sandbox boot sequence failed; the firewall is not up" >&2
88+
exit 1
89+
fi
90+
sleep 2
91+
done'
8492
hint: |
85-
The sandbox boot sequence did not finish. Inspect it with:
93+
The sandbox boot sequence did not finish, so the egress firewall is not
94+
verified and the VM is deliberately not reported ready. Inspect it with:
8695
limactl shell code-sandbox sudo journalctl -u sandbox-boot.service

internal/guest/files/scripts/init-firewall.sh

Lines changed: 63 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -238,31 +238,81 @@ iptables -A OUTPUT -m limit --limit 5/min -j LOG --log-prefix "[FIREWALL-BLOCKED
238238
iptables -A OUTPUT -j REJECT --reject-with icmp-port-unreachable
239239

240240
# ── Self-verify ─────────────────────────────────────────────────────────────
241-
# The Lima readiness probe waits for this file, so `limactl start` cannot
242-
# return before the firewall is up.
241+
# Checks match whole rule specs from `iptables -S`, not substrings of the
242+
# human-readable listing. The loose form was actively misleading: the
243+
# agent-to-Squid ACCEPT rule added above also contains
244+
# "owner UID match <agent uid>", so grepping for that reported the gateway
245+
# REJECT as present even when it was gone.
246+
RULES=$(iptables -S OUTPUT)
247+
PROXY_UID=$(id -u proxy 2> /dev/null || echo 13)
248+
243249
VERIFY_OK=true
244-
OUTPUT_POLICY=$(iptables -L OUTPUT -n | head -1 | grep -o "DROP" || echo "NOT_DROP")
245-
[ "$OUTPUT_POLICY" = "DROP" ] || VERIFY_OK=false
250+
251+
# has_rule matches a complete rule line, so no other rule can satisfy a check.
252+
# Each check is spelled out rather than routed through a helper that returns its
253+
# result: a helper called in a command substitution runs in a subshell, where
254+
# setting VERIFY_OK=false would be silently discarded and the verification would
255+
# always pass.
256+
has_rule() { printf '%s\n' "$RULES" | grep -qxF -- "$1"; }
257+
258+
OUTPUT_POLICY=NOT_DROP
259+
if has_rule "-P OUTPUT DROP"; then
260+
OUTPUT_POLICY=DROP
261+
else
262+
echo "[firewall] ERROR: the OUTPUT policy is not DROP" >&2
263+
VERIFY_OK=false
264+
fi
246265

247266
udp_drop=no
248-
iptables -L OUTPUT -n | grep -qE "DROP[[:space:]]+17|DROP.*udp" && udp_drop=yes
249-
[ "$udp_drop" = yes ] || VERIFY_OK=false
267+
if has_rule "-A OUTPUT -p udp -j DROP"; then
268+
udp_drop=yes
269+
else
270+
echo "[firewall] ERROR: the DNS-tunneling UDP drop is missing" >&2
271+
VERIFY_OK=false
272+
fi
250273

251-
PROXY_UID=$(id -u proxy 2> /dev/null || echo 13)
252274
proxy_rule=no
253-
iptables -L OUTPUT -n -v | grep -q "owner UID match $PROXY_UID" && proxy_rule=yes
254-
[ "$proxy_rule" = yes ] || VERIFY_OK=false
275+
if has_rule "-A OUTPUT -m owner --uid-owner $PROXY_UID -j ACCEPT"; then
276+
proxy_rule=yes
277+
else
278+
echo "[firewall] ERROR: Squid's own egress rule (uid $PROXY_UID) is missing" >&2
279+
VERIFY_OK=false
280+
fi
255281

256-
gw_reject=no
282+
gw_reject=skipped
257283
if [ -n "$GATEWAY" ]; then
258-
iptables -L OUTPUT -n -v | grep -q "owner UID match $AGENT_UID" && gw_reject=yes
259-
[ "$gw_reject" = yes ] || VERIFY_OK=false
284+
gw_reject=no
285+
if has_rule "-A OUTPUT -d ${GATEWAY}/32 -m owner --uid-owner ${AGENT_UID} -j REJECT --reject-with icmp-port-unreachable"; then
286+
gw_reject=yes
287+
else
288+
echo "[firewall] ERROR: the agent-to-host-gateway reject ($GATEWAY) is missing" >&2
289+
VERIFY_OK=false
290+
fi
260291
fi
261292

262293
squid_running=no
263-
(echo > /dev/tcp/localhost/3128) 2> /dev/null && squid_running=yes
294+
if (echo > /dev/tcp/localhost/3128) 2> /dev/null; then
295+
squid_running=yes
296+
else
297+
echo "[firewall] ERROR: Squid is not listening on 3128" >&2
298+
VERIFY_OK=false
299+
fi
300+
301+
if [ "$VERIFY_OK" != true ]; then
302+
# Deliberately leave $VERIFY_FILE absent. The Lima readiness probe treats
303+
# its existence as "the firewall is up", so writing it before this gate —
304+
# as an earlier version did — let `limactl start` succeed and hand the
305+
# agent a VM whose firewall had failed its own checks. In the container
306+
# sandbox this same exit aborted entrypoint.sh and the container died; the
307+
# equivalent here is to withhold the readiness signal.
308+
echo "[firewall] ERROR: verification failed; refusing to report the firewall as ready." >&2
309+
exit 1
310+
fi
264311

312+
# Written only once every check above has passed, so its presence is a real
313+
# success signal rather than a record of whatever was found.
265314
{
315+
echo "VERIFY=ok"
266316
echo "OUTPUT_POLICY=$OUTPUT_POLICY"
267317
echo "UDP_DROP=$udp_drop"
268318
echo "PROXY_UID_RULE=$proxy_rule"
@@ -273,8 +323,4 @@ squid_running=no
273323
} > "$VERIFY_FILE"
274324
chmod 0444 "$VERIFY_FILE"
275325

276-
if [ "$VERIFY_OK" != true ]; then
277-
echo "[firewall] ERROR: verification failed; rules are incorrect."
278-
exit 1
279-
fi
280326
echo "[firewall] Active. DEFAULT DENY + Squid allowlist on :3128 (mode=$MODE)"

internal/guest/files/scripts/sandbox-boot.sh

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,20 @@
1212
###############################################################################
1313
set -euo pipefail
1414

15+
FAIL_MARKER=/run/sandbox-boot-failed
16+
rm -f "$FAIL_MARKER"
17+
18+
# init-firewall.sh withholds /run/firewall-verify when its own checks fail, and
19+
# the Lima readiness probe waits for that file — so a failure already blocks the
20+
# VM from being reported ready. Without this marker the probe would only find
21+
# out by timing out, several minutes later, with nothing to point at. Touching
22+
# it lets the probe fail immediately and name the step that broke.
23+
on_failure() {
24+
echo "[boot] FAILED at line $1 — the sandbox is not safe to use" >&2
25+
: > "$FAIL_MARKER"
26+
}
27+
trap 'on_failure $LINENO' ERR
28+
1529
echo "[boot] Sandbox boot sequence starting"
1630

1731
/usr/local/lib/sandbox/update-agent-clis.sh

internal/lima/testdata/golden-template.yaml

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,15 +84,24 @@ provision:
8484
exec /usr/local/lib/sandbox/provision-system.sh
8585
8686
probes:
87-
# init-firewall.sh writes /run/firewall-verify last. Waiting on it makes
88-
# `limactl start` return only once the egress firewall is actually up, so a
89-
# code-vm session can never land in an unfiltered VM.
87+
# init-firewall.sh writes /run/firewall-verify last, and only once its own
88+
# checks have passed. Waiting on it makes `limactl start` return solely when the
89+
# egress firewall is verified up, so a code-vm session can never land in an
90+
# unfiltered VM. sandbox-boot.sh touches the failure marker on any error, which
91+
# turns a broken boot into an immediate failure instead of a silent timeout.
9092
- mode: readiness
9193
description: sandbox boot sequence to finish
9294
script: |
9395
#!/bin/bash
9496
set -eu
95-
timeout 300s bash -c 'until [ -f /run/firewall-verify ]; do sleep 2; done'
97+
timeout 300s bash -c 'until [ -f /run/firewall-verify ]; do
98+
if [ -f /run/sandbox-boot-failed ]; then
99+
echo "the sandbox boot sequence failed; the firewall is not up" >&2
100+
exit 1
101+
fi
102+
sleep 2
103+
done'
96104
hint: |
97-
The sandbox boot sequence did not finish. Inspect it with:
105+
The sandbox boot sequence did not finish, so the egress firewall is not
106+
verified and the VM is deliberately not reported ready. Inspect it with:
98107
limactl shell code-sandbox sudo journalctl -u sandbox-boot.service

test-vm-sandbox.sh

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ echo ""
148148
echo "── Firewall ──────────────────────────────────────────────────────"
149149

150150
VERIFY=$(adm cat /run/firewall-verify)
151-
for kv in "OUTPUT_POLICY=DROP" "UDP_DROP=yes" "PROXY_UID_RULE=yes" \
151+
for kv in "VERIFY=ok" "OUTPUT_POLICY=DROP" "UDP_DROP=yes" "PROXY_UID_RULE=yes" \
152152
"AGENT_GATEWAY_REJECT=yes" "SQUID_RUNNING=yes"; do
153153
if echo "$VERIFY" | grep -qx "$kv"; then
154154
pass "firewall self-verify: $kv"
@@ -157,6 +157,33 @@ for kv in "OUTPUT_POLICY=DROP" "UDP_DROP=yes" "PROXY_UID_RULE=yes" \
157157
fi
158158
done
159159

160+
# The assertions above trust the guest's own report. These check the rules
161+
# directly, because a self-check can be wrong in the direction that matters:
162+
# the gateway-reject check used to match any rule mentioning the agent UID, so
163+
# the agent-to-Squid ACCEPT rule satisfied it and the report said "yes" whether
164+
# or not the reject existed.
165+
RULES=$(adm iptables -S OUTPUT)
166+
# shellcheck disable=SC2016 # awk program, expanded inside the guest
167+
GUEST_GW=$(adm sh -c 'ip route show default | awk "{print \$3; exit}"')
168+
GUEST_AGENT_UID=$(adm id -u "$AGENT_USER")
169+
GUEST_PROXY_UID=$(adm id -u proxy)
170+
171+
for spec in \
172+
"-P OUTPUT DROP" \
173+
"-A OUTPUT -p udp -j DROP" \
174+
"-A OUTPUT -m owner --uid-owner $GUEST_PROXY_UID -j ACCEPT" \
175+
"-A OUTPUT -d ${GUEST_GW}/32 -m owner --uid-owner ${GUEST_AGENT_UID} -j REJECT --reject-with icmp-port-unreachable"; do
176+
if echo "$RULES" | grep -qxF -- "$spec"; then
177+
pass "iptables rule present: $spec"
178+
else
179+
fail "iptables rule present: $spec"
180+
fi
181+
done
182+
183+
# A failed boot must leave no readiness signal and no stale failure marker.
184+
assert_fails "no boot-failure marker on a healthy VM" \
185+
adm test -e /run/sandbox-boot-failed
186+
160187
# No -f here: the API root returns 404, and for HTTPS a Squid denial breaks
161188
# the CONNECT tunnel itself, so any completed HTTP exchange proves reachability.
162189
assert_ok "allowlisted domain reachable through the proxy" \

0 commit comments

Comments
 (0)