|
| 1 | +--- |
| 2 | +name: add-runtime |
| 3 | +description: > |
| 4 | + Add a new programming language runtime to the sandbox. Use this skill when the user |
| 5 | + asks to add a new language, new runtime, or support for a new programming language |
| 6 | + (e.g., "Add Rust support", "add Python runtime", "support a new language"). |
| 7 | + Also trigger when the user mentions adding a runtime, language support, or interpreter/compiler |
| 8 | + to the sandbox execution engine. This skill covers the full end-to-end process: |
| 9 | + runtime implementation, Docker setup, resource limit design, testing, and documentation. |
| 10 | +--- |
| 11 | + |
| 12 | +# Add Runtime Skill |
| 13 | + |
| 14 | +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. |
| 15 | + |
| 16 | +Before starting, gather the following information from the user: |
| 17 | + |
| 18 | +1. **Language name and version** (e.g., "Rust 1.82.0") |
| 19 | +2. **Interpreted or compiled?** — Interpreted runtimes (like Ruby, Python) run source directly. Compiled runtimes (like Go) need a compilation step before execution. |
| 20 | +3. **mise package name** — Check with `mise ls-remote <tool>` or the [mise registry](https://mise.jdx.dev/registry.html) to confirm the tool name and available versions. |
| 21 | + |
| 22 | +--- |
| 23 | + |
| 24 | +## Step 1: Determine Resource Limits |
| 25 | + |
| 26 | +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. |
| 27 | + |
| 28 | +### Existing Limits Reference |
| 29 | + |
| 30 | +| Limit | Node.js | Ruby | Python | Go (run) | Go (compile) | Bash | |
| 31 | +|-------|---------|------|--------|----------|--------------|------| |
| 32 | +| AS (MiB) | 4096 | 1024 | 1024 | 1024 | 4096 | 512 | |
| 33 | +| Fsize (MiB) | 64 | 64 | 64 | 64 | 64 | 64 | |
| 34 | +| Nofile | 64 | 64 | 64 | 64 | 256 | 64 | |
| 35 | +| Nproc | soft | soft | soft | soft | soft | soft | |
| 36 | +| PidsMax | 64 | 32 | 32 | 64 | 128 | 32 | |
| 37 | +| MemMax (bytes) | 268435456 | 268435456 | 268435456 | 268435456 | 268435456 | 268435456 | |
| 38 | +| MemSwapMax | 0 | 0 | 0 | 0 | 0 | 0 | |
| 39 | +| CpuMsPerSec | 900 | 900 | 900 | 900 | 900 | 900 | |
| 40 | + |
| 41 | +### Decision Framework |
| 42 | + |
| 43 | +These values are consistent across all runtimes and should be kept as-is unless there is a strong, documented reason to deviate: |
| 44 | + |
| 45 | +- **Fsize**: 64 MiB (sufficient for typical output files) |
| 46 | +- **Nproc**: "soft" (inherits system soft limit; per-sandbox limiting uses cgroup_pids_max) |
| 47 | +- **MemMax**: 268435456 (256 MiB physical memory; prevents host OOM) |
| 48 | +- **MemSwapMax**: 0 (swap disabled for strict memory enforcement) |
| 49 | +- **CpuMsPerSec**: 900 (90% of one core) |
| 50 | + |
| 51 | +These values require runtime-specific analysis: |
| 52 | + |
| 53 | +#### AS (Virtual Address Space, in MiB) |
| 54 | + |
| 55 | +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. |
| 56 | + |
| 57 | +| Category | Value | When to use | |
| 58 | +|----------|-------|-------------| |
| 59 | +| 4096 | High VAS | Runtimes with JIT/mmap-heavy memory management (V8/Node.js, JVM, .NET CLR). Also needed for compiler toolchains (Go compiler + linker). | |
| 60 | +| 1024 | Standard | Traditional interpreters (CPython, CRuby, Perl) and compiled binaries. | |
| 61 | +| 512 | Minimal | Lightweight runtimes (Bash, shell utilities). Bash needs ~2.8× output size for command substitution. | |
| 62 | + |
| 63 | +**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. |
| 64 | + |
| 65 | +#### Nofile (Open File Descriptors) |
| 66 | + |
| 67 | +| Category | Value | When to use | |
| 68 | +|----------|-------|-------------| |
| 69 | +| 64 | Standard | Most runtimes. Covers stdin/stdout/stderr (3) + nsjail internal fds (~5) + runtime engine fds. | |
| 70 | +| 256 | High | Compilation steps that open many source/object files concurrently (e.g., `go build`). | |
| 71 | + |
| 72 | +#### PidsMax (Per-cgroup Process + Thread Limit) |
| 73 | + |
| 74 | +| Category | Value | When to use | |
| 75 | +|----------|-------|-------------| |
| 76 | +| 32 | Low concurrency | Single-threaded interpreters (Ruby, Python, Bash). Limits fork bombs. | |
| 77 | +| 64 | Moderate concurrency | Runtimes with built-in concurrency (Node.js worker_threads, Go goroutines). | |
| 78 | +| 128 | High concurrency | Compilation steps with heavy parallelism (Go compiler). | |
| 79 | + |
| 80 | +**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. |
| 81 | + |
| 82 | +### For Compiled Runtimes |
| 83 | + |
| 84 | +Compiled runtimes need TWO sets of limits: one for compilation (CompileLimits) and one for execution (Limits). Compilation typically needs: |
| 85 | +- Higher AS (compiler toolchains are memory-hungry) |
| 86 | +- Higher Nofile (many concurrent source file reads) |
| 87 | +- Higher PidsMax (compiler parallelism) |
| 88 | + |
| 89 | +--- |
| 90 | + |
| 91 | +## Step 2: Verify mise Installation |
| 92 | + |
| 93 | +Before writing code, verify that the runtime installs correctly via mise on the target platform (Debian bookworm / glibc). |
| 94 | + |
| 95 | +```bash |
| 96 | +# Check available versions |
| 97 | +mise ls-remote <tool> | tail -20 |
| 98 | + |
| 99 | +# Check if special settings are needed (like ruby.compile=false) |
| 100 | +# Search mise docs for the tool |
| 101 | +``` |
| 102 | + |
| 103 | +Key considerations: |
| 104 | +- The Dockerfile uses a **glibc-linked mise binary** (not musl). This is because mise's libc detection affects which precompiled binaries it downloads. A musl-linked mise would download musl Python/Ruby/etc., which won't run on Debian (glibc). |
| 105 | +- Some runtimes need special mise settings (e.g., `ruby.compile=false` to use prebuilt binaries instead of compiling from source). |
| 106 | +- Check if the runtime binary path follows the standard pattern: `/mise/installs/<tool>/<version>/bin/<executable>`. |
| 107 | + |
| 108 | +--- |
| 109 | + |
| 110 | +## Step 3: Implementation Checklist |
| 111 | + |
| 112 | +The following files need changes. Items marked with ★ apply only to compiled runtimes. |
| 113 | + |
| 114 | +### 3.1 `internal/sandbox/runtime.go` |
| 115 | + |
| 116 | +#### 3.1a Add Runtime Constant |
| 117 | + |
| 118 | +Add the constant to the `const` block. Insert before `RuntimeBash` (Bash is always last by convention): |
| 119 | + |
| 120 | +```go |
| 121 | +const ( |
| 122 | + RuntimeNode RuntimeName = "node" |
| 123 | + RuntimeRuby RuntimeName = "ruby" |
| 124 | + RuntimeGo RuntimeName = "go" |
| 125 | + RuntimePython RuntimeName = "python" |
| 126 | + // ← Insert new runtime here (before RuntimeBash) |
| 127 | + RuntimeBash RuntimeName = "bash" |
| 128 | +) |
| 129 | +``` |
| 130 | + |
| 131 | +#### 3.1b Register in Runtimes Map |
| 132 | + |
| 133 | +Add the entry to the `runtimes` map variable, matching the constant order: |
| 134 | + |
| 135 | +```go |
| 136 | +var runtimes = map[RuntimeName]Runtime{ |
| 137 | + RuntimeNode: nodeRuntime{}, |
| 138 | + RuntimeRuby: rubyRuntime{}, |
| 139 | + RuntimeGo: goRuntime{}, |
| 140 | + RuntimePython: pythonRuntime{}, |
| 141 | + // ← Insert new runtime here (before RuntimeBash) |
| 142 | + RuntimeBash: bashRuntime{}, |
| 143 | +} |
| 144 | +``` |
| 145 | + |
| 146 | +#### 3.1c Implement Runtime Struct |
| 147 | + |
| 148 | +Insert the implementation between the preceding runtime's section and the next one. Follow the `// --- Name ---` section header convention. |
| 149 | + |
| 150 | +**Interpreted runtime template** (use Ruby/Python as reference): |
| 151 | + |
| 152 | +```go |
| 153 | +// --- LanguageName --- |
| 154 | + |
| 155 | +type langRuntime struct{} |
| 156 | + |
| 157 | +func (langRuntime) Name() RuntimeName { return RuntimeLang } |
| 158 | + |
| 159 | +func (langRuntime) Command(entryFile string) []string { |
| 160 | + return []string{"/mise/installs/<tool>/<version>/bin/<executable>", entryFile} |
| 161 | +} |
| 162 | + |
| 163 | +func (langRuntime) BindMounts() []BindMount { |
| 164 | + return []BindMount{{Src: "/mise/installs/<tool>/<version>", Dst: "/mise/installs/<tool>/<version>"}} |
| 165 | +} |
| 166 | + |
| 167 | +func (langRuntime) Env() []string { |
| 168 | + return []string{"PATH=/mise/installs/<tool>/<version>/bin:/usr/bin:/bin"} |
| 169 | +} |
| 170 | + |
| 171 | +// Limits returns resource limits for <Language> execution. |
| 172 | +// Rlimits: |
| 173 | +// - AS <value> MiB: <rationale>. |
| 174 | +// - Fsize 64 MiB: sufficient for typical output files. |
| 175 | +// - Nofile <value>: <rationale>. |
| 176 | +// - Nproc soft: inherits the system soft limit; per-sandbox process limiting is handled by cgroup_pids_max. |
| 177 | +// |
| 178 | +// Cgroups: |
| 179 | +// - PidsMax <value>: per-cgroup task limit (processes + threads); limits fork bombs and runaway thread creation. |
| 180 | +// - MemMax 268435456 (256 MiB): physical memory limit; prevents sandbox OOM from affecting the host. |
| 181 | +// - MemSwapMax 0: swap disabled to enforce strict memory limits. |
| 182 | +// - CpuMsPerSec 900: throttle CPU to 900 ms per second (90% of one core). |
| 183 | +func (langRuntime) Limits() Limits { |
| 184 | + return Limits{ |
| 185 | + Rlimits: Rlimits{ |
| 186 | + AS: "<value>", |
| 187 | + Fsize: "64", |
| 188 | + Nofile: "<value>", |
| 189 | + Nproc: "soft", |
| 190 | + }, |
| 191 | + Cgroups: Cgroups{ |
| 192 | + PidsMax: "<value>", |
| 193 | + MemMax: "268435456", |
| 194 | + MemSwapMax: "0", |
| 195 | + CpuMsPerSec: "900", |
| 196 | + }, |
| 197 | + } |
| 198 | +} |
| 199 | + |
| 200 | +func (langRuntime) RestrictedFiles() []string { return nil } |
| 201 | +``` |
| 202 | + |
| 203 | +**★ 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. |
| 204 | + |
| 205 | +#### 3.1d Default Files (if needed) |
| 206 | + |
| 207 | +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. |
| 208 | + |
| 209 | +#### 3.1e Restricted Files (if needed) |
| 210 | + |
| 211 | +If certain filenames must be rejected (like Go's `go.mod`, `go.sum`, `main`), return them from `RestrictedFiles()`. Most interpreted runtimes return `nil`. |
| 212 | + |
| 213 | +### 3.2 `Dockerfile` |
| 214 | + |
| 215 | +Add the runtime installation in the `base` stage, after the existing runtime installations: |
| 216 | + |
| 217 | +```dockerfile |
| 218 | +# <Tool> |
| 219 | +ENV PATH="/mise/installs/<tool>/<version>/bin:$PATH" |
| 220 | +RUN mise use -g <tool>@<version> |
| 221 | +``` |
| 222 | + |
| 223 | +If the runtime needs special mise settings (like Ruby's `ruby.compile=false`), add them in the same `RUN` command. |
| 224 | + |
| 225 | +**★ 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`). |
| 226 | + |
| 227 | +### 3.3 `internal/sandbox/sandbox_test.go` |
| 228 | + |
| 229 | +Add 4 test entries: |
| 230 | + |
| 231 | +#### 3.3a `Test_LookupRuntime` — add valid runtime entry: |
| 232 | +```go |
| 233 | +{name: "<lang> is valid", runtime: Runtime<Lang>, wantErr: false}, |
| 234 | +``` |
| 235 | + |
| 236 | +#### 3.3b `Test<Lang>Runtime_Limits` — add new test function: |
| 237 | +```go |
| 238 | +func Test<Lang>Runtime_Limits(t *testing.T) { |
| 239 | + t.Parallel() |
| 240 | + rt := <lang>Runtime{} |
| 241 | + got := rt.Limits() |
| 242 | + assert.Equal(t, "<AS>", got.Rlimits.AS) |
| 243 | + assert.Equal(t, "64", got.Rlimits.Fsize) |
| 244 | + assert.Equal(t, "<Nofile>", got.Rlimits.Nofile) |
| 245 | + assert.Equal(t, "soft", got.Rlimits.Nproc) |
| 246 | + assert.Equal(t, "<PidsMax>", got.Cgroups.PidsMax) |
| 247 | + assert.Equal(t, "268435456", got.Cgroups.MemMax) |
| 248 | + assert.Equal(t, "0", got.Cgroups.MemSwapMax) |
| 249 | + assert.Equal(t, "900", got.Cgroups.CpuMsPerSec) |
| 250 | +} |
| 251 | +``` |
| 252 | + |
| 253 | +**★ Compiled runtime**: also test `CompileLimits()` in the same function (see `TestGoRuntime_Limits`). |
| 254 | + |
| 255 | +#### 3.3c `Test_readDefaultFiles` — add sub-test: |
| 256 | +```go |
| 257 | +t.Run("<lang> has no defaults", func(t *testing.T) { |
| 258 | + t.Parallel() |
| 259 | + files, err := readDefaultFiles(Runtime<Lang>) |
| 260 | + assert.NoError(t, err) |
| 261 | + assert.Empty(t, files) |
| 262 | +}) |
| 263 | +``` |
| 264 | + |
| 265 | +If the runtime HAS default files, assert their names and content instead. |
| 266 | + |
| 267 | +#### 3.3d `TestRuntime_RestrictedFiles` — add sub-test: |
| 268 | +```go |
| 269 | +t.Run("<lang> has no restricted files", func(t *testing.T) { |
| 270 | + t.Parallel() |
| 271 | + rt, err := LookupRuntime(Runtime<Lang>) |
| 272 | + require.NoError(t, err) |
| 273 | + assert.Empty(t, rt.RestrictedFiles()) |
| 274 | +}) |
| 275 | +``` |
| 276 | + |
| 277 | +If the runtime HAS restricted files, use `assert.ElementsMatch` instead. |
| 278 | + |
| 279 | +### 3.4 `e2e/tests/api/validation.yml` |
| 280 | + |
| 281 | +Update the "unknown runtime" test case's expected error message to include the new runtime in alphabetical order: |
| 282 | + |
| 283 | +```yaml |
| 284 | +message: 'must be one of "bash", "go", "<new>", "node", "python", "ruby"' |
| 285 | +``` |
| 286 | +
|
| 287 | +Also verify the test case's `runtime` field is set to a truly unknown runtime (not one that was just added). Currently uses `"java"`. |
| 288 | + |
| 289 | +### 3.5 `e2e/tests/runtime/<lang>.yml` |
| 290 | + |
| 291 | +Create a new E2E test file. Include at minimum these test categories: |
| 292 | + |
| 293 | +| Category | Purpose | Example | |
| 294 | +|----------|---------|---------| |
| 295 | +| hello world | Basic execution | `print("Hello, World!")` | |
| 296 | +| stderr output | Stderr works | Write to stderr | |
| 297 | +| stdout and stderr | Interleaved output | Both streams, verify `output` field | |
| 298 | +| non-zero exit code | Exit code propagation | `sys.exit(1)` equivalent | |
| 299 | +| stderr with non-zero exit | Error + exit code | Combined | |
| 300 | +| multiple files | Multi-file support | Import from second file | |
| 301 | +| syntax error | Parse failure | Broken syntax → stderr with error, exit 1 | |
| 302 | +| unhandled exception | Runtime error | Uncaught exception → stderr with error, exit 1 | |
| 303 | +| standard library usage | stdlib available | JSON, regex, math, etc. | |
| 304 | +| language features | Core features | Classes, closures, data structures, etc. | |
| 305 | + |
| 306 | +Use regex matching (`/pattern/`) for stderr/output when the exact output is non-deterministic (e.g., stack traces). |
| 307 | + |
| 308 | +**★ Compiled runtime**: test cases should also verify the `compile` field in the response (stdout, stderr, exit_code, status for the compile step). Test compilation errors as well. |
| 309 | + |
| 310 | +### 3.6 Documentation Updates |
| 311 | + |
| 312 | +#### `CLAUDE.md` — 3 locations: |
| 313 | +1. **Line 7** (Project Overview): Add language name to the supported runtimes list |
| 314 | +2. **Line 90** (API docs): Add `"<lang>"` to the runtime enum list |
| 315 | +3. **Line 102** (compile field docs): If interpreted, add to "non-compiled runtimes" list |
| 316 | + |
| 317 | +#### `README.md` — 3 locations: |
| 318 | +1. **Supported Runtimes table** (~line 35-41): Add row `| <Language> | \`<lang>\` |` |
| 319 | +2. **API `runtime` parameter** (~line 118): Add `"<lang>"` to the enum list |
| 320 | +3. **`compile` field description** (~line 137): If interpreted, add to interpreted runtimes list |
| 321 | + |
| 322 | +### 3.7 `.claude/skills/add-runtime/SKILL.md` |
| 323 | + |
| 324 | +Update the "Existing Limits Reference" table in Step 1 of this skill to include the new runtime's limits. This keeps the table accurate for the next runtime addition. |
| 325 | + |
| 326 | +--- |
| 327 | + |
| 328 | +## Step 4: Verification |
| 329 | + |
| 330 | +Run these checks in order. Fix any failures before proceeding to the next step. |
| 331 | + |
| 332 | +### 4.1 Unit Tests |
| 333 | +```bash |
| 334 | +go test ./... |
| 335 | +``` |
| 336 | + |
| 337 | +### 4.2 Lint |
| 338 | +```bash |
| 339 | +golangci-lint run |
| 340 | +``` |
| 341 | + |
| 342 | +### 4.3 Docker Build + E2E Tests |
| 343 | +```bash |
| 344 | +docker compose down && docker compose up --build -d |
| 345 | +go test -tags e2e ./e2e/... |
| 346 | +``` |
| 347 | + |
| 348 | +If the Docker build fails: |
| 349 | +- **mise install failure**: Check if special settings are needed (e.g., `ruby.compile=false`). Check if the mise binary's libc matches the base image (must be glibc for Debian). |
| 350 | +- **Binary not found**: Verify the install path with `mise where <tool>@<version>` inside a running container. |
| 351 | + |
| 352 | +--- |
| 353 | + |
| 354 | +## Quick Reference: Files to Modify |
| 355 | + |
| 356 | +| # | File | Operation | Required for | |
| 357 | +|---|------|-----------|-------------| |
| 358 | +| 1 | `internal/sandbox/runtime.go` | Edit (constant, map, struct) | All | |
| 359 | +| 2 | `internal/sandbox/defaults/<lang>/` | Create (if needed) | Compiled | |
| 360 | +| 3 | `Dockerfile` | Edit (mise install) | All (except Bash) | |
| 361 | +| 4 | `internal/sandbox/sandbox_test.go` | Edit (4 test additions) | All | |
| 362 | +| 5 | `e2e/tests/runtime/<lang>.yml` | Create | All | |
| 363 | +| 6 | `e2e/tests/api/validation.yml` | Edit (error message) | All | |
| 364 | +| 7 | `CLAUDE.md` | Edit (3 locations) | All | |
| 365 | +| 8 | `README.md` | Edit (3 locations) | All | |
| 366 | +| 9 | `.claude/skills/add-runtime/SKILL.md` | Edit (limits table in Step 1) | All | |
| 367 | + |
| 368 | +Files that do NOT need changes (they resolve runtimes dynamically): |
| 369 | +- `internal/handler/handler.go` — uses `LookupRuntime()` |
| 370 | +- `internal/sandbox/sandbox.go` — uses `CompiledRuntime` type assertion |
| 371 | +- `internal/sandbox/execution.go` — generic execution engine |
| 372 | +- `internal/sandbox/configs/nsjail.cfg` — static config, per-invocation overrides via CLI flags |
| 373 | +- `internal/sandbox/configs/seccomp.kafel` — syscall policy, runtime-agnostic |
0 commit comments