Script Runner
Script Runner
This page describes how mentu script run works internally — from CLI invocation through V8 sandbox execution to output.
Execution flow
mentu script run hello --var NAME=world --timeout 60
│
├─ 1. Swift CLI resolves script path
├─ 2. Spawns: node script-runner.js hello.ts --var NAME=world --workspace /cwd --timeout 60
│
└─ script-runner.js:
├─ 3. Reads script file from disk
├─ 4. Builds mentu SDK (shell-out wrappers for each namespace)
├─ 5. Loads .mcp.json, lazy-spawns MCP child servers
├─ 6. Creates V8 sandbox context with { mentu, servers, sleep, console }
├─ 7. Wraps code in async IIFE with 'use strict'
├─ 8. Executes via vm.Script with timeout
├─ 9. Prints console output to stdout
└─ 10. Prints return value as JSON to stdoutScript resolution
When you run mentu script run hello, the CLI resolves the script name to a file path. It searches two directories in order:
{workspace}/.mentu/scripts/hello.ts— workspace-scoped scripts~/.mentu/scripts/hello.ts— global user scripts
You can also pass a direct file path instead of a name:
mentu script run ./my-scripts/audit.tsThe --workspace flag overrides the workspace root (defaults to the current working directory).
MCP bootstrapping
The script runner reads MCP server configuration from the workspace:
- Check for
{workspace}/.mcp.json - If not found,
servers.*calls will fail with an empty catalog
Servers are lazy-spawned — the child process is not started until the script's first servers.<id>.call() invocation. This means scripts that don't use MCP servers pay no startup cost.
After execution completes (or fails), all child server processes are shut down automatically.
Sandbox isolation
Scripts run inside a V8 sandbox created with vm.createContext. The sandbox follows a deny-all default — nothing is available unless explicitly injected.
Allowed globals
Only safe, side-effect-free JavaScript built-ins are available:
JSON, Math, Date, Array, Map, Set, Promise, Object, String, Number, Boolean, RegExp, Error, TypeError, RangeError, SyntaxError, URIError, parseInt, parseFloat, isNaN, isFinite, encodeURIComponent, decodeURIComponent, encodeURI, decodeURI, undefined, NaN, Infinity, setTimeout, clearTimeout
Blocked globals
These are explicitly not injected into the sandbox:
require,module,exports— no module loadingprocess— no access to the host processBuffer— no binary data manipulationfs,path,net,http— no I/Ofetch— no network accesseval,Function(as constructor) — code generation disabled viacodeGeneration: { strings: false, wasm: false }, throwsEvalErrorSharedArrayBuffer,WebAssembly— deleted from context after creationglobal,globalThis— no reference to the host global scope__dirname,__filename— no host path information
Injected globals
| Global | Type | Description |
|---|---|---|
mentu |
MentuSDK |
Frozen SDK object with all namespaces |
servers |
Proxy |
Lazy MCP server proxy — servers.<id>.call(tool, args) |
sleep |
(ms: number) => Promise<void> |
Pause execution (capped at 30s per call) |
console |
object |
Captured console — log, warn, error, info, debug |
Prototype hardening
On first sandbox execution, shared prototypes are locked to prevent pollution:
Object.prototype— sealed (no new properties, existing stay writable)Array.prototype— sealedFunction.prototype— frozen (blocksconstructor.constructorescape)- Async function and generator prototypes — frozen
This is irreversible and permanent for the Node.js process lifetime. It runs once, before any sandbox code executes.
Context hardening
- The sandbox object uses
Object.create(null)— no__proto__traversal - Code is wrapped in
'use strict'; (async () => { ... })()— blocksarguments.callee.caller Error.prepareStackTraceis set toundefined— no stack trace information leakage
Timeout handling
Timeouts are enforced at two levels:
| Level | Default | Flag | Description |
|---|---|---|---|
| Script execution | 300s | --timeout <seconds> |
Total time for the entire script |
| SDK command | 30s | — | Each mentu.* CLI shell-out |
| Sequence run | 600s (10 min) | — | mentu.sequence.run() and mentu.temporal.fire() |
| Sleep call | 30s max | — | Each sleep() invocation is capped |
For the async portion of script execution, Promise.race is used against a timeout promise. For synchronous code, vm.Script timeout is enforced directly by V8.
Error handling
Script errors are sanitized before being reported to stderr:
- Host filesystem paths are not exposed
- Stack traces from the host process are not leaked
- Errors are wrapped as
Sandbox error: <message>
The script runner exits with code 1 on any error, code 0 on success.
Output format
Script output has two parts, printed to stdout in order:
- Console output — every
console.log(),console.warn(),console.error(),console.info(), andconsole.debug()call during execution, one line per call - Return value — if the script's final expression returns a value, it is printed as JSON (pretty-printed with 2-space indent). String values are printed as-is.
Hello, world! ← console.log
[warn] Low confidence signal detected ← console.warn
{ ← return value (JSON)
"signals": 42,
"healthy": true
}Limits
| Limit | Value | What happens |
|---|---|---|
| Code size | 50 KB | Script is rejected before execution |
| Output size | 10 MB | Console output is truncated with [output truncated] |
| Sleep per call | 30s | Values above 30,000ms are silently capped |
| Script timeout | 300s default | Configurable via --timeout |
| SDK call timeout | 30s | Each shell-out to mentu CLI |
| Sequence timeout | 10 min | sequence.run() and temporal.fire() |