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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,24 @@ To start over with a clean sandbox, delete the container and run the installer a
container delete coderunner && ./install.sh
```

### Resource limits and workspace mounts

CPU and memory limits can be set with installer options or the `CODERUNNER_CPUS` and `CODERUNNER_MEMORY` environment variables:

```bash
./install.sh --cpus 4 --memory 8g
```

Additional host directories must be mounted under `/workspace`. They are read-only by default; add `:rw` only when the sandbox needs to write to the host:

```bash
./install.sh \
--mount "$HOME/projects/api:/workspace/api" \
--mount "$HOME/projects/output:/workspace/output:rw"
```

Extra mounts default to none. Resource and mount settings are fixed when the container is created; delete the container before changing them.

### Disable outbound network access

By default, code running in the sandbox has unrestricted network access. To run it on a host-only network with no internet access:
Expand Down
150 changes: 146 additions & 4 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,120 @@ install_container_tool() {
rm -f "$CONTAINER_INSTALLER"
}

CPUS="${CODERUNNER_CPUS:-8}"
MEMORY="${CODERUNNER_MEMORY:-4g}"
MOUNT_SPECS=()
VOLUME_ARGS=()

usage() {
cat <<'EOF'
Usage: ./install.sh [options]

Options:
--cpus N CPU count (default: 8)
--memory SIZE Memory limit, e.g. 4g or 8192m (default: 4g)
--mount HOST:TARGET[:ro|rw]
Mount a host directory under /workspace.
Mounts are read-only unless :rw is specified.
-h, --help Show this help
EOF
}

while [ "$#" -gt 0 ]; do
case "$1" in
--cpus)
[ "$#" -ge 2 ] || { echo "Error: --cpus requires a value." >&2; exit 2; }
CPUS="$2"
shift 2
;;
--memory)
[ "$#" -ge 2 ] || { echo "Error: --memory requires a value." >&2; exit 2; }
MEMORY="$2"
shift 2
;;
--mount)
[ "$#" -ge 2 ] || { echo "Error: --mount requires a value." >&2; exit 2; }
MOUNT_SPECS+=("$2")
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Error: unknown option '$1'." >&2
usage >&2
exit 2
;;
esac
done

if ! [[ "$CPUS" =~ ^[1-9][0-9]*$ ]]; then
echo "Error: CPU count must be a positive integer." >&2
exit 2
fi
if ! [[ "$MEMORY" =~ ^[1-9][0-9]*([KkMmGgTtPp][Bb]?)?$ ]]; then
echo "Error: memory must be a positive size such as 4g or 8192m." >&2
exit 2
fi
Comment thread
Copilot marked this conversation as resolved.
MEMORY=$(printf '%s' "$MEMORY" | tr '[:upper:]' '[:lower:]')
MEMORY=${MEMORY%b}

NORMALIZED_MOUNTS=()
for spec in ${MOUNT_SPECS[@]+"${MOUNT_SPECS[@]}"}; do
source_path=${spec%%:*}
remainder=${spec#*:}
if [ "$remainder" = "$spec" ] || [ -z "$source_path" ] || [ -z "$remainder" ]; then
echo "Error: mount must use HOST:TARGET[:ro|rw]." >&2
exit 2
fi
target_path=${remainder%%:*}
if [ "$target_path" = "$remainder" ]; then
mode="ro"
else
mode=${remainder#*:}
if [[ "$mode" == *:* ]]; then
echo "Error: mount must use HOST:TARGET[:ro|rw]." >&2
exit 2
fi
fi
if [ "$mode" != "ro" ] && [ "$mode" != "rw" ]; then
echo "Error: mount mode must be 'ro' or 'rw'." >&2
exit 2
fi
if [ ! -d "$source_path" ]; then
echo "Error: mount source is not a directory: $source_path" >&2
exit 2
fi
source_path=$(cd "$source_path" && pwd -P)
case "$target_path" in
/workspace|/workspace/*) ;;
*)
echo "Error: mount targets must be /workspace or a path below it." >&2
exit 2
;;
esac
if [[ "$target_path" == *"/../"* ]] || [[ "$target_path" == */.. ]] || [[ "$target_path" == *"//"* ]]; then
echo "Error: mount target contains an invalid path segment." >&2
exit 2
fi
for existing in ${NORMALIZED_MOUNTS[@]+"${NORMALIZED_MOUNTS[@]}"}; do
existing_target=${existing#*:}
existing_target=${existing_target%:*}
if [ "$existing_target" = "$target_path" ]; then
echo "Error: duplicate mount target: $target_path" >&2
exit 2
fi
done
NORMALIZED_MOUNTS+=("$source_path:$target_path:$mode")
if [ "$mode" = "ro" ]; then
VOLUME_ARGS+=(--volume "$source_path:$target_path:ro")
else
# Apple's --volume syntax represents writable mounts by omitting :ro.
VOLUME_ARGS+=(--volume "$source_path:$target_path")
fi
done

# Function to get current macOS version
get_macos_version() {
sw_vers -productVersion | awk -F. '{print $1 "." $2}'
Expand Down Expand Up @@ -194,10 +308,31 @@ wait_for_server() {
done
echo "❌ The container started but the MCP server did not respond within 120 seconds."
echo " Check the container logs with: container logs coderunner"
echo " If coderunner.local does not resolve, verify DNS setup with: container system property list"
echo " If coderunner.local does not resolve, verify domain = \"local\" in ~/.config/container/config.toml"
echo " and check the DNS service with: container system dns list"
return 1
}

CONFIG_FILE="$HOME/.coderunner/container-config"
desired_config="cpus=$CPUS"$'\n'"memory=$MEMORY"
if [ -n "${NORMALIZED_MOUNTS[*]-}" ]; then
sorted_mounts=$(printf '%s\n' ${NORMALIZED_MOUNTS[@]+"${NORMALIZED_MOUNTS[@]}"} | LC_ALL=C sort)
desired_config="${desired_config}"$'\n'"$(printf '%s\n' "$sorted_mounts" | sed 's/^/mount=/')"
fi
if container inspect coderunner &>/dev/null; then
if [ -f "$CONFIG_FILE" ]; then
if [ "$(cat "$CONFIG_FILE")" != "$desired_config" ]; then
echo "❌ Existing container was created with different resource or mount settings."
echo " Recreate it with: container delete coderunner && ./install.sh [options]"
exit 1
fi
elif [ "$desired_config" != $'cpus=8\nmemory=4g' ]; then
echo "❌ Existing container predates configurable resources and mounts."
echo " Recreate it with: container delete coderunner && ./install.sh [options]"
exit 1
fi
fi

# Stop any existing coderunner container
echo "Stopping any existing coderunner container..."
container stop coderunner 2>/dev/null || true
Expand All @@ -221,6 +356,11 @@ print(networks[0]["network"] if networks else "default")
exit $?
fi
fi
if container inspect coderunner &>/dev/null; then
echo "❌ Existing coderunner container could not be started."
echo " Check its logs, or recreate it with: container delete coderunner && ./install.sh [options]"
exit 1
fi

echo "Pulling the latest image: instavm/coderunner"
if ! container image pull instavm/coderunner; then
Expand All @@ -229,16 +369,18 @@ if ! container image pull instavm/coderunner; then
fi

# Run the command to start the sandbox container
echo "Running: container run --volume \"$ASSETS_SRC/skills/user:/app/uploads/skills/user\" --volume \"$ASSETS_SRC/outputs:/app/uploads/outputs\" --name coderunner --detach --cpus 8 --memory 4g instavm/coderunner"
echo "Starting coderunner with $CPUS CPUs and $MEMORY memory..."
if container run \
--volume "$ASSETS_SRC/skills/user:/app/uploads/skills/user" \
--volume "$ASSETS_SRC/outputs:/app/uploads/outputs" \
${VOLUME_ARGS[@]+"${VOLUME_ARGS[@]}"} \
--name coderunner \
--detach \
--cpus 8 \
--memory 4g \
--cpus "$CPUS" \
--memory "$MEMORY" \
${NETWORK_ARGS[@]+"${NETWORK_ARGS[@]}"} \
instavm/coderunner; then
printf '%s' "$desired_config" > "$CONFIG_FILE"
wait_for_server
exit $?
else
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ openai

requests>=2.33.0

mcp[cli]
mcp[cli]>=1.26,<2

fastmcp

Expand Down
46 changes: 41 additions & 5 deletions server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@
import json
import logging
import os
import zipfile
import pathlib
import shutil
import stat
import time
import uuid
import zipfile
from typing import Dict, Optional, Set
from dataclasses import dataclass, field
from enum import Enum
from datetime import datetime, timedelta
from urllib.parse import urlsplit

import aiofiles
import websockets
Expand All @@ -35,7 +38,9 @@
# Extra hostnames (comma-separated) that may be used to reach this server,
# e.g. a LAN IP or a custom DNS name.
EXTRA_ALLOWED_HOSTNAMES = [
h.strip() for h in os.environ.get("CODERUNNER_EXTRA_HOSTS", "").split(",") if h.strip()
h.strip().lower()
for h in os.environ.get("CODERUNNER_EXTRA_HOSTS", "").split(",")
if h.strip()
]

# Configure DNS rebinding protection to allow coderunner.local
Expand Down Expand Up @@ -600,7 +605,23 @@ def _extract_skill_archive(archive_path: pathlib.Path) -> None:
target = (destination / member.filename).resolve()
if not target.is_relative_to(destination):
raise ValueError(f"Unsafe path in skill archive: {member.filename}")
archive.extractall(destination)

mode = member.external_attr >> 16
file_type = stat.S_IFMT(mode)
if file_type not in (0, stat.S_IFREG, stat.S_IFDIR):
raise ValueError(f"Unsupported file type in skill archive: {member.filename}")

for member in archive.infolist():
target = destination / member.filename
if member.is_dir():
target.mkdir(parents=True, exist_ok=True)
continue

target.parent.mkdir(parents=True, exist_ok=True)
if target.is_symlink():
raise ValueError(f"Unsafe symlink target in skill archive: {member.filename}")
with archive.open(member, "r") as source, target.open("wb") as output:
shutil.copyfileobj(source, output)


@mcp.tool()
Expand Down Expand Up @@ -790,6 +811,21 @@ async def report_progress(self, progress: int, message: str):
ALLOWED_HOSTNAMES = {"localhost", "127.0.0.1", "coderunner.local", "0.0.0.0", *EXTRA_ALLOWED_HOSTNAMES}


def _header_hostname(value: str, *, origin: bool = False) -> Optional[str]:
try:
parsed = urlsplit(value if origin else f"//{value}")
if origin and parsed.scheme.lower() not in {"http", "https"}:
return None
if parsed.username is not None or parsed.password is not None:
return None
if parsed.path or parsed.query or parsed.fragment:
return None
parsed.port
return parsed.hostname.lower() if parsed.hostname else None
except ValueError:
return None


class HostOriginValidator:
def __init__(self, asgi_app):
self.asgi_app = asgi_app
Expand All @@ -804,12 +840,12 @@ async def __call__(self, scope, receive, send):
@staticmethod
def _is_allowed(scope) -> bool:
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]}
host = headers.get("host", "").rsplit(":", 1)[0]
host = _header_hostname(headers.get("host", ""))
if host not in ALLOWED_HOSTNAMES:
return False
origin = headers.get("origin")
if origin:
origin_host = origin.split("://", 1)[-1].rsplit(":", 1)[0]
origin_host = _header_hostname(origin, origin=True)
if origin_host not in ALLOWED_HOSTNAMES:
return False
return True
Expand Down
30 changes: 17 additions & 13 deletions test-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -83,26 +83,30 @@ fi

# 8. Zip archives cannot write outside the user skills directory
if [ -d "$SKILLS_DIR" ]; then
marker="$(dirname "$SKILLS_DIR")/zip-slip-check"
rm -f "$marker"
python3 - "$SKILLS_DIR/unsafe.zip" <<'PY'
if [ -z "$sid" ]; then
check "skill archive traversal is rejected (no MCP session)" "session" "none"
else
marker="$(dirname "$SKILLS_DIR")/zip-slip-check"
rm -f "$marker"
python3 - "$SKILLS_DIR/unsafe.zip" <<'PY'
import sys
import zipfile

with zipfile.ZipFile(sys.argv[1], "w") as archive:
archive.writestr("../zip-slip-check", "unsafe")
PY
curl -s -o /dev/null -X POST "$BASE/mcp" \
-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-H "mcp-session-id: $sid" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_skills","arguments":{}}}'
if [ -e "$marker" ]; then
check "skill archive traversal is rejected" "rejected" "leaked"
rm -f "$marker"
else
check "skill archive traversal is rejected" "rejected" "rejected"
curl -s -o /dev/null -X POST "$BASE/mcp" \
-H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \
-H "mcp-session-id: $sid" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_skills","arguments":{}}}'
if [ -e "$marker" ]; then
check "skill archive traversal is rejected" "rejected" "leaked"
rm -f "$marker"
else
check "skill archive traversal is rejected" "rejected" "rejected"
fi
rm -f "$SKILLS_DIR/unsafe.zip"
fi
rm -f "$SKILLS_DIR/unsafe.zip"
fi

# 9. Jupyter must not be reachable from outside the container
Expand Down
Loading