diff --git a/.mcp.json b/.mcp.json index d71108f978..8a587025cd 100644 --- a/.mcp.json +++ b/.mcp.json @@ -3,10 +3,6 @@ "svelte": { "type": "http", "url": "https://mcp.svelte.dev/mcp" - }, - "playwright": { - "command": "npx", - "args": ["@playwright/mcp@latest"] } } } \ No newline at end of file diff --git a/.workmux.yaml b/.workmux.yaml index 0b7d1707bc..c04f21642a 100644 --- a/.workmux.yaml +++ b/.workmux.yaml @@ -45,4 +45,4 @@ files: sandbox: enabled: false toolchain: off - host_commands: ["cargo", "npm", "npx", "node", "git"] + # image and host_commands configured in global ~/.config/workmux/config.yaml diff --git a/Dockerfile.sandbox b/Dockerfile.sandbox new file mode 100644 index 0000000000..0f8d3c2120 --- /dev/null +++ b/Dockerfile.sandbox @@ -0,0 +1,127 @@ +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + ca-certificates \ + git \ + iptables \ + gosu \ + sudo \ + && rm -rf /var/lib/apt/lists/* \ + && echo "ALL ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/sandbox \ + && chmod 0440 /etc/sudoers.d/sandbox \ + && chmod 666 /etc/passwd /etc/group /etc/shadow + +# Embed network init script (sets up iptables firewall, then drops privileges) +RUN cat <<'SCRIPT' > /usr/local/bin/network-init.sh +#!/bin/bash +set -euo pipefail + +if [ -n "${WM_PROXY_HOST:-}" ] && [ -n "${WM_PROXY_PORT:-}" ]; then + # Resolve hostnames to ALL IPs (multi-A records, round-robin DNS) + PROXY_IPS=$(getent ahostsv4 "$WM_PROXY_HOST" | awk '{print $1}' | sort -u) + RPC_HOST="${WM_RPC_HOST:-$WM_PROXY_HOST}" + RPC_IPS=$(getent ahostsv4 "$RPC_HOST" | awk '{print $1}' | sort -u) + + if [ -z "$PROXY_IPS" ] || [ -z "$RPC_IPS" ]; then + echo "network-init: failed to resolve proxy/RPC host" >&2 + exit 1 + fi + + # IPv4: default deny outbound + iptables -P OUTPUT DROP + iptables -A OUTPUT -o lo -j ACCEPT + iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT + + # Allow DNS (UDP/TCP 53) to configured nameservers. + # Without this, any hostname resolution hangs until timeout. + if [ -f /etc/resolv.conf ]; then + grep '^nameserver' /etc/resolv.conf | awk '{print $2}' | while read -r ns; do + iptables -A OUTPUT -d "$ns" -p udp --dport 53 -j ACCEPT + iptables -A OUTPUT -d "$ns" -p tcp --dport 53 -j ACCEPT + done + fi + + # Allow ALL resolved proxy IPs (handles multi-A DNS) + for ip in $PROXY_IPS; do + iptables -A OUTPUT -d "$ip" -p tcp --dport "$WM_PROXY_PORT" -j ACCEPT + done + + # Allow ALL resolved RPC IPs + if [ -n "${WM_RPC_PORT:-}" ]; then + for ip in $RPC_IPS; do + iptables -A OUTPUT -d "$ip" -p tcp --dport "$WM_RPC_PORT" -j ACCEPT + done + fi + + # Reject (not drop) everything else to fail fast instead of hanging + iptables -A OUTPUT -j REJECT + + # IPv6: block entirely to prevent leaks (fail closed) + if ip6tables -L -n >/dev/null 2>&1; then + ip6tables -P OUTPUT DROP + ip6tables -A OUTPUT -o lo -j ACCEPT + ip6tables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT + ip6tables -A OUTPUT -j REJECT + else + # If ip6tables unavailable, disable IPv6 via sysctl as fallback + if ! sysctl -w net.ipv6.conf.all.disable_ipv6=1 2>/dev/null; then + echo "network-init: failed to block IPv6 (neither ip6tables nor sysctl available)" >&2 + exit 1 + fi + fi +fi + +# Add sandbox user to passwd so sudo works with arbitrary UIDs. +if ! getent passwd "${WM_TARGET_UID}" >/dev/null 2>&1; then + echo "sandbox:x:${WM_TARGET_UID}:${WM_TARGET_GID}:sandbox:/tmp:/bin/sh" >> /etc/passwd + echo "sandbox:x:${WM_TARGET_GID}:" >> /etc/group 2>/dev/null || true +fi + +# Fix PTY ownership so the unprivileged user can read/write the terminal. +# Docker allocates the PTY as root; after gosu drops privileges, the user +# has no access to /dev/pts/0 unless we transfer ownership. +if [ -t 0 ]; then + chown "${WM_TARGET_UID}:${WM_TARGET_GID}" "$(tty)" +fi + +# Drop privileges and exec the user command. +# gosu resets HOME via getpwuid() which returns "/" for UIDs not in /etc/passwd. +# Preserve the container's HOME=/tmp so agents find their config dirs. +exec gosu "${WM_TARGET_UID}:${WM_TARGET_GID}" env HOME=/tmp "$@" +SCRIPT +RUN chmod +x /usr/local/bin/network-init.sh + +# Install workmux (needed for sandbox RPC) +RUN curl -fsSL https://raw.githubusercontent.com/raine/workmux/main/scripts/install.sh | bash + +# --- claude agent --- +# Install Claude Code, then move to a globally accessible path so the binary +# is reachable when running as an arbitrary non-root UID (--user uid:gid). +RUN curl -fsSL https://claude.ai/install.sh | bash && \ + target="$(readlink -f /root/.local/bin/claude)" && \ + mv /root/.local/share/claude /opt/claude && \ + ln -s "/opt/claude/versions/$(basename "$target")" /usr/local/bin/claude && \ + # Claude Code checks $HOME/.local/bin/claude when installMethod is "native". + # Container sets HOME=/tmp, so place a symlink to suppress the warning. + mkdir -p /tmp/.local/bin && \ + ln -s /usr/local/bin/claude /tmp/.local/bin/claude && \ + chmod 777 /tmp/.local + +# Entrypoint that registers the dynamic UID in /etc/passwd so sudo works. +# workmux passes --user UID:GID directly, bypassing network-init.sh. +RUN cat <<'ENTRY' > /usr/local/bin/entrypoint.sh +#!/bin/sh +if ! getent passwd "$(id -u)" >/dev/null 2>&1; then + echo "sandbox:x:$(id -u):$(id -g):sandbox:/tmp:/bin/sh" >> /etc/passwd +fi +if ! getent group "$(id -g)" >/dev/null 2>&1; then + echo "sandbox:x:$(id -g):" >> /etc/group +fi +if ! grep -q "^sandbox:" /etc/shadow 2>/dev/null; then + echo "sandbox:*:19000:0:99999:7:::" >> /etc/shadow +fi +exec "$@" +ENTRY +RUN chmod +x /usr/local/bin/entrypoint.sh +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] \ No newline at end of file