MCP server to run Python code in a sandbox.
  • Python 91.8%
  • Shell 4.6%
  • Makefile 2.3%
  • Dockerfile 1.3%
Find a file
max a37a1c75f6 Route sandbox internet through a separate egress proxy
vivarium.network stays Internal, so the container still has no route to
the LAN, the router or other Podman networks. The quadlet now sets
http(s)_proxy to an egress-proxy container (~/ai/egress-proxy, not part
of this repo) that is dual-homed on the sandbox network and the
internet-capable network, allows only ports 80/443, and refuses targets
resolving to private, loopback, link-local, CGNAT or metadata addresses.
Enforcement lives outside the sandbox, so code inside it cannot disable
the policy.

The README threat model now says what this does and does not protect
against: public hosts are reachable, so exfiltration to them is
possible. The run_python docstring tells the model internet is
available for public hosts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:56:26 +02:00
deploy Route sandbox internet through a separate egress proxy 2026-10-21 19:56:26 +02:00
src Route sandbox internet through a separate egress proxy 2026-10-21 19:56:26 +02:00
tests Cut idle CPU: 1 s uvicorn tick, 5 min healthcheck 2026-10-21 19:32:57 +02:00
.gitignore Ignore uv.lock 2026-10-21 19:01:28 +02:00
Containerfile Make image build resilient to slow networks (pip timeout/retries) 2026-10-04 13:13:56 +02:00
Makefile Pin dependencies with hash-verified requirements.txt 2026-10-02 11:56:50 +02:00
PROMPT.md Import tested python-sandbox prototype 2026-10-01 14:25:22 +02:00
pyproject.toml Add vivarium_charts helpers for short, crash-resistant charts 2026-10-21 17:52:34 +02:00
README.md Route sandbox internet through a separate egress proxy 2026-10-21 19:56:26 +02:00
requirements.txt Pin dependencies with hash-verified requirements.txt 2026-10-02 11:56:50 +02:00

vivarium

Sandboxed Python execution MCP server for LibreChat. The model gets a run_python tool for data work: download CSV/XLSX files, analyse them with pandas, and make matplotlib charts that are returned as images.

Each call runs in a fresh CPython subprocess inside a locked-down rootless Podman container (read-only root filesystem, no capabilities, resource limits, isolated network). Files in the /workspace volume persist between calls — and so, since the persistent-namespace work, does the Python namespace itself. See Persistent namespace.

Persistent namespace

Every run_python call goes through vivarium._runner, which restores the previous call's globals before running your code and pickles them back afterwards. The point is not convenience: in one measured research session the model wrote import requests in 23 of 28 calls, fetched the same DBnomics URL 4 times and re-wrote the same read_csv line 3 times, because each call had started from an empty namespace. Re-emitting that pipeline is expensive — code generation, not execution, is what makes a call slow (measured: 83% of tool time is spent generating code, 17% running it).

What carries over:

  • Any picklable value. DataFrames, dicts, lists, numbers, strings, and functions imported from importable modules.
  • Module aliases. Modules cannot be pickled, so the runner records which alias pointed at which module and re-imports it on the next call. import pandas as pd once and pd is bound in every later call.
  • Files, as before, in /workspace.

What does not:

  • Lambdas and locally-defined functions, open file handles, database connections, sockets — anything unpicklable.
  • Anything you name _vivarium* or __dunder__.

Nothing is dropped silently. Every result ends with a footer describing exactly what the next call will start with:

--- state ---
namespace: restored 6 name(s)
vars: URL 78 chars  df 2306x2 [GBP, USD]  line_chart line_chart()  raw 2306 keys
already imported (do not re-import): pd=pandas  requests=requests
gone next call: handle (TextIOWrapper is not picklable)
namespace size: 0.2 MB
wrote: fx.png (64K)
workspace: 42 files, newest: fx.png, ameco_all.pkl, ameco_rel.pkl

The footer is deliberately short — it rides along with every single result.

Escape hatches. fresh=True runs with an empty namespace and discards the stored one (the footer says how many names were thrown away). Set VIVARIUM_PERSIST_NAMESPACE=0 to turn the whole thing off and get the old stateless behaviour back. VIVARIUM_STATE_SUMMARY=0 drops the footer but keeps persistence.

The namespace is capped at VIVARIUM_NAMESPACE_MAX_BYTES (default 256 MB). When a session exceeds it the largest values are dropped first, and each drop is named in the footer.

Threat model (read this)

Be honest with yourself about what this is:

  • Internet through a proxy, never the LAN. vivarium.network is internal (Internal=true): the container has no default route, so model-written code cannot reach the LAN, the router, or any other Podman network. The only way out is the egress proxy (~/ai/egress-proxy), which is dual-homed on the sandbox network and the internet-capable ai network, and refuses any target that resolves to a private, loopback, link-local, CGNAT or metadata address. Only ports 80/443 are allowed. http_proxy/https_proxy are set in the quadlet, so requests, urllib and pip use it automatically.

    The enforcement lives in a separate container on purpose. The sandbox has DropCapability=all and no route, so it cannot disable the policy the way it could if the firewall rule lived inside its own network namespace. Verified: a trust_env=False session (no proxy, raw sockets) cannot reach the router, the internet, or any other container.

    Exfiltration is still possible to public hosts. The proxy allows any public address, so code can POST data to a public endpoint. That is inherent to giving a sandbox internet at all — treat it as "can reach the public internet, cannot touch your network", not as a data-loss guarantee.

  • Container isolation is not a VM. Even without network, this is meant for a single trusted user running model-written code, not for untrusted multi-tenant use. A container escape lands on your host as your user.

  • No pip at runtime. The root filesystem is read-only. To add packages, edit the data extra in pyproject.toml and rebuild the image.

Treat the model's code like you would treat a shell command pasted from a forum: convenient, usually fine, occasionally surprising.

Quick start (rootless Podman host, e.g. CachyOS)

git clone <this repo> && cd vivarium
make build              # podman build -t localhost/vivarium .
make install-quadlets   # copies quadlets, systemctl --user daemon-reload
systemctl --user start vivarium
make smoke              # probes /health + MCP initialize via the container network

To start at boot without login: loginctl enable-linger $USER.

Then wire up LibreChat (two places):

  1. Merge deploy/librechat.yaml.example into your librechat.yaml (mcpServers entry, allowedAddresses).
  2. In your LibreChat quadlet's [Container] section, add Network=vivarium.network — vivarium's network is internal, so this is the only way LibreChat can reach it. Then systemctl --user daemon-reload and restart LibreChat.

Optional: for per-user workspaces, uncomment the Environment=VIVARIUM_USER_HEADER=X-User-ID line in the quadlet before starting (see Multiple clients / multiple users).

Troubleshooting: charts not showing in LibreChat

vivarium returns charts as MCP image content (base64). Whether they display well depends on LibreChat and your model endpoint, not on vivarium:

  • Update LibreChat — MCP image handling has had several bugs (images shown as raw base64, broken asset URLs/401s, tool results with images blowing the context window when serialized as text). These are fixed upstream over time.
  • Local/text-only models: a large PNG can be serialized into the model context as base64 text (hundreds of KB ≈ tens of thousands of tokens) and blow a small context window, leaving the model confused. vivarium downscales returned PNGs to VIVARIUM_MAX_IMAGE_SIDE (default 1280 px) for this reason. If your model has no vision support at all, set VIVARIUM_MAX_IMAGES=0 — charts stay in the workspace as files and the model works with textual output only.

Development

Python 3.12, managed with uv:

make venv     # or: uv venv --python 3.12 && uv pip install -e ".[dev]"
make test

Dependencies are pinned in requirements.txt with sha256 hashes (via uv pip compile); the Containerfile installs it with --require-hashes, so image builds are byte-reproducible. Edit the data extra in pyproject.toml, then make lock && make build. (In phase 2 the same pin set feeds the NixOS packaging.)

On NixOS, PyPI's manylinux wheels need nixpkgs' libstdc++/zlib; the Makefile exports a suitable LD_LIBRARY_PATH automatically when it detects NixOS. If you set the venv up by hand there, build it from the nixpkgs interpreter (uv venv --python "$(nix shell nixpkgs#python312 -c which python3.12)"), because uv's downloaded CPython does not run on NixOS.

Tools

  • run_python(code, timeout_seconds=60, fresh=False) — run code with cwd = the workspace, restoring the previous call's namespace unless fresh=True. Returns the exit code, clipped stdout/stderr (tail is kept, truncation is marked), up to VIVARIUM_MAX_IMAGES new/changed *.png files as images, and the --- state --- footer. On timeout the subprocess is killed and a clear message is returned; a killed run writes no report, so it simply comes back without a footer.
  • list_files() — workspace listing with sizes.
  • read_text_file(path, max_chars=10000) — read a text file from the workspace; paths escaping the workspace are rejected.

The server also exposes GET /health (200, for container healthchecks) and logs every run_python call with duration, exit code and code length — never the code itself.

Chart helpers

vivarium_charts ships in the image so charts cost ~110 characters instead of ~3,700. This matters because chart code is generated by the model: a 3.7 KB matplotlib script cost ~29s of generation time before the tool even ran, and then crashed on a format-string bug, so the 29s was paid twice.

from vivarium_charts import line_chart, bar_chart, scatter_chart

line_chart(df, x="year", y=["de", "fr"],
          title="Unit labour costs, 1995=100", ylabel="Index")

bar_chart(df, x="quarter", y=["ulc", "mrc"], horizontal=True)
scatter_chart(df, x="invest", y="growth")

What it handles so the model does not have to:

  • x and y are both optional. Omit y to plot every numeric column; omit x to use the DataFrame index. line_chart(df, title="...") is a complete call for a date-indexed frame. The x column is excluded from the y default so a numeric year column is never plotted against itself.
  • Junk values are coerced, not fatal. Empty strings and "n/a" become NaN instead of raising — the single most common crash in hand-written plot code.
  • Thousands separators on the y-axis, date-axis auto-rotation, tight layout, chat-sized figures, top/right spines removed.
  • Grouped bars automatically when y names more than one column.
  • Returns the filename it wrote; the existing new-PNG watcher returns it to chat.

Only fall back to raw plt.savefig(...) when the helpers cannot express it.

clean_numeric — the "NA" trap

AMECO/DBnomics return the literal string "NA" for missing observations (a third of a typical unit-labour-cost series), Eurostat uses ":". astype(float) raises on all of them, and the crash usually lands after the model has already spent its tool budget building the frame.

from vivarium_charts import clean_numeric

df = clean_numeric(df)                     # every column
df = clean_numeric(df, ["PLCM", "PLCDQ"]) # or just these

It never destroys a text column: a column with nothing numeric in it is left untouched rather than silently becoming all-NaN. Do this before any arithmetic, not just before plotting.

Installed packages

The data stack lives in the data extra in pyproject.toml (single source of truth). The Containerfile installs the pinned, hash-verified requirements.txt generated from it — after editing the extra, run make lock and rebuild:

  • Core: numpy, pandas, scipy, matplotlib, seaborn, scikit-learn, statsmodels, polars, duckdb, pyarrow
  • Computer algebra / solvers: sympy, mpmath + gmpy2 (arbitrary precision), z3-solver (logic/constraints), PuLP (LP/MILP optimization)
  • Simulation: simpy (discrete-event), mesa (agent-based), networkx (graphs)
  • Extraction: pdfplumber/pypdf (PDF), python-docx/python-pptx, openpyxl / xlrd / odfpy (Excel legacy / ODS), requests, beautifulsoup4, lxml
  • Generation & formats: fpdf2 (PDF), xlsxwriter (formatted Excel), pillow, h5py (HDF5), orjson, jsonlines, xmltodict, pyyaml
  • Quality of life: pint (units), tabulate (markdown tables), rapidfuzz (fuzzy matching), ftfy (mojibake repair), numexpr/bottleneck (pandas accelerators)
  • Heavy hitters: scikit-image + opencv-headless (image processing), numba (JIT), OR-Tools (CP-SAT, scheduling, routing), geopandas + shapely + pyproj + pyogrio (geospatial)
  • Domain: rdkit (chemistry), astropy (astronomy), biopython (bioinformatics), librosa (audio analysis)

Everything works offline; nothing in the stack downloads models or datasets at runtime. Deliberately not included: SageMath (gigabytes, not pip-installable), PyTorch/TensorFlow (GB-scale, out of scope), OCR/Java-based extractors (need system binaries), and API clients like yfinance / wolframalpha (pointless without internet, and they need API keys).

Multiple clients / multiple users

Three modes, in increasing order of isolation and effort:

1. Shared workspace (default). Everyone uses one /workspace. Same person on several devices: fine — the shared state is a feature (start an analysis on the desktop, check the result on the phone). Several trusting people: workable, but everyone sees everyone's files.

2. Per-user directories (accident barrier). Set VIVARIUM_USER_HEADER=X-User-ID and have LibreChat send X-User-ID: {{LIBRECHAT_USER_ID}} (see deploy/librechat.yaml.example). Each user gets their own users/<id>/ directory, and different users' runs execute in parallel instead of queueing. Not a security boundary: executed code runs as one container user and can read other users' directories with plain file I/O (e.g. open('../<id>/file')) — the MCP layer cannot stop that. Fine for a family server, wrong for adversarial users.

3. Per-user containers (real isolation). One container, one internal network and one workspace volume per user — podman enforces the boundary, so neither sandboxed code nor a forged header can cross it:

deploy/add-user.sh max alice        # renders vivarium-<user>.{container,network}
systemctl --user start vivarium-max vivarium-alice

Then attach LibreChat to each per-user network (Network=vivarium-max.network etc. in its quadlet) and use deploy/librechat-per-user.yaml.example: one MCP entry with http://vivarium-{{LIBRECHAT_USER_USERNAME}}:8000/mcp — LibreChat substitutes the placeholder per request, so every user's chats reach their own sandbox. Per-user limits default to 2 GB RAM / 2 CPUs / 128 PIDs (override via SANDBOX_MEMORY etc.), sized so a few concurrent users fit the host; each idle sandbox costs ~150–300 MB. New users are provisioned manually (add-user.sh again) — there is no auto-creation. Usernames must be DNS-safe; use {{LIBRECHAT_USER_ID}} if yours are not.

Common to all modes:

  • run_python calls are serialized per workspace (others queue): calls sharing a workspace would race on files and on the before/after chart snapshot. Queued calls just wait — mind your client timeout when a long run is ahead of yours.
  • While a run is in progress the server stays responsive: /health, list_tools and list_files from other clients are served normally.

Configuration

Environment variables only, all prefixed VIVARIUM_:

Variable Default Meaning
VIVARIUM_WORKDIR /workspace Working directory / persistent volume
VIVARIUM_HOST 0.0.0.0 Bind address
VIVARIUM_PORT 8000 Port (MCP path is /mcp)
VIVARIUM_MAX_TIMEOUT 300 Upper clamp for timeout_seconds
VIVARIUM_OUTPUT_LIMIT 20000 Chars kept per stream (stdout/stderr)
VIVARIUM_MAX_IMAGES 4 Max PNGs returned per call (0 disables image return — use this for text-only models)
VIVARIUM_MAX_IMAGE_SIDE 1280 Returned PNGs are downscaled to this max side length (workspace files stay full-size)
VIVARIUM_PERSIST_NAMESPACE 1 Carry the Python namespace between run_python calls (0 restores the old stateless behaviour)
VIVARIUM_NAMESPACE_MAX_BYTES 268435456 Cap on the pickled namespace; largest values are dropped first when exceeded
VIVARIUM_STATE_SUMMARY 1 Append the --- state --- footer to every result
VIVARIUM_USER_HEADER (unset) HTTP header carrying a per-user identity (e.g. X-User-ID); when set, each caller gets their own workspace under users/<id>/

The server accepts any Host header (DNS rebinding protection is disabled): inside a container network it is reached as http://vivarium:8000/mcp, and the hostname cannot be known ahead of time. This is safe here because the server has no per-origin state or credentials.

Deployment (rootless Podman + systemd Quadlets)

deploy/quadlet/vivarium.container and vivarium.network give you:

  • read-only root filesystem, Tmpfs=/tmp, all capabilities dropped, NoNewPrivileges
  • 4 GB RAM / 4 CPU / 256 PID limits
  • an internal network (Internal=true — no internet, no LAN), plus Options=isolate=strict blocking routing to other Podman networks; a commented DNS= line is there in case you ever disable Internal — rootless DNS often fails without an explicit resolver
  • a healthcheck against /health and Restart=always

LibreChat setup

Merge deploy/librechat.yaml.example into your librechat.yaml: a streamable-http MCP server entry with requiresOAuth: false, a client timeout above VIVARIUM_MAX_TIMEOUT, model instructions, and the matching mcpSettings.allowedAddresses entries.