Mentu

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 stdout

Script resolution

When you run mentu script run hello, the CLI resolves the script name to a file path. It searches two directories in order:

  1. {workspace}/.mentu/scripts/hello.ts — workspace-scoped scripts
  2. ~/.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.ts

The --workspace flag overrides the workspace root (defaults to the current working directory).

MCP bootstrapping

The script runner reads MCP server configuration from the workspace:

  1. Check for {workspace}/.mcp.json
  2. 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 loading
  • process — no access to the host process
  • Buffer — no binary data manipulation
  • fs, path, net, http — no I/O
  • fetch — no network access
  • eval, Function (as constructor) — code generation disabled via codeGeneration: { strings: false, wasm: false }, throws EvalError
  • SharedArrayBuffer, WebAssembly — deleted from context after creation
  • global, 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.prototypesealed (no new properties, existing stay writable)
  • Array.prototypesealed
  • Function.prototypefrozen (blocks constructor.constructor escape)
  • 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 () => { ... })() — blocks arguments.callee.caller
  • Error.prepareStackTrace is set to undefined — 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:

  1. Console output — every console.log(), console.warn(), console.error(), console.info(), and console.debug() call during execution, one line per call
  2. 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()
© 2026 Mentu.