Skip to content

Commit ee0caea

Browse files
koki-developclaude
andcommitted
chore: add add-runtime skill for repeatable runtime addition workflow
Co-Authored-By: Claude <noreply@anthropic.com>
1 parent cbebf3e commit ee0caea

1 file changed

Lines changed: 373 additions & 0 deletions

File tree

Lines changed: 373 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,373 @@
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

Comments
 (0)