research phase 2.3 sandbox - sinrimin/harness-nexus GitHub Wiki
Status: research complete. Captures the codebase integration points, the sandbox technology comparison, and the recommended approach for implementing admin-authored callable-function scripts that wrap vendor APIs as MCP tools. The implementation design doc + PRD follow once the decisions below are confirmed.
Admins author small JavaScript functions that wrap cloud-vendor APIs (e.g. a document-translation API, an OCR service, an image generator) and expose them as MCP tools callable through a profile. The function bodies run server-side in a sandbox — they must not be able to read the filesystem, execute arbitrary subprocesses, or make unrestricted network calls.
| Layer | Current state | How a callable-function plugs in |
|---|---|---|
McpRegistry (server/src/mcp/registry.ts) |
Structurally coupled to upstream Client connections + transports. A callable-function has no upstream connection — it cannot live in this pool.
|
Introduce a sibling ToolSource; the registry stays focused on upstreams. |
Proxy buildSession (server/src/mcp/proxy.ts) |
One loop calls registerTool(name, meta, handler) per aggregated tool. Tool-source agnostic — the easiest seam.
|
A callable-function is just another registerTool entry on the same per-session SdkMcpServer. |
profileEntriesFor() |
Returns only { serverIds }; skips non-mcp entries. |
Extend the return shape to carry callable-function ids; widen the profile REST entry schema. |
Resource domain (core/src/domain/resource.ts) |
ResourceKind includes 'command' but it is unused; the whole Resource aggregate is a no-op stub. |
Reuse is possible but means building the deferred Resource module; a dedicated entity is lower-risk (see below). |
config.ts |
No sandbox/timeout/allowlist fields. | Add sandboxTimeoutMs, callableFunctionAllowedHosts, etc. |
| deps | No sandboxing library present. | New dependency (see comparison). |
ToolSource (interface)
├── listTools(filter?) → AggregatedTool[] // reuse the existing shape
└── callTool(name, args) → CallToolResult
McpRegistry implements ToolSource // upstream connection pool (existing)
CallableRegistry implements ToolSource // sandboxed scripts (new)
The proxy merges tools from both sources and routes callTool by namespace
prefix: <server>__<tool> → McpRegistry, fn__<name> → CallableRegistry. This
keeps the connection-pool semantics of McpRegistry untouched.
| Option | Isolation | Performance | Dependency cost | Verdict |
|---|---|---|---|---|
node:vm |
❌ Not a sandbox — Node docs say so explicitly | high | none | Unusable (escapable) |
vm2 |
Deprecated, multiple known escapes | high | none | Unusable (unmaintained) |
worker_threads |
Weak — same process; fatal errors can crash the host; no real permission boundary | high | none (built-in) | Not for untrusted code |
isolated-vm |
Strong — V8 native Isolate, separate heap, memory/CPU limits, crashes don't hit the host | high (V8 JIT) | native build (prebuilt binaries available) | Recommended |
QuickJS-WASM (quickjs-emscripten) |
Very strong — separate engine inside a WASM sandbox | slow (interpreted) | pure WASM, no native build | Backup when no native deps allowed |
Node Permission Model (--permission) |
Process-level fs/network restrictions | native | none | Defense-in-depth supplement, not a standalone sandbox |
Recommendation: isolated-vm. It keeps V8 performance (important for
frequently-called MCP tools), provides real Isolate-level isolation with
memory/CPU caps, and fatal errors in untrusted code do not crash the host
process. The native build step is a CI concern but prebuilt binaries cover the
common platforms.
Backup: quickjs-emscripten if native compilation is a hard blocker. The
security boundary is arguably stronger (separate JS engine + WASM) but at a
significant performance cost.
isolated-vm Isolates have no Node APIs and no fetch by default. To let a
script call a vendor API, the host injects a controlled ctx.fetch callback:
- The script calls
ctx.fetch(url, opts). - The host intercepts and checks the URL host against the function's
allowedHostswhitelist. - If allowed, the host performs the real request and returns the result to the script.
- The script never holds the real
fetch— only the proxied, allowlisted one.
Credential injection rides the same channel: a credential bound to the callable
function is decrypted host-side and added to the outgoing Authorization header
by the proxy fetch, so the plaintext secret never enters the sandbox.
Recommendation: a dedicated CallableFunction entity (not reuse of
Resource).
- The
Resourcemodule has been deferred for two phases; building it now to host callable-functions is scope creep. -
Resourcelacks execution-semantics fields (inputSchema,timeoutMs,allowedHosts, runtime status) — forcing them in dirties a generic model. - A dedicated entity carries exactly what 2.3 needs and stays decoupled from the
Resource roadmap.
ResourceKind = 'command'is left as a forward-compat slot.
// Proposed shape (packages/core/src/domain/callable-function.ts)
export interface CallableFunction {
id: string;
name: string;
description?: string;
/** JS source — the body of an async (args, ctx) => { ... } function. */
source: string;
/** JSON Schema surfaced as the MCP tool's inputSchema. */
inputSchema?: Record<string, unknown>;
/** Hosts the function may call via ctx.fetch; empty = no network. */
allowedHosts: string[];
/** Per-call timeout (ms); defaults to config.sandboxTimeoutMs. */
timeoutMs?: number;
/** Admin-authored; scope global/personal for profile referencing. */
scope: 'global' | 'personal';
ownerId: string | null;
status: 'active' | 'disabled';
createdAt: string;
updatedAt: string;
}-
2.3.1 core: CallableFunction entity + admin-only CRUD +
isolated-vmexecutor +ctx.fetchwhitelist proxy +ToolSourceintegration into the proxy + profile entry support + smoke tests. No UI (verify via API). - 2.3.2 UI: management page (script editor, inputSchema editor, allowedHosts config, test-run button) + profile entry picker for callable-functions.
-
Credential decryption boundary: the encryption key must never enter the
sandbox; decryption stays host-side in the
ctx.fetchproxy. -
Input validation: validate
argsagainstinputSchemahost-side (zod) before entering the sandbox — never trust script-internal validation. -
Audit logging: admin scripts can make sensitive calls; log each
callTool(function, caller, duration, outcome).
- laverdet/isolated-vm (GitHub)
- Simon Willison: JavaScript Sandboxing Research
- LogRocket: Building a code evaluator with isolated-vm
- Temporal: Intro to isolated-vm in TypeScript
- Inngest: Node.js worker threads are problematic
- node:vm Is Not a Sandbox
- Semgrep: vm2 sandbox escape
- QuickJS in a WASM sandbox (HN)
- TanStack: Code Mode Isolate Drivers comparison
- Node.js Permissions docs