{
  "type": "guide",
  "id": "self-host-a-code-execution-api",
  "title": "Self-host a code-execution API for agents with Piston or Judge0",
  "summary": "Run Piston or Judge0 on your own server to give an agent a stateless run_code tool for many languages: what each one isolates, the host requirements that decide between them, the limits to set, and a tested tool wrapper.",
  "author": "Agentica Author",
  "tags": [
    "code-execution",
    "sandboxes",
    "self-hosted",
    "security",
    "agents",
    "tools"
  ],
  "published": "2026-10-30",
  "last_verified": "2026-10-30",
  "difficulty": "intermediate",
  "time_estimate": "45 min",
  "entries": [
    "piston",
    "judge0",
    "llm-sandbox",
    "e2b",
    "microsandbox"
  ],
  "links": {
    "html": "https://indexagentica.com/guides/self-host-a-code-execution-api/",
    "markdown": "https://indexagentica.com/guides/self-host-a-code-execution-api.md",
    "json": "https://indexagentica.com/api/longform/guides/self-host-a-code-execution-api.json",
    "source": "https://github.com/Drudley/indexagentica/blob/main/content-long/guides/self-host-a-code-execution-api.md"
  },
  "status": "published",
  "prerequisites": [
    "A Linux host where you can run privileged Docker containers",
    "For Piston, cgroup v2 with cgroup v1 disabled; for Judge0 v1.13.1, cgroup v1 (see below)",
    "Python 3.9+ for the tool wrapper"
  ],
  "entries_detail": [
    {
      "id": "piston",
      "name": "Piston",
      "summary": "Self-hostable, high-performance engine for running untrusted code in many languages via a simple REST API.",
      "url": "https://indexagentica.com/entries/piston/",
      "json": "https://indexagentica.com/api/entries/piston.json"
    },
    {
      "id": "judge0",
      "name": "Judge0",
      "summary": "Open-source sandboxed code execution system for 90+ languages; self-host or use Judge0 Cloud (via Rapid or direct).",
      "url": "https://indexagentica.com/entries/judge0/",
      "json": "https://indexagentica.com/api/entries/judge0.json"
    },
    {
      "id": "llm-sandbox",
      "name": "LLM Sandbox",
      "summary": "Python library and MCP server for running LLM-generated code in isolated backends (Docker, Kubernetes, Podman, Bubblewrap, NsJail, Windows) behind one API.",
      "url": "https://indexagentica.com/entries/llm-sandbox/",
      "json": "https://indexagentica.com/api/entries/llm-sandbox.json"
    },
    {
      "id": "e2b",
      "name": "E2B",
      "summary": "Open-source, secure cloud sandboxes for AI agents: an isolated machine per agent to run code, browse and use tools.",
      "url": "https://indexagentica.com/entries/e2b/",
      "json": "https://indexagentica.com/api/entries/e2b.json"
    },
    {
      "id": "microsandbox",
      "name": "microsandbox",
      "summary": "Easy, fast, programmable, local-first microVM runtime for untrusted workloads.",
      "url": "https://indexagentica.com/entries/microsandbox/",
      "json": "https://indexagentica.com/api/entries/microsandbox.json"
    }
  ],
  "related": [
    {
      "type": "guide",
      "id": "run-untrusted-code-in-a-sandbox",
      "title": "Run agent-generated code safely in a sandbox",
      "url": "https://indexagentica.com/guides/run-untrusted-code-in-a-sandbox/",
      "json": "https://indexagentica.com/api/longform/guides/run-untrusted-code-in-a-sandbox.json"
    },
    {
      "type": "comparison",
      "id": "code-sandboxes",
      "title": "Code sandboxes for AI agents",
      "url": "https://indexagentica.com/compare/code-sandboxes/",
      "json": "https://indexagentica.com/api/longform/compare/code-sandboxes.json"
    },
    {
      "type": "comparison",
      "id": "self-hosted-agent-sandboxes",
      "title": "Self-hosted sandboxes for agents compared",
      "url": "https://indexagentica.com/compare/self-hosted-agent-sandboxes/",
      "json": "https://indexagentica.com/api/longform/compare/self-hosted-agent-sandboxes.json"
    },
    {
      "type": "stack",
      "id": "coding-agent-starter",
      "title": "Coding agent starter stack",
      "url": "https://indexagentica.com/stacks/coding-agent-starter/",
      "json": "https://indexagentica.com/api/longform/stacks/coding-agent-starter.json"
    }
  ],
  "sources": [
    {
      "title": "Piston README",
      "url": "https://github.com/engineer-man/piston",
      "accessed": "2026-10-30"
    },
    {
      "title": "Piston docs, API v2",
      "url": "https://github.com/engineer-man/piston/blob/master/docs/api-v2.md",
      "accessed": "2026-10-30"
    },
    {
      "title": "Piston docs, Configuration",
      "url": "https://github.com/engineer-man/piston/blob/master/docs/configuration.md",
      "accessed": "2026-10-30"
    },
    {
      "title": "Piston source, api/src/config.js",
      "url": "https://github.com/engineer-man/piston/blob/master/api/src/config.js",
      "accessed": "2026-10-30"
    },
    {
      "title": "Judge0 README",
      "url": "https://github.com/judge0/judge0",
      "accessed": "2026-10-30"
    },
    {
      "title": "Judge0 v1.13.1 release notes",
      "url": "https://github.com/judge0/judge0/releases/tag/v1.13.1",
      "accessed": "2026-10-30"
    },
    {
      "title": "Judge0 CE API docs",
      "url": "https://ce.judge0.com/",
      "accessed": "2026-10-30"
    },
    {
      "title": "Judge0 TELEMETRY.md",
      "url": "https://github.com/judge0/judge0/blob/master/TELEMETRY.md",
      "accessed": "2026-10-30"
    },
    {
      "title": "GHSA-h9g2-45c8-89cf (CVE-2024-28185): Judge0 sandbox escape via symbolic link",
      "url": "https://github.com/judge0/judge0/security/advisories/GHSA-h9g2-45c8-89cf",
      "accessed": "2026-10-30"
    },
    {
      "title": "GHSA-3xpw-36v7-2cmg (CVE-2024-28189): Judge0 patch bypass via chown on a symbolic link",
      "url": "https://github.com/judge0/judge0/security/advisories/GHSA-3xpw-36v7-2cmg",
      "accessed": "2026-10-30"
    },
    {
      "title": "GHSA-q7vg-26pg-v5hr (CVE-2024-29021): Judge0 SSRF into sandbox escape",
      "url": "https://github.com/judge0/judge0/security/advisories/GHSA-q7vg-26pg-v5hr",
      "accessed": "2026-10-30"
    },
    {
      "title": "Isolate (ioi/isolate)",
      "url": "https://github.com/ioi/isolate",
      "accessed": "2026-10-30"
    }
  ],
  "front_matter": {
    "id": "self-host-a-code-execution-api",
    "type": "guide",
    "title": "Self-host a code-execution API for agents with Piston or Judge0",
    "summary": "Run Piston or Judge0 on your own server to give an agent a stateless run_code tool for many languages: what each one isolates, the host requirements that decide between them, the limits to set, and a tested tool wrapper.",
    "description": "Piston and Judge0 are open-source execute-and-return services built on the Isolate sandbox. They do not give an agent a machine with a filesystem; they run one program and return its output. This guide shows a tested Piston setup and a set of hostile probes with their real results, a stdlib Python run_code tool wrapper, Judge0's Docker Compose setup and what happened when we ran it on a cgroup v2 host, the 2024 Judge0 sandbox-escape CVEs, and the settings to change before you put either one in front of an agent.",
    "author": "Agentica Author",
    "difficulty": "intermediate",
    "time_estimate": "45 min",
    "prerequisites": [
      "A Linux host where you can run privileged Docker containers",
      "For Piston, cgroup v2 with cgroup v1 disabled; for Judge0 v1.13.1, cgroup v1 (see below)",
      "Python 3.9+ for the tool wrapper"
    ],
    "tags": [
      "code-execution",
      "sandboxes",
      "self-hosted",
      "security",
      "agents",
      "tools"
    ],
    "entries": [
      "piston",
      "judge0",
      "llm-sandbox",
      "e2b",
      "microsandbox"
    ],
    "sources": [
      {
        "title": "Piston README",
        "url": "https://github.com/engineer-man/piston",
        "accessed": "2026-10-30"
      },
      {
        "title": "Piston docs, API v2",
        "url": "https://github.com/engineer-man/piston/blob/master/docs/api-v2.md",
        "accessed": "2026-10-30"
      },
      {
        "title": "Piston docs, Configuration",
        "url": "https://github.com/engineer-man/piston/blob/master/docs/configuration.md",
        "accessed": "2026-10-30"
      },
      {
        "title": "Piston source, api/src/config.js",
        "url": "https://github.com/engineer-man/piston/blob/master/api/src/config.js",
        "accessed": "2026-10-30"
      },
      {
        "title": "Judge0 README",
        "url": "https://github.com/judge0/judge0",
        "accessed": "2026-10-30"
      },
      {
        "title": "Judge0 v1.13.1 release notes",
        "url": "https://github.com/judge0/judge0/releases/tag/v1.13.1",
        "accessed": "2026-10-30"
      },
      {
        "title": "Judge0 CE API docs",
        "url": "https://ce.judge0.com/",
        "accessed": "2026-10-30"
      },
      {
        "title": "Judge0 TELEMETRY.md",
        "url": "https://github.com/judge0/judge0/blob/master/TELEMETRY.md",
        "accessed": "2026-10-30"
      },
      {
        "title": "GHSA-h9g2-45c8-89cf (CVE-2024-28185): Judge0 sandbox escape via symbolic link",
        "url": "https://github.com/judge0/judge0/security/advisories/GHSA-h9g2-45c8-89cf",
        "accessed": "2026-10-30"
      },
      {
        "title": "GHSA-3xpw-36v7-2cmg (CVE-2024-28189): Judge0 patch bypass via chown on a symbolic link",
        "url": "https://github.com/judge0/judge0/security/advisories/GHSA-3xpw-36v7-2cmg",
        "accessed": "2026-10-30"
      },
      {
        "title": "GHSA-q7vg-26pg-v5hr (CVE-2024-29021): Judge0 SSRF into sandbox escape",
        "url": "https://github.com/judge0/judge0/security/advisories/GHSA-q7vg-26pg-v5hr",
        "accessed": "2026-10-30"
      },
      {
        "title": "Isolate (ioi/isolate)",
        "url": "https://github.com/ioi/isolate",
        "accessed": "2026-10-30"
      }
    ],
    "related": [
      "run-untrusted-code-in-a-sandbox",
      "code-sandboxes",
      "self-hosted-agent-sandboxes",
      "coding-agent-starter"
    ],
    "last_verified": "2026-10-30",
    "published": "2026-10-30"
  },
  "markdown": "\n## When an execute API is the right tool\n\nMost sandbox products give an agent a disposable machine: a filesystem it can keep writing to, a shell, packages it can install, sometimes a browser. [Run agent-generated code safely in a sandbox](https://indexagentica.com/guides/run-untrusted-code-in-a-sandbox/) and the comparisons of [hosted code sandboxes](https://indexagentica.com/compare/code-sandboxes/) and [self-hosted agent sandboxes](https://indexagentica.com/compare/self-hosted-agent-sandboxes/) cover that kind.\n\n[Piston](https://indexagentica.com/entries/piston/) and [Judge0](https://indexagentica.com/entries/judge0/) are a different, smaller thing. They take source code plus stdin, run it once under fixed limits, and return stdout, stderr, the exit code and timing. Nothing survives between calls. They came out of chat bots and online judges, so they support many languages and are built to run code from strangers.\n\nThat shape fits an agent tool when you want:\n\n- a **calculator-style tool** (\"run this snippet and tell me what it prints\") with no state to clean up,\n- **many languages** behind one HTTP call, for example to check a code answer in the language the user asked about,\n- **grading or testing** generated solutions against fixed inputs,\n- something you can run **on your own server** with no per-second billing.\n\nIt is the wrong shape when the agent needs to install a package, keep files between steps, clone a repository or run a long job. Use a machine-style sandbox for that.\n\nBoth projects put the [Isolate](https://github.com/ioi/isolate) sandbox (the one written for programming olympiads) inside a privileged Docker container. Isolate uses Linux namespaces, a chroot, a separate unprivileged uid per run and cgroups for limits. That is a container-class boundary on a kernel shared with the host, not a VM. Run them on a host that does nothing else important.\n\n## What was run for this guide\n\nOn 2026-10-30 we ran Piston (image `ghcr.io/engineer-man/piston`, built 2025-02-08) under Docker on a Linux 6.12 host with cgroup v2, installed Python 3.12.0, GCC 10.2.0, Bash 5.2.0 and Lua 5.4.4, and ran every probe and the wrapper below against it. The results quoted are from those runs.\n\nWe also brought up Judge0 CE v1.13.1 with its release Compose file on the same host. The API came up, but every submission failed with an internal error, because that release needs cgroup v1 (details below). So the Judge0 sections describe the API from its docs and its own `/config_info` output; we did not see a Judge0 submission succeed.\n\n## Pick by host first\n\nThe deciding fact is often your kernel's cgroup setup, not features.\n\n| | Piston | Judge0 CE v1.13.1 |\n|---|---|---|\n| License | MIT | GPL-3.0 |\n| Host requirement | cgroup v2 enabled and cgroup v1 disabled | cgroup v1: the release notes tell you to add `systemd.unified_cgroup_hierarchy=0` to GRUB on Ubuntu 22.04 and reboot |\n| Services | one container | four: API server, workers, PostgreSQL, Redis |\n| Request model | synchronous `POST /api/v2/execute`; WebSocket for interactive runs | queued submissions; poll by token, use `wait=true`, or a callback URL |\n| Languages | installed one by one as packages | fixed set in the image (47 in our CE v1.13.1 instance; Extra CE is a separate branch) |\n| Auth | none | optional token header, off by default |\n| Telemetry | none documented | on by default |\n| Last release | rolling `master` image | v1.13.1, 2024-04-18 |\n\nMost current distributions (Ubuntu 22.04 and later, Debian 12, Fedora) boot with cgroup v2 only. Check yours:\n\n```bash\nstat -fc %T /sys/fs/cgroup   # cgroup2fs means cgroup v2 only\n```\n\nOn a cgroup v2 host, Piston runs as documented and Judge0 v1.13.1 needs a kernel command-line change and a reboot. On a dedicated cgroup v1 host, either works.\n\n## Run Piston\n\nStart the API, bound to localhost only. The README's command publishes port 2000 on every interface; do not do that on a machine with a public address, because the API has no authentication.\n\n```bash\nmkdir -p piston-data\ndocker run --privileged -v \"$PWD/piston-data:/piston\" -dit \\\n  -p 127.0.0.1:2000:2000 \\\n  -e PISTON_RUN_MEMORY_LIMIT=268435456 \\\n  --name piston_api ghcr.io/engineer-man/piston\n```\n\n`--privileged` is required: Isolate needs it to create namespaces and cgroups inside the container. That is one more reason to give Piston its own host or VM.\n\nThe image has no languages. List what can be installed, install what you need, and check what is ready:\n\n```bash\ncurl -s localhost:2000/api/v2/packages     # every package, with \"installed\": true/false\ncurl -s -X POST localhost:2000/api/v2/packages \\\n  -H 'content-type: application/json' -d '{\"language\":\"python\",\"version\":\"3.12.0\"}'\ncurl -s localhost:2000/api/v2/runtimes     # what /execute can use now\n```\n\nInstalling Lua 5.4.4 took about 3 seconds here. The package endpoint is as unauthenticated as `/execute`, so anyone who can reach the API can install runtimes. Expose only `/api/v2/execute` to the agent (see the hardening list).\n\nRun code:\n\n```bash\ncurl -s -X POST localhost:2000/api/v2/execute -H 'content-type: application/json' -d '{\n  \"language\": \"python\", \"version\": \"3.12\",\n  \"files\": [{\"name\": \"main.py\", \"content\": \"import helper; print(helper.X)\"},\n            {\"name\": \"helper.py\", \"content\": \"X = 99\"}],\n  \"stdin\": \"\"\n}'\n```\n\n```json\n{\"run\":{\"signal\":null,\"stdout\":\"99\\n\",\"stderr\":\"\",\"code\":0,\"output\":\"99\\n\",\"memory\":3856000,\"message\":null,\"status\":null,\"cpu_time\":11,\"wall_time\":51},\"language\":\"python\",\"version\":\"3.12.0\"}\n```\n\n`version` is required and is a semver selector, so `\"3.12\"` matched 3.12.0. Leaving it out returns `400 {\"message\":\"version is required as a string\"}`. The first file is the one that runs. Compiled languages add a `compile` object with the same fields as `run`. `status` is null on success, or one of `RE` (runtime error), `SG` (signal), `TO` (timeout), `OL` (stdout too long), `EL` (stderr too long) or `XX` (internal error).\n\n## What the sandbox actually stopped\n\nWe sent these snippets with default limits, except where noted:\n\n| Probe | Result |\n|---|---|\n| `while True: pass` | `status: TO`, `SIGKILL`, \"Time limit exceeded (wall clock)\" at about 3.1 s |\n| `time.sleep(10)` | `TO` at 3.1 s, so wall time is limited, not just CPU |\n| TCP connect to `1.1.1.1:80` | `OSError: [Errno 101] Network is unreachable` |\n| DNS lookup of `example.com` | `gaierror: [Errno -3] Temporary failure in name resolution` |\n| Fork loop | stopped after 62 forks with `[Errno 11] Resource temporarily unavailable` |\n| Allocate 512 MB with `run_memory_limit` 256 MiB | killed, exit code 137, `memory` 268432000 |\n| Print 5,000 characters | `status: OL`, \"stdout length exceeded\", `SIGKILL` |\n| Write to `/tmp` | allowed, as an unprivileged uid; the file was gone on the next run |\n| List `/piston` | saw `['packages']`; writing to `/piston/packages` raised `PermissionError` |\n| C program with a compile error | `compile.status: RE`, exit code 1; the run stage never started |\n| `\"language\": \"cobol\"` | `400`, \"cobol-* runtime is unknown\" |\n\nThe defaults behind those numbers come from `api/src/config.js` and the configuration docs: networking disabled, 3 s run timeout and 3 s run CPU time, 10 s for compiling, 1,024 characters of output per stream, 64 processes, 2,048 open files, 10 MB maximum file size, and 64 concurrent jobs. The fork result (62 children plus the parent) matches the 64-process default. The README's security section still says processes are capped at 256 by default; the code and the configuration page say 64.\n\n**The memory default is \"no limit\".** `compile_memory_limit` and `run_memory_limit` default to `-1`. Set them with `PISTON_RUN_MEMORY_LIMIT` and `PISTON_COMPILE_MEMORY_LIMIT` (bytes). With no server limit, a request could ask for `run_memory_limit: 999999999999` and Piston ran it. With `PISTON_RUN_MEMORY_LIMIT=268435456` set, the same request got `400 {\"message\":\"run_memory_limit cannot exceed the configured limit of 268435456\"}`, and a 512 MB allocation was killed even when the request did not mention memory.\n\nPer-request limits can only go down. Asking for `run_timeout: 60000` against the default returned `400 {\"message\":\"run_timeout cannot exceed the configured limit of 3000\"}`. To allow more, raise the server value (for example `PISTON_RUN_TIMEOUT=10000`; a 5 s sleep then completed), or use `PISTON_LIMIT_OVERRIDES` to raise limits for one language only.\n\nThe 1,024-character output cap is small for an agent that wants to read a traceback. `PISTON_OUTPUT_MAX_SIZE=65536` let a 5,000-character print through. Truncate again in your wrapper so a chatty program does not fill the model's context.\n\nThe probe script, so you can rerun this against your own instance:\n\n```bash\n#!/usr/bin/env bash\n# piston-probe.sh: run hostile and normal snippets against a Piston API and print one line each.\nP=${PISTON_URL:-http://127.0.0.1:2000}\nrun() { # $1 label, $2 JSON body\n  printf '%-11s ' \"$1\"\n  curl -s -X POST \"$P/api/v2/execute\" -H 'content-type: application/json' -d \"$2\" |\n    python3 -c 'import sys,json; d=json.load(sys.stdin); r=d.get(\"run\",d); c=d.get(\"compile\") or {}\nprint(json.dumps({\"compile\":c.get(\"status\"),\"status\":r.get(\"status\"),\"code\":r.get(\"code\"),\"signal\":r.get(\"signal\"),\"stdout\":(r.get(\"stdout\") or \"\")[:70],\"stderr\":(r.get(\"stderr\") or \"\")[-90:],\"message\":r.get(\"message\"),\"wall_ms\":r.get(\"wall_time\"),\"mem\":r.get(\"memory\")}))'\n}\npy() { python3 -c 'import json,sys; print(json.dumps({\"language\":\"python\",\"version\":\"3.12\",\"files\":[{\"content\":sys.argv[1]}],**json.loads(sys.argv[2] if len(sys.argv)>2 else \"{}\")}))' \"$@\"; }\n\nrun hello     \"$(py 'print(\"hello from piston\")')\"\nrun timeout   \"$(py 'while True: pass')\"\nrun sleep     \"$(py 'import time; time.sleep(10)')\"\nrun network   \"$(py 'import socket; socket.create_connection((\"1.1.1.1\",80),timeout=3); print(\"NET OK\")')\"\nrun dns       \"$(py 'import socket; print(socket.gethostbyname(\"example.com\"))')\"\nrun forkbomb  \"$(py 'import os\nn=0\ntry:\n    while True:\n        if os.fork()==0:\n            import time; time.sleep(5); os._exit(0)\n        n+=1\nexcept OSError as e: print(\"forks before error:\",n,e)')\"\nrun memory    \"$(py 'b=bytearray(512*1024*1024); print(\"allocated\", len(b))' '{\"run_memory_limit\":268435456}')\"\nrun bigout    \"$(py 'print(\"x\"*5000)')\"\nrun write-tmp \"$(py 'open(\"/tmp/x\",\"w\").write(\"1\"); import os; print(os.getuid(), os.listdir(\"/tmp\"))')\"\nrun read-host \"$(py 'import os; print(os.listdir(\"/piston\"))')\"\nrun unknown   '{\"language\":\"cobol\",\"version\":\"*\",\"files\":[{\"content\":\"x\"}]}'\n```\n\n## Wrap it as an agent tool\n\nThe model should see one small tool with a fixed language list. The wrapper re-checks limits so the model cannot ask for more, and returns a compact result. This is standard-library Python and was run against the instance above; the schema is in the shape most function-calling APIs accept (rename `input_schema` to `parameters` where needed).\n\n```python\nimport json, os, urllib.request, urllib.error\n\nPISTON_URL = os.environ.get(\"PISTON_URL\", \"http://127.0.0.1:2000\")\nALLOWED = {\"python\": \"3.12\", \"bash\": \"5\", \"c\": \"10\"}  # language -> version selector\nMAX_CODE_BYTES = 64_000\nMAX_RETURN_CHARS = 4_000\n\nTOOL_SCHEMA = {\n    \"name\": \"run_code\",\n    \"description\": \"Run a short, self-contained program and return its output. \"\n                   \"No network, no persistent files, about 3 s of run time.\",\n    \"input_schema\": {\n        \"type\": \"object\",\n        \"properties\": {\n            \"language\": {\"type\": \"string\", \"enum\": sorted(ALLOWED)},\n            \"code\": {\"type\": \"string\"},\n            \"stdin\": {\"type\": \"string\"},\n        },\n        \"required\": [\"language\", \"code\"],\n    },\n}\n\ndef _post(path, body, timeout=30):\n    req = urllib.request.Request(PISTON_URL + path, data=json.dumps(body).encode(),\n                                 headers={\"content-type\": \"application/json\"})\n    try:\n        with urllib.request.urlopen(req, timeout=timeout) as r:\n            return json.load(r)\n    except urllib.error.HTTPError as e:\n        return {\"message\": json.load(e).get(\"message\", str(e))}\n\ndef run_code(language, code, stdin=\"\"):\n    if language not in ALLOWED:\n        return {\"ok\": False, \"error\": f\"language must be one of {sorted(ALLOWED)}\"}\n    if len(code.encode()) > MAX_CODE_BYTES:\n        return {\"ok\": False, \"error\": \"code too large\"}\n    res = _post(\"/api/v2/execute\", {\n        \"language\": language, \"version\": ALLOWED[language],\n        \"files\": [{\"content\": code}], \"stdin\": stdin or \"\",\n        \"run_timeout\": 3000, \"run_memory_limit\": 256 * 1024 * 1024,\n    })\n    if \"run\" not in res:   # 400: bad request, unknown runtime, limit above the server's\n        return {\"ok\": False, \"error\": res.get(\"message\", \"execution failed\")}\n    stage = res.get(\"compile\") if res.get(\"compile\", {}).get(\"code\") else res[\"run\"]\n    return {\n        \"ok\": stage[\"code\"] == 0 and stage[\"status\"] is None,\n        \"stage\": \"compile\" if stage is not res[\"run\"] else \"run\",\n        \"exit_code\": stage[\"code\"], \"signal\": stage[\"signal\"],\n        \"status\": stage[\"status\"], \"message\": stage[\"message\"],\n        \"stdout\": stage[\"stdout\"][:MAX_RETURN_CHARS],\n        \"stderr\": stage[\"stderr\"][:MAX_RETURN_CHARS],\n    }\n```\n\nWhat it returned for six test calls:\n\n| Call | Result |\n|---|---|\n| Python `print(sum(range(10)))` | `ok: true`, stdout `45` |\n| Python reading stdin `hi` | `ok: true`, stdout `HI` |\n| C with an undeclared variable | `ok: false`, `stage: compile`, `status: RE`, compiler error in stderr |\n| Python `while True: pass` | `ok: false`, `status: TO`, \"Time limit exceeded (wall clock)\" |\n| Python `urlopen(\"http://1.1.1.1\")` | `ok: false`, `status: RE`, \"Network is unreachable\" in stderr |\n| `language: \"ruby\"` | rejected by the wrapper before any request |\n\nTwo habits matter more than the code. Treat `stdout` and `stderr` as data the model reads, never as instructions to follow. And because Piston blocks the network, tell the model so in the tool description; otherwise it will try `pip install` and waste turns.\n\nIf you would rather call a library than run a service, [LLM Sandbox](https://indexagentica.com/entries/llm-sandbox/) runs code through Docker, Kubernetes, Podman, Bubblewrap or NsJail backends from Python and ships an MCP server.\n\n## Run Judge0\n\nJudge0's release deployment, from the v1.13.1 release notes:\n\n```bash\nwget https://github.com/judge0/judge0/releases/download/v1.13.1/judge0-v1.13.1.zip\nunzip judge0-v1.13.1.zip && cd judge0-v1.13.1\n# set REDIS_PASSWORD and POSTGRES_PASSWORD in judge0.conf to long random values\ndocker-compose up -d db redis\nsleep 10s\ndocker-compose up -d\n```\n\nThe Compose file runs four services. `server` and `workers` both use `judge0/judge0:1.13.1` with `privileged: true`, and the server publishes `2358:2358` on all interfaces. Change that to `127.0.0.1:2358:2358` unless a gateway sits in front.\n\nSubmit code and wait for the result in one call (`language_id` 71 is Python 3.8.1 in CE):\n\n```bash\ncurl -s -X POST 'localhost:2358/submissions?base64_encoded=false&wait=true' \\\n  -H 'content-type: application/json' \\\n  -d '{\"source_code\":\"print(\\\"hello\\\")\",\"language_id\":71,\"stdin\":\"\"}'\n```\n\nWithout `wait=true` you get a token back and poll `GET /submissions/{token}`. `POST /submissions/batch` takes up to 20 at once by default, and a `callback_url` field makes Judge0 call you when a run finishes. `GET /languages` lists ids, and `GET /statuses` returns the result codes, from 1 \"In Queue\" and 3 \"Accepted\" to 5 \"Time Limit Exceeded\", 6 \"Compilation Error\", 7 to 12 for runtime errors and 13 \"Internal Error\". `GET /config_info` shows the limits in force. The interactive docs are served at `/docs` on your instance and at ce.judge0.com.\n\n### What happened on a cgroup v2 host\n\nThe API came up and answered `/about`, `/languages` (47 languages), `/statuses` and `/config_info`. Every submission came back as status 13, Internal Error, with the message `No such file or directory @ rb_sysopen - /box/script.py`. Running Isolate's setup by hand inside the workers container shows why:\n\n```text\n$ isolate --version\nThe process isolator 1.8.1\n$ isolate --cg -b 99 --init\nFailed to create control group /sys/fs/cgroup/memory/box-99/: No such file or directory\n```\n\nIsolate 1.8.1, bundled in the v1.13.1 image, expects the cgroup v1 memory controller at `/sys/fs/cgroup/memory`. On a cgroup v2 host that path does not exist, so the sandbox is never created. This is what the release notes' GRUB step is for. If you cannot change the host's boot parameters (most managed Kubernetes nodes and many cloud images), use Piston or a different sandbox, or give Judge0 a VM you control.\n\n(Unrelated to Judge0: on our host the Compose network's DNS and container-to-container traffic did not work, so to get this far we put all four services in one network namespace. Do not copy that workaround. It puts PostgreSQL and Redis on the same localhost as the workers, which is exactly what a sandboxed program with networking enabled must not reach.)\n\n### Judge0 settings to change\n\nThe live `/config_info` of the default install showed these, and `judge0.conf` documents each:\n\n- **Authentication is off.** `AUTHN_TOKEN` is empty by default. Set it, and clients send it in the `X-Auth-Token` header (`AUTHN_HEADER` renames it). `AUTHZ_TOKEN` (header `X-Auth-User`) is a separate token for protected API calls, which are disabled while it is empty.\n- **Callers can turn networking on.** `enable_network` defaults to false, but `allow_enable_network` defaults to true, so any client can send `\"enable_network\": true` with a submission. Set `ALLOW_ENABLE_NETWORK=false`.\n- **Telemetry is on.** Per TELEMETRY.md, every instance sends its instance ID and version to Judge0's server every 12 hours. Set `JUDGE0_TELEMETRY_ENABLE=false` if you don't want that.\n- **Defaults to know:** 5 s CPU time (15 s max), 10 s wall time (20 s max), 128 MB memory (512 MB max), 60 processes, 1 MB per file the program writes, a queue of 100, and `wait=true` allowed. Clients can raise limits up to the `MAX_*` values, so set the `MAX_*` values to what you would accept.\n\n### The 2024 sandbox escapes\n\nv1.13.1 fixed three critical advisories reported by Daniel Cooper, all affecting versions up to 1.13.0:\n\n- **CVE-2024-28185:** symlinks placed inside the sandbox directory let a submission write files outside it and run code on the host.\n- **CVE-2024-28189:** a bypass of that fix, through `chown` following a symlink.\n- **CVE-2024-29021:** with `enable_network`, a submission could reach the PostgreSQL database on the internal Docker network and turn that into unsandboxed code execution as root.\n\nThe lesson goes beyond Judge0. In both projects the API and the code it runs sit in the same privileged container, so the boundary is Isolate plus your settings. Run v1.13.1 or later, keep networking off, and put the whole stack on a host with nothing worth stealing.\n\n## Hardening checklist for either one\n\n1. **Give it its own VM or host.** Both need privileged containers, and Isolate shares the host kernel. That VM is your real boundary.\n2. **Bind to localhost or a private network**, and put a small gateway in front that allows only the execute endpoint (`POST /api/v2/execute`, or `POST /submissions`) and checks a token. Piston has no auth at all, and its package endpoint installs runtimes for anyone.\n3. **Keep networking off.** It is off by default in Piston (`PISTON_DISABLE_NETWORKING=true`). In Judge0, set `ALLOW_ENABLE_NETWORK=false` so callers cannot turn it on.\n4. **Set server-side ceilings.** Piston: set `PISTON_RUN_MEMORY_LIMIT` and `PISTON_COMPILE_MEMORY_LIMIT`, which default to unlimited. Judge0: set the `MAX_*` values. Then set tighter per-call values in the wrapper.\n5. **Install only the languages the agent needs.** It is less to patch and less for the model to try.\n6. **Rate-limit the tool.** Piston runs up to 64 jobs at once by default (`PISTON_MAX_CONCURRENT_JOBS`); Judge0 rejects submissions once 100 are queued. An agent stuck in a loop can fill either.\n7. **Treat output as untrusted** and truncate it before it goes back into the model's context.\n8. **Don't rely on the public Piston API.** Since 15 Feb 2026 the emkc.org instance needs a key from the maintainer, issued only for non-commercial educational projects. Judge0's hosted option is Judge0 Cloud, through Rapid or directly.\n\nWhen the agent outgrows one-shot runs and needs a persistent workspace, move to a machine-style sandbox: [E2B](https://indexagentica.com/entries/e2b/) or the others in [hosted code sandboxes](https://indexagentica.com/compare/code-sandboxes/), or [microsandbox](https://indexagentica.com/entries/microsandbox/) and the others in [self-hosted agent sandboxes](https://indexagentica.com/compare/self-hosted-agent-sandboxes/).\n",
  "raw": "---\nid: self-host-a-code-execution-api\ntype: guide\ntitle: Self-host a code-execution API for agents with Piston or Judge0\nsummary: \"Run Piston or Judge0 on your own server to give an agent a stateless run_code tool for many languages: what each one isolates, the host requirements that decide between them, the limits to set, and a tested tool wrapper.\"\ndescription: \"Piston and Judge0 are open-source execute-and-return services built on the Isolate sandbox. They do not give an agent a machine with a filesystem; they run one program and return its output. This guide shows a tested Piston setup and a set of hostile probes with their real results, a stdlib Python run_code tool wrapper, Judge0's Docker Compose setup and what happened when we ran it on a cgroup v2 host, the 2024 Judge0 sandbox-escape CVEs, and the settings to change before you put either one in front of an agent.\"\nauthor: Agentica Author\ndifficulty: intermediate\ntime_estimate: 45 min\nprerequisites:\n  - A Linux host where you can run privileged Docker containers\n  - For Piston, cgroup v2 with cgroup v1 disabled; for Judge0 v1.13.1, cgroup v1 (see below)\n  - Python 3.9+ for the tool wrapper\ntags: [code-execution, sandboxes, self-hosted, security, agents, tools]\nentries: [piston, judge0, llm-sandbox, e2b, microsandbox]\nsources:\n  - title: Piston README\n    url: https://github.com/engineer-man/piston\n    accessed: 2026-10-30\n  - title: Piston docs, API v2\n    url: https://github.com/engineer-man/piston/blob/master/docs/api-v2.md\n    accessed: 2026-10-30\n  - title: Piston docs, Configuration\n    url: https://github.com/engineer-man/piston/blob/master/docs/configuration.md\n    accessed: 2026-10-30\n  - title: Piston source, api/src/config.js\n    url: https://github.com/engineer-man/piston/blob/master/api/src/config.js\n    accessed: 2026-10-30\n  - title: Judge0 README\n    url: https://github.com/judge0/judge0\n    accessed: 2026-10-30\n  - title: Judge0 v1.13.1 release notes\n    url: https://github.com/judge0/judge0/releases/tag/v1.13.1\n    accessed: 2026-10-30\n  - title: Judge0 CE API docs\n    url: https://ce.judge0.com/\n    accessed: 2026-10-30\n  - title: Judge0 TELEMETRY.md\n    url: https://github.com/judge0/judge0/blob/master/TELEMETRY.md\n    accessed: 2026-10-30\n  - title: \"GHSA-h9g2-45c8-89cf (CVE-2024-28185): Judge0 sandbox escape via symbolic link\"\n    url: https://github.com/judge0/judge0/security/advisories/GHSA-h9g2-45c8-89cf\n    accessed: 2026-10-30\n  - title: \"GHSA-3xpw-36v7-2cmg (CVE-2024-28189): Judge0 patch bypass via chown on a symbolic link\"\n    url: https://github.com/judge0/judge0/security/advisories/GHSA-3xpw-36v7-2cmg\n    accessed: 2026-10-30\n  - title: \"GHSA-q7vg-26pg-v5hr (CVE-2024-29021): Judge0 SSRF into sandbox escape\"\n    url: https://github.com/judge0/judge0/security/advisories/GHSA-q7vg-26pg-v5hr\n    accessed: 2026-10-30\n  - title: Isolate (ioi/isolate)\n    url: https://github.com/ioi/isolate\n    accessed: 2026-10-30\nrelated: [run-untrusted-code-in-a-sandbox, code-sandboxes, self-hosted-agent-sandboxes, coding-agent-starter]\nlast_verified: 2026-10-30\npublished: 2026-10-30\n---\n\n## When an execute API is the right tool\n\nMost sandbox products give an agent a disposable machine: a filesystem it can keep writing to, a shell, packages it can install, sometimes a browser. [Run agent-generated code safely in a sandbox](https://indexagentica.com/guides/run-untrusted-code-in-a-sandbox/) and the comparisons of [hosted code sandboxes](https://indexagentica.com/compare/code-sandboxes/) and [self-hosted agent sandboxes](https://indexagentica.com/compare/self-hosted-agent-sandboxes/) cover that kind.\n\n[Piston](https://indexagentica.com/entries/piston/) and [Judge0](https://indexagentica.com/entries/judge0/) are a different, smaller thing. They take source code plus stdin, run it once under fixed limits, and return stdout, stderr, the exit code and timing. Nothing survives between calls. They came out of chat bots and online judges, so they support many languages and are built to run code from strangers.\n\nThat shape fits an agent tool when you want:\n\n- a **calculator-style tool** (\"run this snippet and tell me what it prints\") with no state to clean up,\n- **many languages** behind one HTTP call, for example to check a code answer in the language the user asked about,\n- **grading or testing** generated solutions against fixed inputs,\n- something you can run **on your own server** with no per-second billing.\n\nIt is the wrong shape when the agent needs to install a package, keep files between steps, clone a repository or run a long job. Use a machine-style sandbox for that.\n\nBoth projects put the [Isolate](https://github.com/ioi/isolate) sandbox (the one written for programming olympiads) inside a privileged Docker container. Isolate uses Linux namespaces, a chroot, a separate unprivileged uid per run and cgroups for limits. That is a container-class boundary on a kernel shared with the host, not a VM. Run them on a host that does nothing else important.\n\n## What was run for this guide\n\nOn 2026-10-30 we ran Piston (image `ghcr.io/engineer-man/piston`, built 2025-02-08) under Docker on a Linux 6.12 host with cgroup v2, installed Python 3.12.0, GCC 10.2.0, Bash 5.2.0 and Lua 5.4.4, and ran every probe and the wrapper below against it. The results quoted are from those runs.\n\nWe also brought up Judge0 CE v1.13.1 with its release Compose file on the same host. The API came up, but every submission failed with an internal error, because that release needs cgroup v1 (details below). So the Judge0 sections describe the API from its docs and its own `/config_info` output; we did not see a Judge0 submission succeed.\n\n## Pick by host first\n\nThe deciding fact is often your kernel's cgroup setup, not features.\n\n| | Piston | Judge0 CE v1.13.1 |\n|---|---|---|\n| License | MIT | GPL-3.0 |\n| Host requirement | cgroup v2 enabled and cgroup v1 disabled | cgroup v1: the release notes tell you to add `systemd.unified_cgroup_hierarchy=0` to GRUB on Ubuntu 22.04 and reboot |\n| Services | one container | four: API server, workers, PostgreSQL, Redis |\n| Request model | synchronous `POST /api/v2/execute`; WebSocket for interactive runs | queued submissions; poll by token, use `wait=true`, or a callback URL |\n| Languages | installed one by one as packages | fixed set in the image (47 in our CE v1.13.1 instance; Extra CE is a separate branch) |\n| Auth | none | optional token header, off by default |\n| Telemetry | none documented | on by default |\n| Last release | rolling `master` image | v1.13.1, 2024-04-18 |\n\nMost current distributions (Ubuntu 22.04 and later, Debian 12, Fedora) boot with cgroup v2 only. Check yours:\n\n```bash\nstat -fc %T /sys/fs/cgroup   # cgroup2fs means cgroup v2 only\n```\n\nOn a cgroup v2 host, Piston runs as documented and Judge0 v1.13.1 needs a kernel command-line change and a reboot. On a dedicated cgroup v1 host, either works.\n\n## Run Piston\n\nStart the API, bound to localhost only. The README's command publishes port 2000 on every interface; do not do that on a machine with a public address, because the API has no authentication.\n\n```bash\nmkdir -p piston-data\ndocker run --privileged -v \"$PWD/piston-data:/piston\" -dit \\\n  -p 127.0.0.1:2000:2000 \\\n  -e PISTON_RUN_MEMORY_LIMIT=268435456 \\\n  --name piston_api ghcr.io/engineer-man/piston\n```\n\n`--privileged` is required: Isolate needs it to create namespaces and cgroups inside the container. That is one more reason to give Piston its own host or VM.\n\nThe image has no languages. List what can be installed, install what you need, and check what is ready:\n\n```bash\ncurl -s localhost:2000/api/v2/packages     # every package, with \"installed\": true/false\ncurl -s -X POST localhost:2000/api/v2/packages \\\n  -H 'content-type: application/json' -d '{\"language\":\"python\",\"version\":\"3.12.0\"}'\ncurl -s localhost:2000/api/v2/runtimes     # what /execute can use now\n```\n\nInstalling Lua 5.4.4 took about 3 seconds here. The package endpoint is as unauthenticated as `/execute`, so anyone who can reach the API can install runtimes. Expose only `/api/v2/execute` to the agent (see the hardening list).\n\nRun code:\n\n```bash\ncurl -s -X POST localhost:2000/api/v2/execute -H 'content-type: application/json' -d '{\n  \"language\": \"python\", \"version\": \"3.12\",\n  \"files\": [{\"name\": \"main.py\", \"content\": \"import helper; print(helper.X)\"},\n            {\"name\": \"helper.py\", \"content\": \"X = 99\"}],\n  \"stdin\": \"\"\n}'\n```\n\n```json\n{\"run\":{\"signal\":null,\"stdout\":\"99\\n\",\"stderr\":\"\",\"code\":0,\"output\":\"99\\n\",\"memory\":3856000,\"message\":null,\"status\":null,\"cpu_time\":11,\"wall_time\":51},\"language\":\"python\",\"version\":\"3.12.0\"}\n```\n\n`version` is required and is a semver selector, so `\"3.12\"` matched 3.12.0. Leaving it out returns `400 {\"message\":\"version is required as a string\"}`. The first file is the one that runs. Compiled languages add a `compile` object with the same fields as `run`. `status` is null on success, or one of `RE` (runtime error), `SG` (signal), `TO` (timeout), `OL` (stdout too long), `EL` (stderr too long) or `XX` (internal error).\n\n## What the sandbox actually stopped\n\nWe sent these snippets with default limits, except where noted:\n\n| Probe | Result |\n|---|---|\n| `while True: pass` | `status: TO`, `SIGKILL`, \"Time limit exceeded (wall clock)\" at about 3.1 s |\n| `time.sleep(10)` | `TO` at 3.1 s, so wall time is limited, not just CPU |\n| TCP connect to `1.1.1.1:80` | `OSError: [Errno 101] Network is unreachable` |\n| DNS lookup of `example.com` | `gaierror: [Errno -3] Temporary failure in name resolution` |\n| Fork loop | stopped after 62 forks with `[Errno 11] Resource temporarily unavailable` |\n| Allocate 512 MB with `run_memory_limit` 256 MiB | killed, exit code 137, `memory` 268432000 |\n| Print 5,000 characters | `status: OL`, \"stdout length exceeded\", `SIGKILL` |\n| Write to `/tmp` | allowed, as an unprivileged uid; the file was gone on the next run |\n| List `/piston` | saw `['packages']`; writing to `/piston/packages` raised `PermissionError` |\n| C program with a compile error | `compile.status: RE`, exit code 1; the run stage never started |\n| `\"language\": \"cobol\"` | `400`, \"cobol-* runtime is unknown\" |\n\nThe defaults behind those numbers come from `api/src/config.js` and the configuration docs: networking disabled, 3 s run timeout and 3 s run CPU time, 10 s for compiling, 1,024 characters of output per stream, 64 processes, 2,048 open files, 10 MB maximum file size, and 64 concurrent jobs. The fork result (62 children plus the parent) matches the 64-process default. The README's security section still says processes are capped at 256 by default; the code and the configuration page say 64.\n\n**The memory default is \"no limit\".** `compile_memory_limit` and `run_memory_limit` default to `-1`. Set them with `PISTON_RUN_MEMORY_LIMIT` and `PISTON_COMPILE_MEMORY_LIMIT` (bytes). With no server limit, a request could ask for `run_memory_limit: 999999999999` and Piston ran it. With `PISTON_RUN_MEMORY_LIMIT=268435456` set, the same request got `400 {\"message\":\"run_memory_limit cannot exceed the configured limit of 268435456\"}`, and a 512 MB allocation was killed even when the request did not mention memory.\n\nPer-request limits can only go down. Asking for `run_timeout: 60000` against the default returned `400 {\"message\":\"run_timeout cannot exceed the configured limit of 3000\"}`. To allow more, raise the server value (for example `PISTON_RUN_TIMEOUT=10000`; a 5 s sleep then completed), or use `PISTON_LIMIT_OVERRIDES` to raise limits for one language only.\n\nThe 1,024-character output cap is small for an agent that wants to read a traceback. `PISTON_OUTPUT_MAX_SIZE=65536` let a 5,000-character print through. Truncate again in your wrapper so a chatty program does not fill the model's context.\n\nThe probe script, so you can rerun this against your own instance:\n\n```bash\n#!/usr/bin/env bash\n# piston-probe.sh: run hostile and normal snippets against a Piston API and print one line each.\nP=${PISTON_URL:-http://127.0.0.1:2000}\nrun() { # $1 label, $2 JSON body\n  printf '%-11s ' \"$1\"\n  curl -s -X POST \"$P/api/v2/execute\" -H 'content-type: application/json' -d \"$2\" |\n    python3 -c 'import sys,json; d=json.load(sys.stdin); r=d.get(\"run\",d); c=d.get(\"compile\") or {}\nprint(json.dumps({\"compile\":c.get(\"status\"),\"status\":r.get(\"status\"),\"code\":r.get(\"code\"),\"signal\":r.get(\"signal\"),\"stdout\":(r.get(\"stdout\") or \"\")[:70],\"stderr\":(r.get(\"stderr\") or \"\")[-90:],\"message\":r.get(\"message\"),\"wall_ms\":r.get(\"wall_time\"),\"mem\":r.get(\"memory\")}))'\n}\npy() { python3 -c 'import json,sys; print(json.dumps({\"language\":\"python\",\"version\":\"3.12\",\"files\":[{\"content\":sys.argv[1]}],**json.loads(sys.argv[2] if len(sys.argv)>2 else \"{}\")}))' \"$@\"; }\n\nrun hello     \"$(py 'print(\"hello from piston\")')\"\nrun timeout   \"$(py 'while True: pass')\"\nrun sleep     \"$(py 'import time; time.sleep(10)')\"\nrun network   \"$(py 'import socket; socket.create_connection((\"1.1.1.1\",80),timeout=3); print(\"NET OK\")')\"\nrun dns       \"$(py 'import socket; print(socket.gethostbyname(\"example.com\"))')\"\nrun forkbomb  \"$(py 'import os\nn=0\ntry:\n    while True:\n        if os.fork()==0:\n            import time; time.sleep(5); os._exit(0)\n        n+=1\nexcept OSError as e: print(\"forks before error:\",n,e)')\"\nrun memory    \"$(py 'b=bytearray(512*1024*1024); print(\"allocated\", len(b))' '{\"run_memory_limit\":268435456}')\"\nrun bigout    \"$(py 'print(\"x\"*5000)')\"\nrun write-tmp \"$(py 'open(\"/tmp/x\",\"w\").write(\"1\"); import os; print(os.getuid(), os.listdir(\"/tmp\"))')\"\nrun read-host \"$(py 'import os; print(os.listdir(\"/piston\"))')\"\nrun unknown   '{\"language\":\"cobol\",\"version\":\"*\",\"files\":[{\"content\":\"x\"}]}'\n```\n\n## Wrap it as an agent tool\n\nThe model should see one small tool with a fixed language list. The wrapper re-checks limits so the model cannot ask for more, and returns a compact result. This is standard-library Python and was run against the instance above; the schema is in the shape most function-calling APIs accept (rename `input_schema` to `parameters` where needed).\n\n```python\nimport json, os, urllib.request, urllib.error\n\nPISTON_URL = os.environ.get(\"PISTON_URL\", \"http://127.0.0.1:2000\")\nALLOWED = {\"python\": \"3.12\", \"bash\": \"5\", \"c\": \"10\"}  # language -> version selector\nMAX_CODE_BYTES = 64_000\nMAX_RETURN_CHARS = 4_000\n\nTOOL_SCHEMA = {\n    \"name\": \"run_code\",\n    \"description\": \"Run a short, self-contained program and return its output. \"\n                   \"No network, no persistent files, about 3 s of run time.\",\n    \"input_schema\": {\n        \"type\": \"object\",\n        \"properties\": {\n            \"language\": {\"type\": \"string\", \"enum\": sorted(ALLOWED)},\n            \"code\": {\"type\": \"string\"},\n            \"stdin\": {\"type\": \"string\"},\n        },\n        \"required\": [\"language\", \"code\"],\n    },\n}\n\ndef _post(path, body, timeout=30):\n    req = urllib.request.Request(PISTON_URL + path, data=json.dumps(body).encode(),\n                                 headers={\"content-type\": \"application/json\"})\n    try:\n        with urllib.request.urlopen(req, timeout=timeout) as r:\n            return json.load(r)\n    except urllib.error.HTTPError as e:\n        return {\"message\": json.load(e).get(\"message\", str(e))}\n\ndef run_code(language, code, stdin=\"\"):\n    if language not in ALLOWED:\n        return {\"ok\": False, \"error\": f\"language must be one of {sorted(ALLOWED)}\"}\n    if len(code.encode()) > MAX_CODE_BYTES:\n        return {\"ok\": False, \"error\": \"code too large\"}\n    res = _post(\"/api/v2/execute\", {\n        \"language\": language, \"version\": ALLOWED[language],\n        \"files\": [{\"content\": code}], \"stdin\": stdin or \"\",\n        \"run_timeout\": 3000, \"run_memory_limit\": 256 * 1024 * 1024,\n    })\n    if \"run\" not in res:   # 400: bad request, unknown runtime, limit above the server's\n        return {\"ok\": False, \"error\": res.get(\"message\", \"execution failed\")}\n    stage = res.get(\"compile\") if res.get(\"compile\", {}).get(\"code\") else res[\"run\"]\n    return {\n        \"ok\": stage[\"code\"] == 0 and stage[\"status\"] is None,\n        \"stage\": \"compile\" if stage is not res[\"run\"] else \"run\",\n        \"exit_code\": stage[\"code\"], \"signal\": stage[\"signal\"],\n        \"status\": stage[\"status\"], \"message\": stage[\"message\"],\n        \"stdout\": stage[\"stdout\"][:MAX_RETURN_CHARS],\n        \"stderr\": stage[\"stderr\"][:MAX_RETURN_CHARS],\n    }\n```\n\nWhat it returned for six test calls:\n\n| Call | Result |\n|---|---|\n| Python `print(sum(range(10)))` | `ok: true`, stdout `45` |\n| Python reading stdin `hi` | `ok: true`, stdout `HI` |\n| C with an undeclared variable | `ok: false`, `stage: compile`, `status: RE`, compiler error in stderr |\n| Python `while True: pass` | `ok: false`, `status: TO`, \"Time limit exceeded (wall clock)\" |\n| Python `urlopen(\"http://1.1.1.1\")` | `ok: false`, `status: RE`, \"Network is unreachable\" in stderr |\n| `language: \"ruby\"` | rejected by the wrapper before any request |\n\nTwo habits matter more than the code. Treat `stdout` and `stderr` as data the model reads, never as instructions to follow. And because Piston blocks the network, tell the model so in the tool description; otherwise it will try `pip install` and waste turns.\n\nIf you would rather call a library than run a service, [LLM Sandbox](https://indexagentica.com/entries/llm-sandbox/) runs code through Docker, Kubernetes, Podman, Bubblewrap or NsJail backends from Python and ships an MCP server.\n\n## Run Judge0\n\nJudge0's release deployment, from the v1.13.1 release notes:\n\n```bash\nwget https://github.com/judge0/judge0/releases/download/v1.13.1/judge0-v1.13.1.zip\nunzip judge0-v1.13.1.zip && cd judge0-v1.13.1\n# set REDIS_PASSWORD and POSTGRES_PASSWORD in judge0.conf to long random values\ndocker-compose up -d db redis\nsleep 10s\ndocker-compose up -d\n```\n\nThe Compose file runs four services. `server` and `workers` both use `judge0/judge0:1.13.1` with `privileged: true`, and the server publishes `2358:2358` on all interfaces. Change that to `127.0.0.1:2358:2358` unless a gateway sits in front.\n\nSubmit code and wait for the result in one call (`language_id` 71 is Python 3.8.1 in CE):\n\n```bash\ncurl -s -X POST 'localhost:2358/submissions?base64_encoded=false&wait=true' \\\n  -H 'content-type: application/json' \\\n  -d '{\"source_code\":\"print(\\\"hello\\\")\",\"language_id\":71,\"stdin\":\"\"}'\n```\n\nWithout `wait=true` you get a token back and poll `GET /submissions/{token}`. `POST /submissions/batch` takes up to 20 at once by default, and a `callback_url` field makes Judge0 call you when a run finishes. `GET /languages` lists ids, and `GET /statuses` returns the result codes, from 1 \"In Queue\" and 3 \"Accepted\" to 5 \"Time Limit Exceeded\", 6 \"Compilation Error\", 7 to 12 for runtime errors and 13 \"Internal Error\". `GET /config_info` shows the limits in force. The interactive docs are served at `/docs` on your instance and at ce.judge0.com.\n\n### What happened on a cgroup v2 host\n\nThe API came up and answered `/about`, `/languages` (47 languages), `/statuses` and `/config_info`. Every submission came back as status 13, Internal Error, with the message `No such file or directory @ rb_sysopen - /box/script.py`. Running Isolate's setup by hand inside the workers container shows why:\n\n```text\n$ isolate --version\nThe process isolator 1.8.1\n$ isolate --cg -b 99 --init\nFailed to create control group /sys/fs/cgroup/memory/box-99/: No such file or directory\n```\n\nIsolate 1.8.1, bundled in the v1.13.1 image, expects the cgroup v1 memory controller at `/sys/fs/cgroup/memory`. On a cgroup v2 host that path does not exist, so the sandbox is never created. This is what the release notes' GRUB step is for. If you cannot change the host's boot parameters (most managed Kubernetes nodes and many cloud images), use Piston or a different sandbox, or give Judge0 a VM you control.\n\n(Unrelated to Judge0: on our host the Compose network's DNS and container-to-container traffic did not work, so to get this far we put all four services in one network namespace. Do not copy that workaround. It puts PostgreSQL and Redis on the same localhost as the workers, which is exactly what a sandboxed program with networking enabled must not reach.)\n\n### Judge0 settings to change\n\nThe live `/config_info` of the default install showed these, and `judge0.conf` documents each:\n\n- **Authentication is off.** `AUTHN_TOKEN` is empty by default. Set it, and clients send it in the `X-Auth-Token` header (`AUTHN_HEADER` renames it). `AUTHZ_TOKEN` (header `X-Auth-User`) is a separate token for protected API calls, which are disabled while it is empty.\n- **Callers can turn networking on.** `enable_network` defaults to false, but `allow_enable_network` defaults to true, so any client can send `\"enable_network\": true` with a submission. Set `ALLOW_ENABLE_NETWORK=false`.\n- **Telemetry is on.** Per TELEMETRY.md, every instance sends its instance ID and version to Judge0's server every 12 hours. Set `JUDGE0_TELEMETRY_ENABLE=false` if you don't want that.\n- **Defaults to know:** 5 s CPU time (15 s max), 10 s wall time (20 s max), 128 MB memory (512 MB max), 60 processes, 1 MB per file the program writes, a queue of 100, and `wait=true` allowed. Clients can raise limits up to the `MAX_*` values, so set the `MAX_*` values to what you would accept.\n\n### The 2024 sandbox escapes\n\nv1.13.1 fixed three critical advisories reported by Daniel Cooper, all affecting versions up to 1.13.0:\n\n- **CVE-2024-28185:** symlinks placed inside the sandbox directory let a submission write files outside it and run code on the host.\n- **CVE-2024-28189:** a bypass of that fix, through `chown` following a symlink.\n- **CVE-2024-29021:** with `enable_network`, a submission could reach the PostgreSQL database on the internal Docker network and turn that into unsandboxed code execution as root.\n\nThe lesson goes beyond Judge0. In both projects the API and the code it runs sit in the same privileged container, so the boundary is Isolate plus your settings. Run v1.13.1 or later, keep networking off, and put the whole stack on a host with nothing worth stealing.\n\n## Hardening checklist for either one\n\n1. **Give it its own VM or host.** Both need privileged containers, and Isolate shares the host kernel. That VM is your real boundary.\n2. **Bind to localhost or a private network**, and put a small gateway in front that allows only the execute endpoint (`POST /api/v2/execute`, or `POST /submissions`) and checks a token. Piston has no auth at all, and its package endpoint installs runtimes for anyone.\n3. **Keep networking off.** It is off by default in Piston (`PISTON_DISABLE_NETWORKING=true`). In Judge0, set `ALLOW_ENABLE_NETWORK=false` so callers cannot turn it on.\n4. **Set server-side ceilings.** Piston: set `PISTON_RUN_MEMORY_LIMIT` and `PISTON_COMPILE_MEMORY_LIMIT`, which default to unlimited. Judge0: set the `MAX_*` values. Then set tighter per-call values in the wrapper.\n5. **Install only the languages the agent needs.** It is less to patch and less for the model to try.\n6. **Rate-limit the tool.** Piston runs up to 64 jobs at once by default (`PISTON_MAX_CONCURRENT_JOBS`); Judge0 rejects submissions once 100 are queued. An agent stuck in a loop can fill either.\n7. **Treat output as untrusted** and truncate it before it goes back into the model's context.\n8. **Don't rely on the public Piston API.** Since 15 Feb 2026 the emkc.org instance needs a key from the maintainer, issued only for non-commercial educational projects. Judge0's hosted option is Judge0 Cloud, through Rapid or directly.\n\nWhen the agent outgrows one-shot runs and needs a persistent workspace, move to a machine-style sandbox: [E2B](https://indexagentica.com/entries/e2b/) or the others in [hosted code sandboxes](https://indexagentica.com/compare/code-sandboxes/), or [microsandbox](https://indexagentica.com/entries/microsandbox/) and the others in [self-hosted agent sandboxes](https://indexagentica.com/compare/self-hosted-agent-sandboxes/).\n",
  "html": "<h2>When an execute API is the right tool</h2>\n<p>Most sandbox products give an agent a disposable machine: a filesystem it can keep writing to, a shell, packages it can install, sometimes a browser. <a href=\"/guides/run-untrusted-code-in-a-sandbox/\">Run agent-generated code safely in a sandbox</a> and the comparisons of <a href=\"/compare/code-sandboxes/\">hosted code sandboxes</a> and <a href=\"/compare/self-hosted-agent-sandboxes/\">self-hosted agent sandboxes</a> cover that kind.</p>\n<p><a href=\"/entries/piston/\">Piston</a> and <a href=\"/entries/judge0/\">Judge0</a> are a different, smaller thing. They take source code plus stdin, run it once under fixed limits, and return stdout, stderr, the exit code and timing. Nothing survives between calls. They came out of chat bots and online judges, so they support many languages and are built to run code from strangers.</p>\n<p>That shape fits an agent tool when you want:</p>\n<ul><li>a <strong>calculator-style tool</strong> (&quot;run this snippet and tell me what it prints&quot;) with no state to clean up,</li><li><strong>many languages</strong> behind one HTTP call, for example to check a code answer in the language the user asked about,</li><li><strong>grading or testing</strong> generated solutions against fixed inputs,</li><li>something you can run <strong>on your own server</strong> with no per-second billing.</li></ul>\n<p>It is the wrong shape when the agent needs to install a package, keep files between steps, clone a repository or run a long job. Use a machine-style sandbox for that.</p>\n<p>Both projects put the <a href=\"https://github.com/ioi/isolate\">Isolate</a> sandbox (the one written for programming olympiads) inside a privileged Docker container. Isolate uses Linux namespaces, a chroot, a separate unprivileged uid per run and cgroups for limits. That is a container-class boundary on a kernel shared with the host, not a VM. Run them on a host that does nothing else important.</p>\n<h2>What was run for this guide</h2>\n<p>On 2026-10-30 we ran Piston (image <code>ghcr.io/engineer-man/piston</code>, built 2025-02-08) under Docker on a Linux 6.12 host with cgroup v2, installed Python 3.12.0, GCC 10.2.0, Bash 5.2.0 and Lua 5.4.4, and ran every probe and the wrapper below against it. The results quoted are from those runs.</p>\n<p>We also brought up Judge0 CE v1.13.1 with its release Compose file on the same host. The API came up, but every submission failed with an internal error, because that release needs cgroup v1 (details below). So the Judge0 sections describe the API from its docs and its own <code>/config_info</code> output; we did not see a Judge0 submission succeed.</p>\n<h2>Pick by host first</h2>\n<p>The deciding fact is often your kernel&#39;s cgroup setup, not features.</p>\n<table><thead><tr><th scope=\"col\"></th><th scope=\"col\">Piston</th><th scope=\"col\">Judge0 CE v1.13.1</th></tr></thead><tbody><tr><td>License</td><td>MIT</td><td>GPL-3.0</td></tr><tr><td>Host requirement</td><td>cgroup v2 enabled and cgroup v1 disabled</td><td>cgroup v1: the release notes tell you to add <code>systemd.unified_cgroup_hierarchy=0</code> to GRUB on Ubuntu 22.04 and reboot</td></tr><tr><td>Services</td><td>one container</td><td>four: API server, workers, PostgreSQL, Redis</td></tr><tr><td>Request model</td><td>synchronous <code>POST /api/v2/execute</code>; WebSocket for interactive runs</td><td>queued submissions; poll by token, use <code>wait=true</code>, or a callback URL</td></tr><tr><td>Languages</td><td>installed one by one as packages</td><td>fixed set in the image (47 in our CE v1.13.1 instance; Extra CE is a separate branch)</td></tr><tr><td>Auth</td><td>none</td><td>optional token header, off by default</td></tr><tr><td>Telemetry</td><td>none documented</td><td>on by default</td></tr><tr><td>Last release</td><td>rolling <code>master</code> image</td><td>v1.13.1, 2024-04-18</td></tr></tbody></table>\n<p>Most current distributions (Ubuntu 22.04 and later, Debian 12, Fedora) boot with cgroup v2 only. Check yours:</p>\n<pre><code class=\"language-bash\">stat -fc %T /sys/fs/cgroup   # cgroup2fs means cgroup v2 only</code></pre>\n<p>On a cgroup v2 host, Piston runs as documented and Judge0 v1.13.1 needs a kernel command-line change and a reboot. On a dedicated cgroup v1 host, either works.</p>\n<h2>Run Piston</h2>\n<p>Start the API, bound to localhost only. The README&#39;s command publishes port 2000 on every interface; do not do that on a machine with a public address, because the API has no authentication.</p>\n<pre><code class=\"language-bash\">mkdir -p piston-data\ndocker run --privileged -v &quot;$PWD/piston-data:/piston&quot; -dit \\\n  -p 127.0.0.1:2000:2000 \\\n  -e PISTON_RUN_MEMORY_LIMIT=268435456 \\\n  --name piston_api ghcr.io/engineer-man/piston</code></pre>\n<p><code>--privileged</code> is required: Isolate needs it to create namespaces and cgroups inside the container. That is one more reason to give Piston its own host or VM.</p>\n<p>The image has no languages. List what can be installed, install what you need, and check what is ready:</p>\n<pre><code class=\"language-bash\">curl -s localhost:2000/api/v2/packages     # every package, with &quot;installed&quot;: true/false\ncurl -s -X POST localhost:2000/api/v2/packages \\\n  -H &#39;content-type: application/json&#39; -d &#39;{&quot;language&quot;:&quot;python&quot;,&quot;version&quot;:&quot;3.12.0&quot;}&#39;\ncurl -s localhost:2000/api/v2/runtimes     # what /execute can use now</code></pre>\n<p>Installing Lua 5.4.4 took about 3 seconds here. The package endpoint is as unauthenticated as <code>/execute</code>, so anyone who can reach the API can install runtimes. Expose only <code>/api/v2/execute</code> to the agent (see the hardening list).</p>\n<p>Run code:</p>\n<pre><code class=\"language-bash\">curl -s -X POST localhost:2000/api/v2/execute -H &#39;content-type: application/json&#39; -d &#39;{\n  &quot;language&quot;: &quot;python&quot;, &quot;version&quot;: &quot;3.12&quot;,\n  &quot;files&quot;: [{&quot;name&quot;: &quot;main.py&quot;, &quot;content&quot;: &quot;import helper; print(helper.X)&quot;},\n            {&quot;name&quot;: &quot;helper.py&quot;, &quot;content&quot;: &quot;X = 99&quot;}],\n  &quot;stdin&quot;: &quot;&quot;\n}&#39;</code></pre>\n<pre><code class=\"language-json\">{&quot;run&quot;:{&quot;signal&quot;:null,&quot;stdout&quot;:&quot;99\\n&quot;,&quot;stderr&quot;:&quot;&quot;,&quot;code&quot;:0,&quot;output&quot;:&quot;99\\n&quot;,&quot;memory&quot;:3856000,&quot;message&quot;:null,&quot;status&quot;:null,&quot;cpu_time&quot;:11,&quot;wall_time&quot;:51},&quot;language&quot;:&quot;python&quot;,&quot;version&quot;:&quot;3.12.0&quot;}</code></pre>\n<p><code>version</code> is required and is a semver selector, so <code>&quot;3.12&quot;</code> matched 3.12.0. Leaving it out returns <code>400 {&quot;message&quot;:&quot;version is required as a string&quot;}</code>. The first file is the one that runs. Compiled languages add a <code>compile</code> object with the same fields as <code>run</code>. <code>status</code> is null on success, or one of <code>RE</code> (runtime error), <code>SG</code> (signal), <code>TO</code> (timeout), <code>OL</code> (stdout too long), <code>EL</code> (stderr too long) or <code>XX</code> (internal error).</p>\n<h2>What the sandbox actually stopped</h2>\n<p>We sent these snippets with default limits, except where noted:</p>\n<table><thead><tr><th scope=\"col\">Probe</th><th scope=\"col\">Result</th></tr></thead><tbody><tr><td><code>while True: pass</code></td><td><code>status: TO</code>, <code>SIGKILL</code>, &quot;Time limit exceeded (wall clock)&quot; at about 3.1 s</td></tr><tr><td><code>time.sleep(10)</code></td><td><code>TO</code> at 3.1 s, so wall time is limited, not just CPU</td></tr><tr><td>TCP connect to <code>1.1.1.1:80</code></td><td><code>OSError: [Errno 101] Network is unreachable</code></td></tr><tr><td>DNS lookup of <code>example.com</code></td><td><code>gaierror: [Errno -3] Temporary failure in name resolution</code></td></tr><tr><td>Fork loop</td><td>stopped after 62 forks with <code>[Errno 11] Resource temporarily unavailable</code></td></tr><tr><td>Allocate 512 MB with <code>run_memory_limit</code> 256 MiB</td><td>killed, exit code 137, <code>memory</code> 268432000</td></tr><tr><td>Print 5,000 characters</td><td><code>status: OL</code>, &quot;stdout length exceeded&quot;, <code>SIGKILL</code></td></tr><tr><td>Write to <code>/tmp</code></td><td>allowed, as an unprivileged uid; the file was gone on the next run</td></tr><tr><td>List <code>/piston</code></td><td>saw <code>[&#39;packages&#39;]</code>; writing to <code>/piston/packages</code> raised <code>PermissionError</code></td></tr><tr><td>C program with a compile error</td><td><code>compile.status: RE</code>, exit code 1; the run stage never started</td></tr><tr><td><code>&quot;language&quot;: &quot;cobol&quot;</code></td><td><code>400</code>, &quot;cobol-* runtime is unknown&quot;</td></tr></tbody></table>\n<p>The defaults behind those numbers come from <code>api/src/config.js</code> and the configuration docs: networking disabled, 3 s run timeout and 3 s run CPU time, 10 s for compiling, 1,024 characters of output per stream, 64 processes, 2,048 open files, 10 MB maximum file size, and 64 concurrent jobs. The fork result (62 children plus the parent) matches the 64-process default. The README&#39;s security section still says processes are capped at 256 by default; the code and the configuration page say 64.</p>\n<p><strong>The memory default is &quot;no limit&quot;.</strong> <code>compile_memory_limit</code> and <code>run_memory_limit</code> default to <code>-1</code>. Set them with <code>PISTON_RUN_MEMORY_LIMIT</code> and <code>PISTON_COMPILE_MEMORY_LIMIT</code> (bytes). With no server limit, a request could ask for <code>run_memory_limit: 999999999999</code> and Piston ran it. With <code>PISTON_RUN_MEMORY_LIMIT=268435456</code> set, the same request got <code>400 {&quot;message&quot;:&quot;run_memory_limit cannot exceed the configured limit of 268435456&quot;}</code>, and a 512 MB allocation was killed even when the request did not mention memory.</p>\n<p>Per-request limits can only go down. Asking for <code>run_timeout: 60000</code> against the default returned <code>400 {&quot;message&quot;:&quot;run_timeout cannot exceed the configured limit of 3000&quot;}</code>. To allow more, raise the server value (for example <code>PISTON_RUN_TIMEOUT=10000</code>; a 5 s sleep then completed), or use <code>PISTON_LIMIT_OVERRIDES</code> to raise limits for one language only.</p>\n<p>The 1,024-character output cap is small for an agent that wants to read a traceback. <code>PISTON_OUTPUT_MAX_SIZE=65536</code> let a 5,000-character print through. Truncate again in your wrapper so a chatty program does not fill the model&#39;s context.</p>\n<p>The probe script, so you can rerun this against your own instance:</p>\n<pre><code class=\"language-bash\">#!/usr/bin/env bash\n# piston-probe.sh: run hostile and normal snippets against a Piston API and print one line each.\nP=${PISTON_URL:-http://127.0.0.1:2000}\nrun() { # $1 label, $2 JSON body\n  printf &#39;%-11s &#39; &quot;$1&quot;\n  curl -s -X POST &quot;$P/api/v2/execute&quot; -H &#39;content-type: application/json&#39; -d &quot;$2&quot; |\n    python3 -c &#39;import sys,json; d=json.load(sys.stdin); r=d.get(&quot;run&quot;,d); c=d.get(&quot;compile&quot;) or {}\nprint(json.dumps({&quot;compile&quot;:c.get(&quot;status&quot;),&quot;status&quot;:r.get(&quot;status&quot;),&quot;code&quot;:r.get(&quot;code&quot;),&quot;signal&quot;:r.get(&quot;signal&quot;),&quot;stdout&quot;:(r.get(&quot;stdout&quot;) or &quot;&quot;)[:70],&quot;stderr&quot;:(r.get(&quot;stderr&quot;) or &quot;&quot;)[-90:],&quot;message&quot;:r.get(&quot;message&quot;),&quot;wall_ms&quot;:r.get(&quot;wall_time&quot;),&quot;mem&quot;:r.get(&quot;memory&quot;)}))&#39;\n}\npy() { python3 -c &#39;import json,sys; print(json.dumps({&quot;language&quot;:&quot;python&quot;,&quot;version&quot;:&quot;3.12&quot;,&quot;files&quot;:[{&quot;content&quot;:sys.argv[1]}],**json.loads(sys.argv[2] if len(sys.argv)&gt;2 else &quot;{}&quot;)}))&#39; &quot;$@&quot;; }\n\nrun hello     &quot;$(py &#39;print(&quot;hello from piston&quot;)&#39;)&quot;\nrun timeout   &quot;$(py &#39;while True: pass&#39;)&quot;\nrun sleep     &quot;$(py &#39;import time; time.sleep(10)&#39;)&quot;\nrun network   &quot;$(py &#39;import socket; socket.create_connection((&quot;1.1.1.1&quot;,80),timeout=3); print(&quot;NET OK&quot;)&#39;)&quot;\nrun dns       &quot;$(py &#39;import socket; print(socket.gethostbyname(&quot;example.com&quot;))&#39;)&quot;\nrun forkbomb  &quot;$(py &#39;import os\nn=0\ntry:\n    while True:\n        if os.fork()==0:\n            import time; time.sleep(5); os._exit(0)\n        n+=1\nexcept OSError as e: print(&quot;forks before error:&quot;,n,e)&#39;)&quot;\nrun memory    &quot;$(py &#39;b=bytearray(512*1024*1024); print(&quot;allocated&quot;, len(b))&#39; &#39;{&quot;run_memory_limit&quot;:268435456}&#39;)&quot;\nrun bigout    &quot;$(py &#39;print(&quot;x&quot;*5000)&#39;)&quot;\nrun write-tmp &quot;$(py &#39;open(&quot;/tmp/x&quot;,&quot;w&quot;).write(&quot;1&quot;); import os; print(os.getuid(), os.listdir(&quot;/tmp&quot;))&#39;)&quot;\nrun read-host &quot;$(py &#39;import os; print(os.listdir(&quot;/piston&quot;))&#39;)&quot;\nrun unknown   &#39;{&quot;language&quot;:&quot;cobol&quot;,&quot;version&quot;:&quot;*&quot;,&quot;files&quot;:[{&quot;content&quot;:&quot;x&quot;}]}&#39;</code></pre>\n<h2>Wrap it as an agent tool</h2>\n<p>The model should see one small tool with a fixed language list. The wrapper re-checks limits so the model cannot ask for more, and returns a compact result. This is standard-library Python and was run against the instance above; the schema is in the shape most function-calling APIs accept (rename <code>input_schema</code> to <code>parameters</code> where needed).</p>\n<pre><code class=\"language-python\">import json, os, urllib.request, urllib.error\n\nPISTON_URL = os.environ.get(&quot;PISTON_URL&quot;, &quot;http://127.0.0.1:2000&quot;)\nALLOWED = {&quot;python&quot;: &quot;3.12&quot;, &quot;bash&quot;: &quot;5&quot;, &quot;c&quot;: &quot;10&quot;}  # language -&gt; version selector\nMAX_CODE_BYTES = 64_000\nMAX_RETURN_CHARS = 4_000\n\nTOOL_SCHEMA = {\n    &quot;name&quot;: &quot;run_code&quot;,\n    &quot;description&quot;: &quot;Run a short, self-contained program and return its output. &quot;\n                   &quot;No network, no persistent files, about 3 s of run time.&quot;,\n    &quot;input_schema&quot;: {\n        &quot;type&quot;: &quot;object&quot;,\n        &quot;properties&quot;: {\n            &quot;language&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;enum&quot;: sorted(ALLOWED)},\n            &quot;code&quot;: {&quot;type&quot;: &quot;string&quot;},\n            &quot;stdin&quot;: {&quot;type&quot;: &quot;string&quot;},\n        },\n        &quot;required&quot;: [&quot;language&quot;, &quot;code&quot;],\n    },\n}\n\ndef _post(path, body, timeout=30):\n    req = urllib.request.Request(PISTON_URL + path, data=json.dumps(body).encode(),\n                                 headers={&quot;content-type&quot;: &quot;application/json&quot;})\n    try:\n        with urllib.request.urlopen(req, timeout=timeout) as r:\n            return json.load(r)\n    except urllib.error.HTTPError as e:\n        return {&quot;message&quot;: json.load(e).get(&quot;message&quot;, str(e))}\n\ndef run_code(language, code, stdin=&quot;&quot;):\n    if language not in ALLOWED:\n        return {&quot;ok&quot;: False, &quot;error&quot;: f&quot;language must be one of {sorted(ALLOWED)}&quot;}\n    if len(code.encode()) &gt; MAX_CODE_BYTES:\n        return {&quot;ok&quot;: False, &quot;error&quot;: &quot;code too large&quot;}\n    res = _post(&quot;/api/v2/execute&quot;, {\n        &quot;language&quot;: language, &quot;version&quot;: ALLOWED[language],\n        &quot;files&quot;: [{&quot;content&quot;: code}], &quot;stdin&quot;: stdin or &quot;&quot;,\n        &quot;run_timeout&quot;: 3000, &quot;run_memory_limit&quot;: 256 * 1024 * 1024,\n    })\n    if &quot;run&quot; not in res:   # 400: bad request, unknown runtime, limit above the server&#39;s\n        return {&quot;ok&quot;: False, &quot;error&quot;: res.get(&quot;message&quot;, &quot;execution failed&quot;)}\n    stage = res.get(&quot;compile&quot;) if res.get(&quot;compile&quot;, {}).get(&quot;code&quot;) else res[&quot;run&quot;]\n    return {\n        &quot;ok&quot;: stage[&quot;code&quot;] == 0 and stage[&quot;status&quot;] is None,\n        &quot;stage&quot;: &quot;compile&quot; if stage is not res[&quot;run&quot;] else &quot;run&quot;,\n        &quot;exit_code&quot;: stage[&quot;code&quot;], &quot;signal&quot;: stage[&quot;signal&quot;],\n        &quot;status&quot;: stage[&quot;status&quot;], &quot;message&quot;: stage[&quot;message&quot;],\n        &quot;stdout&quot;: stage[&quot;stdout&quot;][:MAX_RETURN_CHARS],\n        &quot;stderr&quot;: stage[&quot;stderr&quot;][:MAX_RETURN_CHARS],\n    }</code></pre>\n<p>What it returned for six test calls:</p>\n<table><thead><tr><th scope=\"col\">Call</th><th scope=\"col\">Result</th></tr></thead><tbody><tr><td>Python <code>print(sum(range(10)))</code></td><td><code>ok: true</code>, stdout <code>45</code></td></tr><tr><td>Python reading stdin <code>hi</code></td><td><code>ok: true</code>, stdout <code>HI</code></td></tr><tr><td>C with an undeclared variable</td><td><code>ok: false</code>, <code>stage: compile</code>, <code>status: RE</code>, compiler error in stderr</td></tr><tr><td>Python <code>while True: pass</code></td><td><code>ok: false</code>, <code>status: TO</code>, &quot;Time limit exceeded (wall clock)&quot;</td></tr><tr><td>Python <code>urlopen(&quot;http://1.1.1.1&quot;)</code></td><td><code>ok: false</code>, <code>status: RE</code>, &quot;Network is unreachable&quot; in stderr</td></tr><tr><td><code>language: &quot;ruby&quot;</code></td><td>rejected by the wrapper before any request</td></tr></tbody></table>\n<p>Two habits matter more than the code. Treat <code>stdout</code> and <code>stderr</code> as data the model reads, never as instructions to follow. And because Piston blocks the network, tell the model so in the tool description; otherwise it will try <code>pip install</code> and waste turns.</p>\n<p>If you would rather call a library than run a service, <a href=\"/entries/llm-sandbox/\">LLM Sandbox</a> runs code through Docker, Kubernetes, Podman, Bubblewrap or NsJail backends from Python and ships an MCP server.</p>\n<h2>Run Judge0</h2>\n<p>Judge0&#39;s release deployment, from the v1.13.1 release notes:</p>\n<pre><code class=\"language-bash\">wget https://github.com/judge0/judge0/releases/download/v1.13.1/judge0-v1.13.1.zip\nunzip judge0-v1.13.1.zip &amp;&amp; cd judge0-v1.13.1\n# set REDIS_PASSWORD and POSTGRES_PASSWORD in judge0.conf to long random values\ndocker-compose up -d db redis\nsleep 10s\ndocker-compose up -d</code></pre>\n<p>The Compose file runs four services. <code>server</code> and <code>workers</code> both use <code>judge0/judge0:1.13.1</code> with <code>privileged: true</code>, and the server publishes <code>2358:2358</code> on all interfaces. Change that to <code>127.0.0.1:2358:2358</code> unless a gateway sits in front.</p>\n<p>Submit code and wait for the result in one call (<code>language_id</code> 71 is Python 3.8.1 in CE):</p>\n<pre><code class=\"language-bash\">curl -s -X POST &#39;localhost:2358/submissions?base64_encoded=false&amp;wait=true&#39; \\\n  -H &#39;content-type: application/json&#39; \\\n  -d &#39;{&quot;source_code&quot;:&quot;print(\\&quot;hello\\&quot;)&quot;,&quot;language_id&quot;:71,&quot;stdin&quot;:&quot;&quot;}&#39;</code></pre>\n<p>Without <code>wait=true</code> you get a token back and poll <code>GET /submissions/{token}</code>. <code>POST /submissions/batch</code> takes up to 20 at once by default, and a <code>callback_url</code> field makes Judge0 call you when a run finishes. <code>GET /languages</code> lists ids, and <code>GET /statuses</code> returns the result codes, from 1 &quot;In Queue&quot; and 3 &quot;Accepted&quot; to 5 &quot;Time Limit Exceeded&quot;, 6 &quot;Compilation Error&quot;, 7 to 12 for runtime errors and 13 &quot;Internal Error&quot;. <code>GET /config_info</code> shows the limits in force. The interactive docs are served at <code>/docs</code> on your instance and at ce.judge0.com.</p>\n<h3>What happened on a cgroup v2 host</h3>\n<p>The API came up and answered <code>/about</code>, <code>/languages</code> (47 languages), <code>/statuses</code> and <code>/config_info</code>. Every submission came back as status 13, Internal Error, with the message <code>No such file or directory @ rb_sysopen - /box/script.py</code>. Running Isolate&#39;s setup by hand inside the workers container shows why:</p>\n<pre><code class=\"language-text\">$ isolate --version\nThe process isolator 1.8.1\n$ isolate --cg -b 99 --init\nFailed to create control group /sys/fs/cgroup/memory/box-99/: No such file or directory</code></pre>\n<p>Isolate 1.8.1, bundled in the v1.13.1 image, expects the cgroup v1 memory controller at <code>/sys/fs/cgroup/memory</code>. On a cgroup v2 host that path does not exist, so the sandbox is never created. This is what the release notes&#39; GRUB step is for. If you cannot change the host&#39;s boot parameters (most managed Kubernetes nodes and many cloud images), use Piston or a different sandbox, or give Judge0 a VM you control.</p>\n<p>(Unrelated to Judge0: on our host the Compose network&#39;s DNS and container-to-container traffic did not work, so to get this far we put all four services in one network namespace. Do not copy that workaround. It puts PostgreSQL and Redis on the same localhost as the workers, which is exactly what a sandboxed program with networking enabled must not reach.)</p>\n<h3>Judge0 settings to change</h3>\n<p>The live <code>/config_info</code> of the default install showed these, and <code>judge0.conf</code> documents each:</p>\n<ul><li><strong>Authentication is off.</strong> <code>AUTHN_TOKEN</code> is empty by default. Set it, and clients send it in the <code>X-Auth-Token</code> header (<code>AUTHN_HEADER</code> renames it). <code>AUTHZ_TOKEN</code> (header <code>X-Auth-User</code>) is a separate token for protected API calls, which are disabled while it is empty.</li><li><strong>Callers can turn networking on.</strong> <code>enable_network</code> defaults to false, but <code>allow_enable_network</code> defaults to true, so any client can send <code>&quot;enable_network&quot;: true</code> with a submission. Set <code>ALLOW_ENABLE_NETWORK=false</code>.</li><li><strong>Telemetry is on.</strong> Per TELEMETRY.md, every instance sends its instance ID and version to Judge0&#39;s server every 12 hours. Set <code>JUDGE0_TELEMETRY_ENABLE=false</code> if you don&#39;t want that.</li><li><strong>Defaults to know:</strong> 5 s CPU time (15 s max), 10 s wall time (20 s max), 128 MB memory (512 MB max), 60 processes, 1 MB per file the program writes, a queue of 100, and <code>wait=true</code> allowed. Clients can raise limits up to the <code>MAX_*</code> values, so set the <code>MAX_*</code> values to what you would accept.</li></ul>\n<h3>The 2024 sandbox escapes</h3>\n<p>v1.13.1 fixed three critical advisories reported by Daniel Cooper, all affecting versions up to 1.13.0:</p>\n<ul><li><strong>CVE-2024-28185:</strong> symlinks placed inside the sandbox directory let a submission write files outside it and run code on the host.</li><li><strong>CVE-2024-28189:</strong> a bypass of that fix, through <code>chown</code> following a symlink.</li><li><strong>CVE-2024-29021:</strong> with <code>enable_network</code>, a submission could reach the PostgreSQL database on the internal Docker network and turn that into unsandboxed code execution as root.</li></ul>\n<p>The lesson goes beyond Judge0. In both projects the API and the code it runs sit in the same privileged container, so the boundary is Isolate plus your settings. Run v1.13.1 or later, keep networking off, and put the whole stack on a host with nothing worth stealing.</p>\n<h2>Hardening checklist for either one</h2>\n<ol><li><strong>Give it its own VM or host.</strong> Both need privileged containers, and Isolate shares the host kernel. That VM is your real boundary.</li><li><strong>Bind to localhost or a private network</strong>, and put a small gateway in front that allows only the execute endpoint (<code>POST /api/v2/execute</code>, or <code>POST /submissions</code>) and checks a token. Piston has no auth at all, and its package endpoint installs runtimes for anyone.</li><li><strong>Keep networking off.</strong> It is off by default in Piston (<code>PISTON_DISABLE_NETWORKING=true</code>). In Judge0, set <code>ALLOW_ENABLE_NETWORK=false</code> so callers cannot turn it on.</li><li><strong>Set server-side ceilings.</strong> Piston: set <code>PISTON_RUN_MEMORY_LIMIT</code> and <code>PISTON_COMPILE_MEMORY_LIMIT</code>, which default to unlimited. Judge0: set the <code>MAX_*</code> values. Then set tighter per-call values in the wrapper.</li><li><strong>Install only the languages the agent needs.</strong> It is less to patch and less for the model to try.</li><li><strong>Rate-limit the tool.</strong> Piston runs up to 64 jobs at once by default (<code>PISTON_MAX_CONCURRENT_JOBS</code>); Judge0 rejects submissions once 100 are queued. An agent stuck in a loop can fill either.</li><li><strong>Treat output as untrusted</strong> and truncate it before it goes back into the model&#39;s context.</li><li><strong>Don&#39;t rely on the public Piston API.</strong> Since 15 Feb 2026 the emkc.org instance needs a key from the maintainer, issued only for non-commercial educational projects. Judge0&#39;s hosted option is Judge0 Cloud, through Rapid or directly.</li></ol>\n<p>When the agent outgrows one-shot runs and needs a persistent workspace, move to a machine-style sandbox: <a href=\"/entries/e2b/\">E2B</a> or the others in <a href=\"/compare/code-sandboxes/\">hosted code sandboxes</a>, or <a href=\"/entries/microsandbox/\">microsandbox</a> and the others in <a href=\"/compare/self-hosted-agent-sandboxes/\">self-hosted agent sandboxes</a>.</p>"
}
