Code mode: design spike and go / no-go¶
Experimental spike, not a shipped feature
This is the write-up of the spike for #325
(audit roadmap item 23). The prototype is compiled only with go build -tags codemode, and even
then it stays off until code_mode.enabled: true. The release binary does not contain it:
CGO_ENABLED=0 builds of this branch and of main are byte-for-byte the same size.
TL;DR¶
- What was built. One gateway tool,
execute_code {language: "js", code}. The model sends a short JavaScript program. The program calls upstream tools asawait tools.<server>.<tool>(args)and filters their results. Only the value it returns goes back into the context window. - Runtime. The program runs in goja, a pure-Go JavaScript
interpreter, inside a separate child process. That process is the same binary running a
hidden
codemode-workercommand. It has kernel resource limits and an empty environment, and its only channel back to the proxy is a pipe. - Security. Every tool call a program makes goes through the proxy's whole middleware
pipeline, exactly like an
invoke_toolcall. That covers per-tool policy (deny, confirm and unknown tools), tool pinning, redaction in both directions, the injection guard, the response governor and OTel. End-to-end tests with the real binary prove this. Code mode adds a way to call tools. It does not add a way around any check. - Measured (harness, see Measurements).
- On the task it is built for, 5 large listings reduced to a top 3, code mode cut the tokens from 289,982 to 230 (−99.9%) and returned the correct answer.
- It loses on small tasks: +100–200% tokens for a one-task session, because the tool's
definition costs 213 tokens in every
tools/list. Each call also has an 18 ms sandbox floor, against 0.7 ms for a plain call. - With the response governor on, the program silently returned a wrong answer. It computed its result from shortened listings.
- The binary grows by 6.1 MiB (+31%).
- Recommendation: no-go for v1.0. Keep the spike behind the build tag, or delete it. Revisit only if the conditions below are met. The token numbers are real, but they come from one task shape. We have not measured how reliably a model writes the code. The feature also opens a code-execution surface that is hard to keep safe on macOS and Windows, and the market already offers it.
Why code mode, and why it is optional¶
The "code mode" pattern is described in Anthropic's "code execution with MCP", Cloudflare MCP Portals' Code Mode and Docker MCP Gateway's experimental code-mode. It saves tokens on intermediate results, which neither tool search (#305) nor response truncation (#319) can touch. A task like "find the 3 open issues with the most comments across 5 repos" normally pulls five full listings into the context so the model can read them once. A program can read them outside the context and return three lines.
The audit (§5 item 23) rated it P3 for four reasons: the market already has it, it needs a real sandbox, it needs a runtime that grows the binary, and its results depend on how well the model writes code. This spike measures the first three. The fourth needs a real-model evaluation (see Evaluation plan).
Runtime options¶
| goja in the proxy process | goja in a child process (prototype) | wazero + QuickJS/Javy (WASM) | Container sandbox (#312, pkg/pool/sandbox.go) |
Client's own code-execution tool | |
|---|---|---|---|---|---|
| Binary size | +6.1 MiB, measured (goja, regexp2, sourcemap) | same +6.1 MiB, measured: the child is the same binary | about +4–6 MiB (wazero plus an embedded QuickJS .wasm), estimated, not built |
~0 in the binary, but needs docker or podman plus a JS image (≥ 50 MB) | 0 |
| Start-up per call | 0.05 ms, measured (goja.New() + a small program) |
18 ms p50, measured (fork/exec + Go runtime + goja) | ms per instance once compiled; compiling the QuickJS module is 100s of ms (cacheable), estimated | 300 ms–1 s+ (docker run), estimated |
none in LeanProxy |
| Stop an infinite loop | vm.Interrupt works in JS code but not inside native built-ins |
Interrupt, plus RLIMIT_CPU (SIGXCPU, then SIGKILL) and a wall-clock kill |
Context cancellation works everywhere, because all code is WASM | docker kill on a timeout |
client's problem |
| Memory limit | none: goja has no heap cap, and one "x".repeat(2**30) can OOM the proxy and every session with it |
heap watchdog plus RLIMIT_AS (Linux), and a crash only kills the sandbox |
exact: the WASM linear-memory max | cgroup --memory |
client's problem |
| Deterministic time-out | wall clock only | wall clock plus a CPU-time cap from the kernel | wall clock (no fuel metering in wazero) | wall clock | – |
| No filesystem or network | yes (goja exposes nothing) | yes, plus an empty env, RLIMIT_FSIZE 0 and RLIMIT_NOFILE 16 |
yes (no WASI imports given) | --network none, read-only mounts |
depends on the client |
| Tool calls through the pipeline | direct function call | JSON lines over the child's stdio | host functions | stdio or a socket into the container | the client calls the proxy's tools, so they already pass through it |
| Portability of the hard limits | – | Linux only (rlimits). macOS and Windows get soft limits only | all platforms | Linux/macOS with a container runtime | – |
| Maturity / risk | goja is mature and pure Go | same | QuickJS is C compiled to WASM, and WASM contains any bug in it | strong isolation, heavy dependency | out of our control |
Why a child process.
- Why not in-process goja. It is the cheapest option, but it fails the security bar. goja cannot
interrupt its native built-ins and has no memory cap, so one allocation in a program could kill
the proxy for every connected client. The memory tests reproduce this:
"x".repeat(2 ** 30)allocates 1 GiB in one native call. - Why not a container. The container sandbox from #312 gives strong isolation, but a 300 ms–1 s start per call and a docker or podman dependency are too much for a token-saving feature.
- Why not WASM. wazero + QuickJS is the best long-term sandbox: exact memory limits, and cancellation that works everywhere on every platform. It was not built for this spike. Its numbers above are estimates.
- What the child process gives. Most of the WASM safety on Linux, with the runtime the issue asked for. The kernel enforces the limits, and the worst a program can do is crash its own sandbox.
Delegating to the client's code-execution tool needs no runtime in LeanProxy at all, and every call already comes back through the proxy. It is the right answer for clients that have one, such as Claude Code and the Anthropic API code-execution tool. It does nothing for the open and local models LeanProxy targets, and it needs the client's sandbox to be able to reach the proxy.
API surface¶
- The tool.
execute_codeis listed next to the gateway tools, in every exposure mode, only while code mode is on. Its input schema is{code: string, language?: "js"}. The description (213 tokens) names the configured servers and the limits. - Calling tools.
tools.<server>.<tool>(args)returns aPromise. Usetools["my-server"].toolfor names with dashes. The program is the body of anasyncfunction, so it can useawait,Promise.allandreturn. - Stubs. They come from the server list only, through a dynamic object.
Object.keys(tools)lists the servers. Tools are never enumerated: which tools exist, and which ones the policy hides, is decided by the proxy on each call and never disclosed to the sandbox. A model discovers tools withsearch_toolsorlist_tools, then calls them from code under the same server and tool names. - What a call returns. The result's
structuredContentif it has one. Otherwise its text, parsed as JSON when it is a JSON object or array, or else the text as a string. A result withisError: true, a policy refusal, a pinning block or an upstream error rejects the promise with the message the model would have seen. The program cancatchit. - What the model gets back. The return value, as a string or as JSON.
console.logoutput is capped at 4 KiB and appended under[console]. A failure comes back as anisErrorresult naming its kind (error,timeout,cpu,memory,output,callsorcrash). - Not built. The issue asked for typed stubs generated from the tool schemas (TypeScript
declarations). Generating them for every tool would bring back the schema tax that the router
removes. The better follow-up is a one-line signature per hit in
search_toolsresults.
Security model¶
Code mode runs model-written code. Treat that code as untrusted: a prompt injection in any tool result can steer what the model writes next.
| Threat | Control in the prototype | Tested by |
|---|---|---|
| Ambient authority (fs, network, env, processes) | goja exposes only the ECMAScript built-ins plus tools and console: no require, modules, process, fetch, timers, WebAssembly or SharedArrayBuffer. The child runs with an empty environment (no upstream credentials reachable), in the temp directory, with RLIMIT_FSIZE 0, RLIMIT_NOFILE 16 and RLIMIT_CORE 0. |
TestRun_NoAmbientAuthority, e2e no require |
| Bypassing policy | Each call becomes a new tools/call invoke_tool request sent through Handler.HandleRequest, the whole chain from the outermost stage: exposure, telemetry, governor, pinning, policy, cache, redaction and the injection guard. The request carries a context mark: execute_code cannot run from code (no nested sandboxes), and a server that is not configured is refused before the pipeline. |
TestCodeMode_CallsGoThroughTheWholePipeline, e2e policy deny / confirm / unknown-tool, pinning block |
| Leaking secrets | The program only ever sees redacted results, because redaction runs on each nested response. Its arguments are redacted before they reach an upstream. Its final answer goes out through redaction and the injection guard a second time. | e2e: the program checks it never saw the raw key; the upstream reports the arguments arrived redacted; the injection warning appears on the nested and the final result |
| Infinite loop / CPU burn | Wall-clock Interrupt at timeout, and a kill at timeout + 1 s. RLIMIT_CPU: SIGXCPU interrupts the VM at cpu_time, and the kernel sends SIGKILL one second later, which also stops native code. |
TestRun_InfiniteLoopKilledByCPULimit, TestRun_WallClockTimeout, e2e |
| Memory exhaustion | A heap watchdog (runtime/metrics, every 5 ms) interrupts above max_memory_mb. RLIMIT_AS, set to current mappings + 2 × the limit + 256 MiB, makes a huge native allocation abort the sandbox process only. (RLIMIT_DATA was tried first and does not work for Go: the kernel checks it when the address space grows, and the Go heap grows by remapping space it reserved earlier.) |
TestRun_MemoryLimits (1 GiB string, growing heap, 2²⁸-element array), e2e |
| Huge output / flooding | Output cap (64 KiB by default), checked in the sandbox and again in the proxy, with a line cap on the pipe. Caps on console, the source size, the argument size, each tool result handed to the program, call count (32) and concurrency (4). |
TestRun_OutputLimit, TestRun_CallLimit, e2e |
| A crashed or hostile sandbox | Separate process; Pdeathsig: SIGKILL (it dies with the proxy); its own process group. The proxy parses only JSON lines from it, and any malformed line kills it. In-flight tool calls are cancelled when it ends. |
e2e: the proxy answers ping and runs the next program after every limit |
| Audit | One log line per call (server, tool, duration, outcome) and one per program (outcome, calls, duration, output size). Nested calls are OTel child spans of the execute_code span. |
e2e checks the log line |
Residual risks (why this is not a go).
- Client-side approval is collapsed. Clients such as Claude Code ask the user per MCP tool.
With
execute_code, one approval covers every call the program makes. LeanProxy's own policy still applies per call, including a per-callconfirmthrough elicitation. The tool is annotateddestructiveHint: trueso a careful client prompts for it. Still, the user sees less than before. - Exfiltration gets cheaper and less visible. A program can read from one tool and write to
another (the "lethal trifecta") without the intermediate data ever appearing in the transcript.
Redaction, the injection guard and
confirmrules on write or open-world tools are the mitigations, and they are the same ones as for plain calls. But nobody reads the intermediate data anymore, so nobody can spot a problem in it. - Hard limits are Linux-only. On macOS and Windows the prototype has only the watchdogs and the wall-clock kill. The watchdogs cannot stop a runaway native built-in, and a memory spike there can reach the machine's limit (though only the sandbox process dies).
- No syscall filter. The sandbox process is a full Go binary. goja being pure Go and memory-safe is the isolation boundary. A seccomp or Landlock profile, or WASM, would add a second one.
- Side effects of cancelled calls. A call already sent upstream when the program ends or is killed may still take effect.
Measurements¶
From make harness (tests/harness/codemode_test.go, linux/amd64). A proxy built with
-tags codemode runs over catalogmcp --large-results, where list_issues returns a
300-issue page. Tokens are those of the JSON-RPC request and response lines, including the code
the model writes. Latency is the median of 7 runs, proxy side only. No model is involved: the
programs are fixed. These numbers measure cost, not whether a model writes the program correctly.
| Task | Plain tokens | Code mode tokens | Change | Code mode + its definition (one-task session) | Plain latency seq / parallel | Code mode latency | Model turns (seq / parallel / code) | Code mode answer |
|---|---|---|---|---|---|---|---|---|
Top 3 issues by comments across 5 repos (5 × list_issues) |
289,982 | 230 | −99.9% | 443 · −99.8% | 70.2 / 35.8 ms | 108.4 ms | 6 / 2 / 2 | correct |
Same task, response governor on (max_tokens: 4000) |
18,977 | 206 | −98.9% | 419 · −97.8% | 73.5 / 31.8 ms | 62.7 ms | 6 / 2 / 2 | wrong |
One small call (create_issue) |
104 | 101 | −2.9% | 314 · +201.9% | 0.6 / 0.6 ms | 19.2 ms | 2 / 2 / 2 | ok |
| Two small calls, both results needed | 178 | 145 | −18.5% | 358 · +101.1% | 1.1 / 0.5 ms | 19.5 ms | 3 / 2 / 2 | ok |
| Fixed cost | Value |
|---|---|
execute_code definition in tools/list (every session, re-read every turn) |
213 tokens |
Sandbox floor, p50: execute_code("return 1") vs one small invoke_tool |
17.6 ms vs 0.7 ms |
Binary size, -s -w: default vs -tags codemode |
19.9 MiB vs 26.0 MiB (+6.1 MiB, +31%) |
Default binary, this branch vs main (CGO_ENABLED=0, as released) |
20,816,056 vs 20,816,056 bytes |
Reading the numbers honestly:
- Where code mode wins. Large intermediate results that the answer does not need. Proxy-side latency is slightly worse (108 ms vs 36 ms when the plain calls run in parallel): the program's calls queue behind a concurrency of 4, and the sandbox costs 18 ms. It saves model turns only against a client that makes one tool call per turn (6 → 2). A client that makes parallel calls needs 2 turns either way. In a real session the token saving compounds, because plain results stay in the context and are re-read on every later turn.
- Against the response governor. The governor, which already ships, cuts the same plain task by 93% (289,982 → 18,977). Code mode still takes it to 206. But the governor also governs the calls a program makes, as the security requirement demands, so the program worked on truncated, projected listings. It then returned a plausible top 3 with one entry missing its fields, and it raised no error. A model would pass that answer on. Using both features together needs the follow-up listed below, or the answer is silently wrong.
- Where code mode loses.
- Small or single calls. The program costs as much as the call, the definition adds 213 tokens to every session, and the sandbox adds 18 ms.
- Results the model needs verbatim (a file to edit, a diff to review). There is nothing to
filter, and the output cap (64 KiB) is below what the governor would page through
read_result. - Tasks where the next step depends on judging a result. The model has to see the intermediate data anyway.
- Failing programs. A buggy program costs a failed call plus a retry, which is not measured here.
Evaluation plan¶
- Harness (done). The scenario above runs in every
make harness. Its assertions:- the program's answer equals the one computed from the plain calls;
- the multi-call task saves at least 90% of the tokens;
- the default binary does not contain code mode.
- Real model (manual, not run in this spike). This environment has no model access. The plan:
- Tasks, 20 runs each.
- "Top 3 issues by comments across 5 repos".
- "Which of these 10 Jira tickets have no linked PR".
- "Sum the rows of 3 SQL queries".
- Two control tasks where code mode should not help: one small call, and "read this file and fix the bug".
- Arms. Router +
invoke_tool, router +execute_code, and passthrough + the client's native tool search. - Models. One frontier model and one local model through Ollama or llama.cpp. LeanProxy's audience includes local models, and small models write worse code.
- Metrics. Task success (checked answer), total billed tokens including retries (from the client's usage), model turns, and wall time.
- Go bar. Success rate within 5 points of the plain flow on the multi-call tasks, for both models.
- Tasks, 20 runs each.
Go / no-go¶
No-go for v1.0. The prototype stays behind -tags codemode, off by default, as a reference.
Deleting it is also fine.
The reasons, with the numbers above:
- The win is narrow. On the task it is built for, it saves −99.9% of tokens. But it costs +100% to +200% on small tasks once its definition is counted, and it is wrong next to the response governor. The governor already takes 93% off that same task, safely, without asking the model to write code.
- The security cost is real. A code-execution surface whose hard limits exist on Linux only. It also removes per-tool approval in the client, and it makes chained exfiltration invisible. The prototype contains all of this, but each point is a reason to go slowly.
- It depends on the model, and that is not measured. The audience that benefits most from LeanProxy (open and local models, clients without native tool search) is the one most likely to write broken programs.
- The market already has it. Anthropic, Cloudflare and Docker ship code mode, and for clients with a code-execution tool, delegating to it gives the same saving with no runtime in LeanProxy. The +6.1 MiB (+31%) binary would buy a me-too feature, not a differentiator.
Conditions to revisit (all of them):
- A real-model evaluation that meets the go bar above.
- Calls made from code are exempt from the governor's truncation, projection and dedup, but still redacted and injection-scanned. Their results never enter the context, so shortening them only corrupts answers.
- A second isolation boundary: WASM (wazero + QuickJS), or seccomp and Landlock on Linux, plus a macOS story.
- Per-call signatures in
search_toolsresults instead of stubs for every tool, and theexecute_codedefinition only in exposure modes where it pays off.
Try the prototype¶
code_mode:
enabled: true # off by default; ignored (with a warning) by a default build
timeout: 30s # wall clock per execute_code, tool calls included
cpu_time: 5s # sandbox CPU time (RLIMIT_CPU, Linux)
max_memory_mb: 256 # heap watchdog + RLIMIT_AS (Linux)
max_calls: 32 # tool calls per program
max_concurrent_calls: 4
max_output_bytes: 65536
Only server run (stdio and Streamable HTTP) has code mode. The deprecated serve front end does
not. See configuration.
Dependency. github.com/dop251/goja is pinned to v0.0.0-20260917113740-793a2a65c13b. goja has
no tagged releases, so the pseudo-version pins an exact commit, which go.sum hashes.
- Licenses. goja is MIT. Its dependencies are
dlclark/regexp2(MIT),go-sourcemap/sourcemap(BSD-2-Clause) andgoogle/pprof(Apache-2.0), all compatible with this MIT project. - Linking. Everything is pure Go, with no cgo. It is linked only with
-tags codemode. - Vulnerability checks. CI's security job now also runs
govulncheck -tags codemode ./..., because the default run never sees these packages.
Code.
pkg/codemode/: config, sandbox and worker.pkg/mcp/middleware_codemode.go: the pipeline stage.cmd/codemode_on.goandcmd/codemode_off.go.- Tests:
pkg/codemode/*_test.goandpkg/mcp/middleware_codemode_test.go(make test-codemode),tests/e2e/codemode_test.go(defaultgo test), andtests/harness/codemode_test.go.