From e74fc29b829d98e9166eaa5943c58d12716b2eb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 01:08:23 +0000 Subject: [PATCH 1/2] fix: vision(#623): disinto-chat sandbox hardening (#706) Co-Authored-By: Claude Opus 4.6 (1M context) --- docker/chat/Dockerfile | 8 +- docker/chat/entrypoint-chat.sh | 10 +++ lib/generators.sh | 11 ++- tools/edge-control/verify-chat-sandbox.sh | 105 ++++++++++++++++++++++ 4 files changed, 131 insertions(+), 3 deletions(-) create mode 100755 tools/edge-control/verify-chat-sandbox.sh diff --git a/docker/chat/Dockerfile b/docker/chat/Dockerfile index 194cee4..81aebbe 100644 --- a/docker/chat/Dockerfile +++ b/docker/chat/Dockerfile @@ -15,8 +15,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ && rm -rf /var/lib/apt/lists/* -# Non-root user -RUN useradd -m -u 1000 -s /bin/bash chat +# Non-root user — fixed UID 10001 for sandbox hardening (#706) +RUN useradd -m -u 10001 -s /bin/bash chat # Copy application files COPY server.py /usr/local/bin/server.py @@ -28,4 +28,8 @@ RUN chmod +x /entrypoint-chat.sh /usr/local/bin/server.py USER chat WORKDIR /var/chat +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/')" || exit 1 + ENTRYPOINT ["/entrypoint-chat.sh"] diff --git a/docker/chat/entrypoint-chat.sh b/docker/chat/entrypoint-chat.sh index a735d5d..0083348 100755 --- a/docker/chat/entrypoint-chat.sh +++ b/docker/chat/entrypoint-chat.sh @@ -12,6 +12,16 @@ log() { printf '[%s] %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" | tee -a "$LOGFILE" } +# Sandbox sanity checks (#706) — fail fast if isolation is broken +if [ -e /var/run/docker.sock ]; then + log "FATAL: /var/run/docker.sock is accessible — sandbox violation" + exit 1 +fi +if [ "$(id -u)" = "0" ]; then + log "FATAL: running as root (uid 0) — sandbox violation" + exit 1 +fi + # Verify Claude CLI is available (expected via volume mount from host). if ! command -v claude &>/dev/null; then log "FATAL: claude CLI not found in PATH" diff --git a/lib/generators.sh b/lib/generators.sh index 8fb0d69..9ea6c7f 100644 --- a/lib/generators.sh +++ b/lib/generators.sh @@ -475,14 +475,23 @@ services: # Chat container — Claude chat UI backend (#705) # Internal service only; edge proxy routes to chat:8080 + # Sandbox hardened per #706 — no docker.sock, read-only rootfs, minimal caps chat: build: context: ./docker/chat dockerfile: Dockerfile container_name: disinto-chat restart: unless-stopped + read_only: true + tmpfs: + - /tmp:size=64m security_opt: - - apparmor=unconfined + - no-new-privileges:true + cap_drop: + - ALL + pids_limit: 128 + mem_limit: 512m + memswap_limit: 512m volumes: # Mount claude binary from host (same as agents) - CLAUDE_BIN_PLACEHOLDER:/usr/local/bin/claude:ro diff --git a/tools/edge-control/verify-chat-sandbox.sh b/tools/edge-control/verify-chat-sandbox.sh new file mode 100755 index 0000000..b59c306 --- /dev/null +++ b/tools/edge-control/verify-chat-sandbox.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +set -euo pipefail + +# verify-chat-sandbox.sh — One-shot sandbox verification for disinto-chat (#706) +# +# Runs against a live compose project and asserts hardening constraints. +# Exit 0 if all pass, non-zero otherwise. + +CONTAINER="disinto-chat" +PASS=0 +FAIL=0 + +pass() { printf ' ✓ %s\n' "$1"; PASS=$((PASS + 1)); } +fail() { printf ' ✗ %s\n' "$1"; FAIL=$((FAIL + 1)); } + +echo "=== disinto-chat sandbox verification ===" +echo + +# --- docker inspect checks --- + +inspect_json=$(docker inspect "$CONTAINER" 2>/dev/null) || { + echo "ERROR: container '$CONTAINER' not found or not running" + exit 1 +} + +# ReadonlyRootfs +readonly_rootfs=$(echo "$inspect_json" | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['HostConfig']['ReadonlyRootfs'])") +if [ "$readonly_rootfs" = "True" ]; then + pass "ReadonlyRootfs=true" +else + fail "ReadonlyRootfs expected true, got $readonly_rootfs" +fi + +# CapAdd — should be null or empty +cap_add=$(echo "$inspect_json" | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['HostConfig']['CapAdd'])") +if [ "$cap_add" = "None" ] || [ "$cap_add" = "[]" ]; then + pass "CapAdd=null (no extra capabilities)" +else + fail "CapAdd expected null, got $cap_add" +fi + +# PidsLimit +pids_limit=$(echo "$inspect_json" | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['HostConfig']['PidsLimit'])") +if [ "$pids_limit" = "128" ]; then + pass "PidsLimit=128" +else + fail "PidsLimit expected 128, got $pids_limit" +fi + +# Memory limit (512MB = 536870912 bytes) +mem_limit=$(echo "$inspect_json" | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['HostConfig']['Memory'])") +if [ "$mem_limit" = "536870912" ]; then + pass "Memory=512m" +else + fail "Memory expected 536870912, got $mem_limit" +fi + +# SecurityOpt — must contain no-new-privileges +sec_opt=$(echo "$inspect_json" | python3 -c "import sys,json; opts=json.load(sys.stdin)[0]['HostConfig']['SecurityOpt'] or []; print(' '.join(opts))") +if echo "$sec_opt" | grep -q "no-new-privileges"; then + pass "SecurityOpt contains no-new-privileges" +else + fail "SecurityOpt missing no-new-privileges (got: $sec_opt)" +fi + +# No docker.sock bind mount +binds=$(echo "$inspect_json" | python3 -c "import sys,json; binds=json.load(sys.stdin)[0]['HostConfig']['Binds'] or []; print(' '.join(binds))") +if echo "$binds" | grep -q "docker.sock"; then + fail "docker.sock is bind-mounted" +else + pass "No docker.sock mount" +fi + +echo + +# --- runtime exec checks --- + +# touch /root/x should fail (read-only rootfs + unprivileged user) +if docker exec "$CONTAINER" touch /root/x 2>/dev/null; then + fail "touch /root/x succeeded (should fail)" +else + pass "touch /root/x correctly denied" +fi + +# /var/run/docker.sock must not exist +if docker exec "$CONTAINER" ls /var/run/docker.sock 2>/dev/null; then + fail "/var/run/docker.sock is accessible" +else + pass "/var/run/docker.sock not accessible" +fi + +# /etc/shadow should not be readable +if docker exec "$CONTAINER" cat /etc/shadow 2>/dev/null; then + fail "cat /etc/shadow succeeded (should fail)" +else + pass "cat /etc/shadow correctly denied" +fi + +echo +echo "=== Results: $PASS passed, $FAIL failed ===" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0 From 0c5bb09e16fd115c6a9e0d437c4ee99ab7fa120c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 01:19:42 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20move?= =?UTF-8?q?=20LOGFILE=20to=20tmpfs,=20add=20CapDrop=20check=20(#706)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LOGFILE=/var/chat/chat.log is unwritable on read-only rootfs; move to /tmp/chat.log (tmpfs-backed). Add CapDrop=ALL assertion to verify script so removing cap_drop from compose is caught. Co-Authored-By: Claude Opus 4.6 (1M context) --- docker/chat/entrypoint-chat.sh | 2 +- tools/edge-control/verify-chat-sandbox.sh | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docker/chat/entrypoint-chat.sh b/docker/chat/entrypoint-chat.sh index 0083348..00fbe53 100755 --- a/docker/chat/entrypoint-chat.sh +++ b/docker/chat/entrypoint-chat.sh @@ -6,7 +6,7 @@ set -euo pipefail # Exec-replace pattern: this script is the container entrypoint and runs # the server directly (no wrapper needed). Logs to stdout for docker logs. -LOGFILE="/var/chat/chat.log" +LOGFILE="/tmp/chat.log" log() { printf '[%s] %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" | tee -a "$LOGFILE" diff --git a/tools/edge-control/verify-chat-sandbox.sh b/tools/edge-control/verify-chat-sandbox.sh index b59c306..245d1da 100755 --- a/tools/edge-control/verify-chat-sandbox.sh +++ b/tools/edge-control/verify-chat-sandbox.sh @@ -39,6 +39,14 @@ else fail "CapAdd expected null, got $cap_add" fi +# CapDrop — should contain ALL +cap_drop=$(echo "$inspect_json" | python3 -c "import sys,json; caps=json.load(sys.stdin)[0]['HostConfig']['CapDrop'] or []; print(' '.join(caps))") +if echo "$cap_drop" | grep -q "ALL"; then + pass "CapDrop contains ALL" +else + fail "CapDrop expected ALL, got: $cap_drop" +fi + # PidsLimit pids_limit=$(echo "$inspect_json" | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['HostConfig']['PidsLimit'])") if [ "$pids_limit" = "128" ]; then