Add a new programming language runtime to the sandbox. Use this skill when the user asks to add a new language, new runtime, or support for a new programming language (e.g., "Add Rust support", "add Python runtime", "support a new language"). Also trigger when the user mentions adding a runtime, language support, or interpreter/compiler to the sandbox execution engine. This skill covers the full end-to-end process: runtime implementation, Docker setup, resource limit design, testing, and documentation.
This skill guides the complete process of adding a new programming language runtime to the sandbox. It covers every touchpoint in the codebase and includes resource limit design rationale.
Before starting, gather the following information from the user:
mise ls-remote <tool> or the mise registry to confirm the tool name and available versions.Resource limits are a security boundary. Choose values based on the runtime's characteristics, not arbitrary defaults. Below are the existing limits for reference, followed by the decision framework.
| Limit | Node.js | Ruby | Python | Go (run) | Go (compile) | Rust (run) | Rust (compile) | Node-TS (run) | Node-TS (compile) | Bash |
|---|---|---|---|---|---|---|---|---|---|---|
| AS (MiB) | 4096 | 1024 | 1024 | 1024 | 4096 | 1024 | 4096 | 4096 | 4096 | 512 |
| Fsize (MiB) | 64 | 64 | 64 | 64 | 64 | 64 | 64 | 64 | 64 | 64 |
| Nofile | 64 | 64 | 64 | 64 | 256 | 64 | 256 | 64 | 256 | 64 |
| Nproc | soft | soft | soft | soft | soft | soft | soft | soft | soft | soft |
| PidsMax | 64 | 32 | 32 | 64 | 128 | 64 | 128 | 64 | 64 | 32 |
| MemMax (bytes) | 268435456 | 268435456 | 268435456 | 268435456 | 268435456 | 268435456 | 268435456 | 268435456 | 268435456 | 268435456 |
| MemSwapMax | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| CpuMsPerSec | 900 | 900 | 900 | 900 | 900 | 900 | 900 | 900 | 900 | 900 |
These values are consistent across all runtimes and should be kept as-is unless there is a strong, documented reason to deviate:
These values require runtime-specific analysis:
The AS limit controls the maximum virtual address space. It does NOT directly limit physical memory (that's MemMax). Unmapped VAS pages consume no RAM, so a higher AS is safe when MemMax constrains physical usage.
| Category | Value | When to use |
|---|---|---|
| 4096 | High VAS | Runtimes with JIT/mmap-heavy memory management (V8/Node.js, JVM, .NET CLR). Also needed for compiler toolchains (Go compiler + linker). |
| 1024 | Standard | Traditional interpreters (CPython, CRuby, Perl) and compiled binaries. |
| 512 | Minimal | Lightweight runtimes (Bash, shell utilities). Bash needs ~2.8× output size for command substitution. |
How to decide: Run the runtime with a simple program and check its VAS usage (/proc/<pid>/status → VmSize). Then add 2-4× headroom. If the runtime uses mmap-based garbage collection (like V8 or JVM), use 4096.
| Category | Value | When to use |
|---|---|---|
| 64 | Standard | Most runtimes. Covers stdin/stdout/stderr (3) + nsjail internal fds (~5) + runtime engine fds. |
| 256 | High | Compilation steps that open many source/object files concurrently (e.g., go build). |
| Category | Value | When to use |
|---|---|---|
| 32 | Low concurrency | Single-threaded interpreters (Ruby, Python, Bash). Limits fork bombs. |
| 64 | Moderate concurrency | Runtimes with built-in concurrency (Node.js worker_threads, Go goroutines). |
| 128 | High concurrency | Compilation steps with heavy parallelism (Go compiler). |
How to decide: Run a "hello world" program and check the peak thread/process count. Then add headroom for user-created threads. Interpreters that rarely spawn threads → 32. Runtimes with native concurrency support → 64.
Compiled runtimes need TWO sets of limits: one for compilation (CompileLimits) and one for execution (Limits). Compilation typically needs:
Before writing code, verify that the runtime installs correctly via mise on the target platform (Debian bookworm / glibc).
# Check available versions
mise ls-remote <tool> | tail -20
# Check if special settings are needed (like ruby.compile=false)
# Search mise docs for the tool
Key considerations:
ruby.compile=false to use prebuilt binaries instead of compiling from source)./mise/installs/<tool>/<version>/bin/<executable>.The following files need changes. Items marked with ★ apply only to compiled runtimes.
internal/sandbox/runtime.goAdd the constant to the const block. Insert before RuntimeBash (Bash is always last by convention):
const (
RuntimeNode RuntimeName = "node"
RuntimeRuby RuntimeName = "ruby"
RuntimeGo RuntimeName = "go"
RuntimePython RuntimeName = "python"
// ← Insert new runtime here (before RuntimeBash)
RuntimeBash RuntimeName = "bash"
)
Add the entry to the runtimes map variable, matching the constant order:
var runtimes = map[RuntimeName]Runtime{
RuntimeNode: nodeRuntime{},
RuntimeRuby: rubyRuntime{},
RuntimeGo: goRuntime{},
RuntimePython: pythonRuntime{},
// ← Insert new runtime here (before RuntimeBash)
RuntimeBash: bashRuntime{},
}
Insert the implementation between the preceding runtime's section and the next one. Follow the // --- Name --- section header convention.
Interpreted runtime template (use Ruby/Python as reference):
// --- LanguageName ---
type langRuntime struct{}
func (langRuntime) Name() RuntimeName { return RuntimeLang }
func (langRuntime) Command(entryFile string) []string {
return []string{"/mise/installs/<tool>/<version>/bin/<executable>", entryFile}
}
func (langRuntime) BindMounts() []BindMount {
return []BindMount{{Src: "/mise/installs/<tool>/<version>", Dst: "/mise/installs/<tool>/<version>"}}
}
func (langRuntime) Env() []string {
return []string{"PATH=/mise/installs/<tool>/<version>/bin:/usr/bin:/bin"}
}
// Limits returns resource limits for <Language> execution.
// Rlimits:
// - AS <value> MiB: <rationale>.
// - Fsize 64 MiB: sufficient for typical output files.
// - Nofile <value>: <rationale>.
// - Nproc soft: inherits the system soft limit; per-sandbox process limiting is handled by cgroup_pids_max.
//
// Cgroups:
// - PidsMax <value>: per-cgroup task limit (processes + threads); limits fork bombs and runaway thread creation.
// - MemMax 268435456 (256 MiB): physical memory limit; prevents sandbox OOM from affecting the host.
// - MemSwapMax 0: swap disabled to enforce strict memory limits.
// - CpuMsPerSec 900: throttle CPU to 900 ms per second (90% of one core).
func (langRuntime) Limits() Limits {
return Limits{
Rlimits: Rlimits{
AS: "<value>",
Fsize: "64",
Nofile: "<value>",
Nproc: "soft",
},
Cgroups: Cgroups{
PidsMax: "<value>",
MemMax: "268435456",
MemSwapMax: "0",
CpuMsPerSec: "900",
},
}
}
func (langRuntime) RestrictedFiles() []string { return nil }
★ Compiled runtime: additionally implement the CompiledRuntime interface methods (CompileCommand, CompileBindMounts, CompileEnv, CompileLimits) following the Go runtime as reference. Add var _ CompiledRuntime = langRuntime{} type assertion after the existing one for Go.
If the runtime requires default files (like Go's go.mod and go.sum), create them under internal/sandbox/defaults/<runtime>/. Files with .tmpl suffix have the suffix stripped at runtime. Most interpreted runtimes need no default files.
If certain filenames must be rejected (like Go's go.mod, go.sum, main), return them from RestrictedFiles(). Most interpreted runtimes return nil.
DockerfileAdd the runtime installation in the base stage, after the existing runtime installations:
# <Tool>
ENV PATH="/mise/installs/<tool>/<version>/bin:$PATH"
RUN mise use -g <tool>@<version>
If the runtime needs special mise settings (like Ruby's ruby.compile=false), add them in the same RUN command.
★ Compiled runtime: may need additional setup like pre-building standard libraries or pre-downloading dependencies (see Go's pattern with go build std and go mod download).
internal/sandbox/sandbox_test.goAdd 4 test entries:
Test_LookupRuntime — add valid runtime entry:{name: "<lang> is valid", runtime: Runtime<Lang>, wantErr: false},
Test<Lang>Runtime_Limits — add new test function:func Test<Lang>Runtime_Limits(t *testing.T) {
t.Parallel()
rt := <lang>Runtime{}
got := rt.Limits()
assert.Equal(t, "<AS>", got.Rlimits.AS)
assert.Equal(t, "64", got.Rlimits.Fsize)
assert.Equal(t, "<Nofile>", got.Rlimits.Nofile)
assert.Equal(t, "soft", got.Rlimits.Nproc)
assert.Equal(t, "<PidsMax>", got.Cgroups.PidsMax)
assert.Equal(t, "268435456", got.Cgroups.MemMax)
assert.Equal(t, "0", got.Cgroups.MemSwapMax)
assert.Equal(t, "900", got.Cgroups.CpuMsPerSec)
}
★ Compiled runtime: also test CompileLimits() in the same function (see TestGoRuntime_Limits).
Test_readDefaultFiles — add sub-test:t.Run("<lang> has no defaults", func(t *testing.T) {
t.Parallel()
files, err := readDefaultFiles(Runtime<Lang>)
assert.NoError(t, err)
assert.Empty(t, files)
})
If the runtime HAS default files, assert their names and content instead.
TestRuntime_RestrictedFiles — add sub-test:t.Run("<lang> has no restricted files", func(t *testing.T) {
t.Parallel()
rt, err := LookupRuntime(Runtime<Lang>)
require.NoError(t, err)
assert.Empty(t, rt.RestrictedFiles())
})
If the runtime HAS restricted files, use assert.ElementsMatch instead.
e2e/tests/api/validation.ymlUpdate the "unknown runtime" test case's expected error message to include the new runtime in alphabetical order: