- Python 91.8%
- Shell 4.6%
- Makefile 2.3%
- Dockerfile 1.3%
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> |
||
|---|---|---|
| deploy | ||
| src | ||
| tests | ||
| .gitignore | ||
| Containerfile | ||
| Makefile | ||
| PROMPT.md | ||
| pyproject.toml | ||
| README.md | ||
| requirements.txt | ||
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 pdonce andpdis 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.networkis 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-capableainetwork, 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_proxyare set in the quadlet, sorequests,urllibandpipuse it automatically.The enforcement lives in a separate container on purpose. The sandbox has
DropCapability=alland no route, so it cannot disable the policy the way it could if the firewall rule lived inside its own network namespace. Verified: atrust_env=Falsesession (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
dataextra inpyproject.tomland 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):
- Merge
deploy/librechat.yaml.exampleinto yourlibrechat.yaml(mcpServersentry,allowedAddresses). - In your LibreChat quadlet's
[Container]section, addNetwork=vivarium.network— vivarium's network is internal, so this is the only way LibreChat can reach it. Thensystemctl --user daemon-reloadand 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, setVIVARIUM_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 unlessfresh=True. Returns the exit code, clipped stdout/stderr (tail is kept, truncation is marked), up toVIVARIUM_MAX_IMAGESnew/changed*.pngfiles 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:
xandyare both optional. Omityto plot every numeric column; omitxto use the DataFrame index.line_chart(df, title="...")is a complete call for a date-indexed frame. Thexcolumn is excluded from theydefault 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
ynames 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_pythoncalls 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_toolsandlist_filesfrom 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), plusOptions=isolate=strictblocking routing to other Podman networks; a commentedDNS=line is there in case you ever disableInternal— rootless DNS often fails without an explicit resolver - a healthcheck against
/healthandRestart=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.