Loading…
gathering yarn balls…
Cat Game Research
Frontier
Dev-log transmissions from the expedition

Cage Your Python, Polyglot and Me

Self-contained shell wrappers keep Python small, portable, and exactly as dangerous as you allow.

Python is the strongest animal in most automation zoos. It can parse JSON, crunch text, talk to the network, and reason about data better than any shell. But it is also an animal that wants to roam: it drags in virtual environments, pip dependencies, shebang disputes, PYTHONPATH mutations, and the quiet assumption that the machine it runs on looks like the machine it was written on. The cage is the shell wrapper that lets Python do its work without letting it take over the enclosure.

1
source file
0
committed venvs
∞
contexts passed
3
doors in

The cage is not a sandbox, but it is a portable, self-describing boundary.

What the cage is

A polyglot cage is a single file that is primarily a POSIX shell script but embeds a Python payload inside it. The shell half discovers the interpreter, the Python half does the heavy lifting, and the whole thing leaves no extra files behind. The result is a small, movable, context-aware utility that can live in scripts/, bin/, a gist, a USB stick, or a CI pipeline with nothing but a shell and a Python interpreter already present.

The word polyglot here does not mean the NLP library or the multilingual model. It means the file speaks two languages: it is a valid shell program that carries a valid Python program inside it. The two layers cooperate, but the shell layer is always the cage.

The three doors into Python

There are three practical ways to get a block of Python into a running interpreter from inside a shell script. Each has a different relationship with stdin, argv, and cleanliness.

DoorMechanismstdinargvBest for
Heredocpython3 - <<'PY' ... PYoccupied by scriptavailable after -most self-contained scripts
Process substitutionpython3 <(cat <<'PY' ... PY)free for datapath to source as sys.argv[0]filters in a pipeline
Temp filemktemp; cat > $tmp; python3 $tmp; rm $tmpfree for dataextra script path firstseekable input or debugging

Table 1 — every door is a trade-off between simplicity, portability, and control of stdin.

Note on process substitution: it is not POSIX, and the path Python sees is usually a /dev/fd/N file-descriptor reference or a temporary FIFO, depending on the shell. That keeps stdin free for data, but it is less portable than a heredoc.

The heredoc is the default cage. The quoted delimiter (<<'PY') is the most important detail: it prevents the shell from expanding $ variables or command substitutions inside the Python block, so the code you feed the interpreter is exactly the code you wrote. If you forget the quotes, you are not caging Python; you are asking the shell to rewrite it first, which is how accidental code injection and environment leakage happen.

Rule of thumb: quote the heredoc delimiter unless you are deliberately templating the Python source from shell variables. Even then, consider a temp file and explicit substitution so the boundary is visible.

A minimal cage

Here is the smallest useful form. It finds a Python interpreter, runs an embedded snippet, passes command-line arguments through, and exits with the interpreter's own status. There is no venv, no pip install, and no leftover .py file.

#!/bin/sh
set -u

py=
for c in python3 python python2; do
    py=$(command -v "$c" 2>/dev/null) && break
done
[ -n "$py" ] || { echo 'no python found' >&2; exit 1; }

exec "$py" - "$@" <<'PY'
import sys
print("hello from", sys.executable)
print("args:", sys.argv[1:])
PY

The - argument tells Python to read the script from stdin, which in this case is the heredoc. Any arguments after - become sys.argv[1:]. The exec replaces the shell process with Python, so the wrapper does not hang around as a parent. When the script ends, the shell is already gone and the original #!/bin/sh line is irrelevant. That is the cage: the file starts as shell, becomes Python, and then vanishes.

Context-aware and recursive

The real power of the cage is not one-way embedding; it is the ability of the shell to gather runtime context and pass it into Python, then let Python decide the next move. The shell is good at environment, files, and process wiring. Python is good at parsing, computing, and generating structured output. Together they can be recursive: shell → Python → shell → Python, with each layer reading the state the previous layer produced.

Example: a context-aware filter

Suppose you have a log and you want to filter for any of several patterns. The shell already knows the patterns from the user; Python knows how to build a fast regex. The shell passes the patterns as argv, Python reads the actual data from stdin.

#!/bin/sh
python3 - "$@" <<'PY'
import re, sys
patterns = [re.escape(a) for a in sys.argv[1:]]
rx = re.compile("|".join(patterns))
for line in sys.stdin:
    if rx.search(line):
        print(line, end="")
PY

Called as cat server.log | ./cage-grep.sh error warn timeout, the shell turns the user arguments into sys.argv and Python turns them into a single matching engine. The same file is a different program every time it runs.

Example: a self-feeding matrix

The shell can also hand a list of variants to Python, ask Python to expand them into commands, and then dispatch those commands itself. This is the recursive loop: the shell holds the launch pad, Python designs the flight plan, and the shell launches the rockets.

#!/bin/sh
set -eu

TARGETS=${TARGETS:-cpu vulkan cuda}

commands=$(python3 - "$TARGETS" <<'PY'
import sys, shlex
targets = sys.argv[1].split()
for t in targets:
    print(f"./build.sh --backend {shlex.quote(t)}")
PY
)

printf '%s\n' "$commands" | while IFS= read -r cmd; do
    # Do not eval. Run through a known, constrained dispatch.
    set -- $cmd
    shift
    backend=$1
    echo "[$backend] starting build..."
done

Notice that the output of Python is data consumed by the shell in a while read loop, not code executed with eval. That distinction is the difference between a recursive helper and an accidental remote shell. If you let the Python half emit shell syntax and then execute it blindly, you have not caged Python; you have given it the keys.

Example: recursive countdown

Because the wrapper is a single file and Python can re-invoke it by path, a shell/Python pair can recurse on itself. The shell checks the base case, Python does the work, and Python re-executes the same wrapper with a decremented environment variable.

#!/bin/sh
COUNT=${COUNT:-3}
[ "$COUNT" -le 0 ] && { echo "done"; exit 0; }

python3 - "$COUNT" "$0" <<'PY'
import os, subprocess, sys
n = int(sys.argv[1])
print(f"level {n}")
subprocess.run([sys.argv[2]], env={**os.environ, "COUNT": str(n - 1)})
PY

Run it and it counts down to zero, with the shell half handling the loop guard and the Python half handling the print and the next invocation. This is a toy, but it shows the shape: shell as control plane, Python as worker, the file as both.

Where we already cage Python: CatGame build scripts

We did not invent this for a blog post. The CatGame repository uses caged Python all over the build and tooling pipeline. The wrapper keeps the tool portable; the embedded Python keeps the tool competent.

HTML/CSS/JS minifier

scripts/lib/www/www-minify.sh embeds a pure-Python 3 stdlib minifier and calls it with a single directory argument. No build environment, no external packages, one file.

Arena receipt validator

scripts/lib/pi_arena/validate_receipts.sh wraps a Python block that walks JSONL receipt files, checks token counts, detects placeholders, and reports duplicates.

Corpus analyzer

scripts/lib/data/analyze-corpus.sh uses a shell harness for argument parsing and environment, then passes the work to an embedded Python analyzer for sequence lengths and percentile statistics.

API doc generator

scripts/lib/www/www-build-api.sh calls Python for version extraction, navtree rewriting, and shader-page generation inside the same build pipeline that is otherwise shell-driven.

These are not tiny hacks. They are deliberate boundaries: shell owns the project root, presets, logging, and exit codes; Python owns the data transforms that would be painful or slow in pure shell. The cage lets each language do what it is good at without either one colonizing the project structure.

When not to cage

The cage is not the only answer. If a script needs a long-term dependency set, shared modules, or a team that already standardizes on a virtual environment, the heredoc pattern becomes an awkward jail. In that world, PEP 723 inline metadata and runners like uv are a better fit: a single .py file declares its own dependencies inside a comment block, and the runner builds a temporary environment on demand. That is also a cage, but the walls are dependency resolution instead of a heredoc.

ApproachDependenciesBest forTrade-off
Shell + heredoc PythonPOSIX shell + any Pythonbuild scripts, one-off tools, CIno external packages, stdlib only
PEP 723 + uvuv + network/package indexscripts that need PyPI packagesneeds runner and can fetch from network
pyproject.toml projectpip/uv/pdm + venvmulti-file libraries or teamsmore structure, more reproducibility

Table 2 — choose the smallest cage that fits the actual dependencies.

Security: a cage is not a sandbox

We need to be precise. A polyglot shell wrapper contains Python but does not isolate it. The interpreter still runs as your user, can still read your files, and can still do anything your shell can do if the Python code asks. If you are running untrusted or downloaded Python, you need a real sandbox such as bubblewrap, Firejail, a container, or a virtual machine.

Do not feed user input into python3 -c "$input", eval Python output, or run Python as root because a wrapper made it look harmless. The cage makes deployment clean; it does not make code trustworthy.

Inside the wrapper, a few habits keep the boundary firm:

The thesis

Python does not have to own the garden to work in it. A dynamic, polyglot shell wrapper is the smallest cage that still lets Python do its best work: parsing, computing, and generating structured output. The shell handles discovery, wiring, and cleanup. The file stays self-contained, portable, and easy to move between machines and pipelines. It is not a substitute for a real sandbox, but it is a powerful way to keep Python from running wild in your system.

The right tool for the right job, kept in the right cage.

Verified references

  1. Thomas Stringer, "Run Python Code in a Shell Script" — heredoc-based ad hoc Python inside bash, with discussion of indentation and variable expansion.
  2. Stack Overflow: "python -c vs python -<< heredoc" — discusses stdin, single-quote traps, and process substitution.
  3. Greg's Wiki: HereDocument — POSIX heredoc behavior, quoted delimiters, tab stripping, and temp-file implementation notes.
  4. ewenmcneill/please-be-python — a Bourne shell / Python polyglot that probes for an interpreter and execs Python on the same file.
  5. PEP 723 — Inline script metadata — the standard for embedding dependency metadata inside a single .py file.
  6. PyPA: Inline script metadata — canonical specification for the # /// script block.
  7. containers/bubblewrap — an unprivileged sandboxing tool for actually isolating untrusted programs, not just wrapping them.
← All transmissions