Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
294 changes: 294 additions & 0 deletions docs/spikes/smolvm-isolation-findings.md

Large diffs are not rendered by default.

9 changes: 1 addition & 8 deletions phax.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,7 @@
"filesystem": {
"allowWrite": ["~/.phax"]
},
"agentCommands": [
"deno",
"ctx7",
"usage",
"pnpm gen:usage-spec",
"pnpm docs:cli",
"smolvm"
]
"agentCommands": ["deno", "ctx7", "usage", "pnpm gen:usage-spec", "pnpm docs:cli", "smolvm"]
},
"review": { "compliance": { "enabled": true } },
"publish": {
Expand Down
53 changes: 53 additions & 0 deletions spikes/smolvm/00-preflight.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/bin/sh
# 00-preflight.sh — verify smolvm is installed and report version/backend/arch
# Does NOT boot a VM. Exit non-zero if smolvm is missing.
set -eu

fail() {
printf 'ERROR: %s\n' "$1" >&2
exit 1
}

# Check smolvm is on PATH
if ! command -v smolvm > /dev/null 2>&1; then
fail "smolvm not found on PATH. Install it before running this spike.
See: https://github.com/wasm-forge/smolvm or your platform's package manager."
fi

printf '=== smolvm preflight ===\n\n'

# Version
# Note: use '%s\n' here — a format string beginning with "--" is parsed as an option
# by some printf implementations (bash builtin), producing "invalid option".
printf '%s\n' '-- smolvm version --'
smolvm --version

# Host architecture
printf '\n-- host arch --\n'
uname -m

# OS/kernel
printf '\n-- host OS --\n'
uname -s -r

# Resolved binary location
printf '\n-- smolvm binary --\n'
command -v smolvm

# Attempt to surface the VMM backend smolvm will use.
# smolvm may expose this via a subcommand or env var; probe common options.
printf '\n-- VMM backend (best effort) --\n'
if smolvm info > /dev/null 2>&1; then
smolvm info
elif smolvm config > /dev/null 2>&1; then
smolvm config
else
# Fall back to inspecting env hints
if [ -n "${SMOLVM_BACKEND:-}" ]; then
printf 'SMOLVM_BACKEND=%s\n' "$SMOLVM_BACKEND"
else
printf '(smolvm does not expose a backend info command; check docs for VMM selection)\n'
fi
fi

printf '\n=== preflight OK ===\n'
175 changes: 175 additions & 0 deletions spikes/smolvm/01-filesystem.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
#!/bin/sh
# 01-filesystem.sh — smolvm filesystem isolation probe
#
# Tests five properties of the microVM filesystem boundary:
# A. Mounted /workspace: sentinel visible from guest, writable, write-back to host
# B. Host $HOME not accessible from inside the guest
# C. Host repo root not accessible from inside the guest
# D. Host /etc content not leaking into guest (hostname / passwd comparison)
# E. Read-only mount: guest write is rejected (:ro suffix per smolvm -v flag)
#
# Run AFTER 00-preflight.sh confirms smolvm is installed.
# Paste the full output into findings/01-filesystem.md ## Results, then fill ## Verdict.
#
# smolvm flag reference (all used below):
# smolvm pack create -I <image> -o <path> bake image into a self-contained artifact
# smolvm machine run --from <artifact> boot the baked artifact (no pull, no network)
# -v HOST:CONTAINER[:ro] mount host dir into guest (optional :ro)
# -- COMMAND ARGS command to execute inside the guest
set -eu

IMAGE="alpine"
GUEST_WORKSPACE="/workspace"

# smolvm re-pulls the image on every ephemeral `machine run`, and the pull needs the
# network — so a deliberately network-free filesystem probe cannot boot `--image` directly
# (the pull fails with "network is unreachable"). We bake the image ONCE into a
# self-contained artifact (that single pull runs under plain `--net`) and boot every check
# from it with `--from`, which needs neither network nor an allowlist. The artifact is
# cached between runs; set SMOLVM_SPIKE_CACHE to relocate it, or delete it to force a rebake.
CACHE_DIR="${SMOLVM_SPIKE_CACHE:-${TMPDIR:-/tmp}/smolvm-spike}"
ARTIFACT_BIN="$CACHE_DIR/alpine.smolmachine"
ARTIFACT="$ARTIFACT_BIN.smolmachine" # `pack create` emits this .smolmachine sidecar; --from consumes it

ensure_artifact() {
if [ -f "$ARTIFACT" ]; then
printf 'Using cached artifact: %s\n' "$ARTIFACT"
return 0
fi
mkdir -p "$CACHE_DIR"
printf 'Baking %s artifact (one-time; pulls under --net)...\n' "$IMAGE"
smolvm pack create -I "$IMAGE" -o "$ARTIFACT_BIN" --no-sign
}

PASS=0
FAIL=0

pass() { printf 'PASS: %s\n' "$1"; PASS=$((PASS + 1)); }
fail() { printf 'FAIL: %s\n' "$1"; FAIL=$((FAIL + 1)); }
section() { printf '\n── %s ──\n' "$1"; }

printf '=== smolvm filesystem isolation probe ===\n'
printf 'Image: %s (baked artifact)\n' "$IMAGE"
printf 'Host arch: %s\n' "$(uname -m)"
printf 'Host OS: %s\n' "$(uname -s -r)"

ensure_artifact

# Marker files written into real host paths for checks B and C. Defined up front so the
# trap can remove them even if the probe is interrupted mid-check (not just WORK_DIR).
MARKER_NAME=".smolvm-probe-$$"
HOST_HOME="$HOME"
HOST_REPO_ROOT="$(pwd)"
HOME_MARKER="$HOST_HOME/$MARKER_NAME"
REPO_MARKER="$HOST_REPO_ROOT/$MARKER_NAME"

# Throwaway dir for the workspace mount; cleaned up on exit.
WORK_DIR=$(mktemp -d)
trap 'rm -rf "$WORK_DIR"; rm -f "$HOME_MARKER" "$REPO_MARKER"' EXIT INT TERM

# ── A. Workspace mount ─────────────────────────────────────────────────────
section "A. Workspace mount (-v HOST:/workspace)"

SENTINEL_VAL="spike-sentinel-$$"
printf '%s\n' "$SENTINEL_VAL" > "$WORK_DIR/sentinel.txt"
printf 'Sentinel written to host: %s/sentinel.txt\n' "$WORK_DIR"

smolvm machine run --from "$ARTIFACT" \
-v "$WORK_DIR:$GUEST_WORKSPACE" \
-- /bin/sh -c "
set -e
echo '--- guest /workspace contents ---'
ls -la /workspace/
echo '--- sentinel value ---'
cat /workspace/sentinel.txt
echo '--- writing writeback.txt from guest ---'
printf 'write-back-ok\n' > /workspace/writeback.txt
echo 'write succeeded'
"

if [ -f "$WORK_DIR/writeback.txt" ] && grep -q 'write-back-ok' "$WORK_DIR/writeback.txt"; then
pass "write-back visible on host after guest write"
else
fail "write-back NOT visible on host — guest writes may not propagate"
fi

# ── B. Host HOME invisible ─────────────────────────────────────────────────
section "B. Host HOME invisible"

printf 'host-home-probe\n' > "$HOME_MARKER"
printf 'Probing host path from guest: %s\n' "$HOME_MARKER"

smolvm machine run --from "$ARTIFACT" \
-v "$WORK_DIR:$GUEST_WORKSPACE" \
-- /bin/sh -c "
echo '--- ls of host HOME path: $HOST_HOME ---'
ls '$HOST_HOME' 2>&1 && echo 'VISIBLE' || echo 'NOT VISIBLE (expected)'
echo '--- cat of unique marker file ---'
cat '$HOME_MARKER' 2>&1 && echo 'VISIBLE' || echo 'NOT VISIBLE (expected)'
"

rm -f "$HOME_MARKER"

# ── C. Host repo root invisible ────────────────────────────────────────────
section "C. Host repo root invisible"

printf 'repo-probe\n' > "$REPO_MARKER"
printf 'Probing host path from guest: %s\n' "$REPO_MARKER"

smolvm machine run --from "$ARTIFACT" \
-v "$WORK_DIR:$GUEST_WORKSPACE" \
-- /bin/sh -c "
echo '--- ls of host repo root: $HOST_REPO_ROOT ---'
ls '$HOST_REPO_ROOT' 2>&1 && echo 'VISIBLE' || echo 'NOT VISIBLE (expected)'
echo '--- cat of unique marker file ---'
cat '$REPO_MARKER' 2>&1 && echo 'VISIBLE' || echo 'NOT VISIBLE (expected)'
"

rm -f "$REPO_MARKER"

# ── D. Host /etc not leaking into guest ───────────────────────────────────
section "D. Host /etc not leaking into guest"

HOST_HOSTNAME=$(hostname)
printf 'Host hostname (for comparison): %s\n' "$HOST_HOSTNAME"

smolvm machine run --from "$ARTIFACT" \
-v "$WORK_DIR:$GUEST_WORKSPACE" \
-- /bin/sh -c "
echo '--- guest hostname ---'
hostname 2>/dev/null || cat /etc/hostname 2>/dev/null || echo '(hostname unavailable)'
echo '--- guest /etc/passwd (first 3 lines) ---'
head -3 /etc/passwd 2>/dev/null || echo '(no /etc/passwd)'
echo '--- guest /etc/hosts ---'
cat /etc/hosts 2>/dev/null || echo '(no /etc/hosts)'
echo '--- check for macOS /Users path in guest ---'
ls /Users 2>&1 || echo '/Users not present (expected on Linux guest)'
"

# ── E. Read-only mount ─────────────────────────────────────────────────────
section "E. Read-only mount (-v HOST:/workspace:ro)"

printf 'smolvm flag: -v %s:%s:ro\n' "$WORK_DIR" "$GUEST_WORKSPACE"
printf '(smolvm -v flag supports optional :ro suffix per CLI help)\n'

smolvm machine run --from "$ARTIFACT" \
-v "$WORK_DIR:$GUEST_WORKSPACE:ro" \
-- /bin/sh -c "
echo '--- /workspace contents (read-only mount) ---'
ls -la /workspace/
echo '--- attempting write to /workspace/ro-test.txt ---'
printf 'should-fail\n' > /workspace/ro-test.txt \
&& echo 'WRITE SUCCEEDED (unexpected — :ro not enforced)' \
|| echo 'WRITE REJECTED (expected — :ro is enforced)'
"

if [ -f "$WORK_DIR/ro-test.txt" ]; then
fail "read-only mount: guest write propagated to host — :ro not enforced"
else
pass "read-only mount: no write-through to host"
fi

# ── Summary ────────────────────────────────────────────────────────────────
printf '\n=== Probe complete: %d PASS, %d FAIL ===\n' "$PASS" "$FAIL"
printf 'Paste this output into findings/01-filesystem.md ## Results\n'
printf 'then fill in ## Verdict.\n'
132 changes: 132 additions & 0 deletions spikes/smolvm/02-network.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
#!/bin/sh
# 02-network.sh — smolvm per-domain network allowlist probe
#
# Five-case matrix that determines whether smolvm's --allow-host is:
# (a) deny-by-default egress filtering, and
# (b) a real egress boundary (not just DNS-name filtering).
#
# The DECISIVE test is case 3: if a non-allowed host reached by hard-coded IP
# succeeds, the allowlist is DNS/SNI filtering only, not a true egress boundary.
#
# Run AFTER 00-preflight.sh confirms smolvm is installed.
# Paste the full output into findings/02-network.md ## Results, then fill ## Verdict.
#
# Two things this script accounts for (learned by running it on smolvm 1.3.2):
# 1. smolvm applies --allow-host to the IMAGE PULL too, and an ephemeral `machine run`
# re-pulls every boot. So the Docker registry + CDN hosts must be on the allowlist or
# the pull dies with "no such host" before any case runs. They are added below; they do
# NOT affect case 3 (the blocked host's IP is still not allowlisted).
# 2. `apk add curl` cannot work under the allowlist (the package CDN is not allowed), so
# this probe uses Alpine's built-in busybox `wget` — no install, no extra egress.
#
# smolvm flag reference (all used below):
# smolvm machine run --image <image> ephemeral VM, cleaned up on exit
# --net enable networking (off by default)
# --allow-host <HOSTNAME> add hostname to egress allowlist
# --timeout <DURATION> kill the VM after DURATION (safety)
# -v HOST:CONTAINER[:ro] mount host dir into guest
# -e KEY=VALUE set guest env var
# -- COMMAND ARGS command to execute inside the guest
set -eu

IMAGE="alpine"

# The single domain we allow. Pick something lightweight and reliable.
ALLOWED_DOMAIN="example.com"

# A blocked domain (should be denied by the allowlist).
BLOCKED_DOMAIN="httpbin.org"

# Docker registry + CDN hosts the image pull needs while the allowlist is active. Unquoted
# on use so it word-splits into multiple --allow-host args (intentional).
REGISTRY_ALLOW="--allow-host index.docker.io --allow-host auth.docker.io --allow-host registry-1.docker.io --allow-host registry.docker.io --allow-host production.cloudfront.docker.com --allow-host production.cloudflare.docker.com --allow-host docker.io"

# Resolve a host IP using whichever resolver tool is present. Empty on failure.
resolve_ip() {
ip=$(nslookup "$1" 2>/dev/null | awk '/^Address: / { print $2; exit }')
if [ -z "$ip" ]; then
ip=$(dig +short "$1" A 2>/dev/null | head -1)
fi
printf '%s' "$ip"
}

BLOCKED_IP=$(resolve_ip "$BLOCKED_DOMAIN")
if [ -z "$BLOCKED_IP" ]; then
printf 'ERROR: could not resolve %s on host; case 3 (the decisive test) needs its IP.\n' "$BLOCKED_DOMAIN" >&2
exit 1
fi
ALLOWED_IP=$(resolve_ip "$ALLOWED_DOMAIN")

# Guest-side classifier (busybox wget). Distinguishes a real egress block (DNS or connect
# failure) from an application-layer HTTP error, which still proves the connection went
# through. Defined here as a literal (single quotes → no host expansion of $1/$MSG) and
# interpolated into the guest payload below.
GUEST_PROBE='
probe() {
# $1 = label, $2 = url
if wget -T 8 -q -O /dev/null "$2" 2>/tmp/werr; then
printf " %-32s REACHABLE (HTTP 2xx)\n" "$1"; return 0
fi
MSG=$(cat /tmp/werr 2>/dev/null)
case "$MSG" in
*"server returned error"*) printf " %-32s REACHABLE (connected; %s)\n" "$1" "$MSG" ;;
*"bad address"*) printf " %-32s BLOCKED (DNS denied: %s)\n" "$1" "$MSG" ;;
*"Connection refused"*|*"timed out"*|*"unreachable"*|*"timeout"*)
printf " %-32s BLOCKED (egress denied: %s)\n" "$1" "$MSG" ;;
*) printf " %-32s UNKNOWN (%s)\n" "$1" "$MSG" ;;
esac
}'

# Throwaway workspace dir so the VM boot has a writable mount (some images need it).
WORK_DIR=$(mktemp -d)
trap 'rm -rf "$WORK_DIR"' EXIT INT TERM

printf '=== smolvm per-domain network allowlist probe ===\n'
printf 'Image: %s\n' "$IMAGE"
printf 'Allowed domain: %s (IP %s)\n' "$ALLOWED_DOMAIN" "${ALLOWED_IP:-unresolved}"
printf 'Blocked domain: %s (IP %s)\n' "$BLOCKED_DOMAIN" "$BLOCKED_IP"
printf 'Host arch: %s\n' "$(uname -m)"
printf 'Host OS: %s\n' "$(uname -s -r)"
printf '\nThe matrix runs in a single VM boot under: --allow-host %s (+ registry hosts).\n' "$ALLOWED_DOMAIN"
printf 'Tool: busybox wget (built into Alpine; no apk install).\n'
printf '\nCase legend:\n'
printf ' case1 allowed host by NAME — does egress work at all?\n'
printf ' case2 blocked host by NAME — deny-by-default for unlisted hosts?\n'
printf ' case3 blocked host by RAW IP — DECISIVE: real boundary or DNS filter?\n'
printf ' case4 allowed host by RAW IP — enforcement layer (IP vs SNI/DNS)?\n'
printf ' case5 ICMP ping — TCP/UDP-only behaviour\n'

section() { printf '\n── %s ──\n' "$1"; }
section "Running matrix (single boot)"

# Only test case 4 if the allowed IP resolved.
CASE4_LINE=":"
if [ -n "$ALLOWED_IP" ]; then
CASE4_LINE="probe 'case4 allowed RAW IP' \"http://$ALLOWED_IP/\""
fi

smolvm machine run --image "$IMAGE" \
--net --allow-host "$ALLOWED_DOMAIN" $REGISTRY_ALLOW --timeout 120s \
-v "$WORK_DIR:/workspace" \
-- /bin/sh -c "$GUEST_PROBE
echo '[HTTP egress]'
probe 'case1 allowed by NAME' \"http://$ALLOWED_DOMAIN/\"
probe 'case2 blocked by NAME' \"http://$BLOCKED_DOMAIN/\"
probe 'case3 blocked RAW IP' \"http://$BLOCKED_IP/\"
$CASE4_LINE
echo '[ICMP]'
if ping -c 2 -W 3 $ALLOWED_DOMAIN >/dev/null 2>&1; then
echo ' case5 ICMP ping SUCCEEDED (unexpected — ICMP forwarded)'
else
echo ' case5 ICMP ping BLOCKED/UNSUPPORTED (expected — TCP/UDP only)'
fi
"

# ── Summary ────────────────────────────────────────────────────────────────────
printf '\n=== Probe complete ===\n'
printf '\nKey questions for ## Verdict:\n'
printf ' 1. Is egress deny-by-default? (case2 + case3 both BLOCKED?)\n'
printf ' 2. Is --allow-host a security boundary? (case3 — raw IP to a non-allowed\n'
printf ' host — BLOCKED? If REACHABLE, it is DNS-name filtering only, not a boundary.)\n'
printf ' 3. Enforcement layer? (case4 REACHABLE => IP-based; note the shared-CDN-IP caveat.)\n'
printf '\nPaste this output into findings/02-network.md ## Results, then fill ## Verdict.\n'
Loading