Skip to content

Commit d633908

Browse files
NathanFlurryclaude
andcommitted
feat: US-019 - Custom bindings core plumbing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c3401ee commit d633908

7 files changed

Lines changed: 137 additions & 1 deletion

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* Custom bindings: host-to-sandbox function bridge.
3+
*
4+
* Users register a BindingTree of host-side functions via the `bindings`
5+
* option. The tree is validated, flattened to __bind.* prefixed keys, and
6+
* merged into bridgeHandlers so sandbox code can call them through the bridge.
7+
*/
8+
9+
import type { BridgeHandler } from "./bridge-handlers.js";
10+
import { BRIDGE_GLOBAL_KEY_LIST } from "./bridge-contract.js";
11+
12+
/** A user-defined host-side function callable from the sandbox. */
13+
export type BindingFunction = (...args: unknown[]) => unknown | Promise<unknown>;
14+
15+
/** A nested tree of binding functions. Nesting depth limited to 4. */
16+
export interface BindingTree {
17+
[key: string]: BindingFunction | BindingTree;
18+
}
19+
20+
/** Prefix for flattened binding keys in the bridge handler map. */
21+
export const BINDING_PREFIX = "__bind.";
22+
23+
const MAX_DEPTH = 4;
24+
const MAX_LEAVES = 64;
25+
const JS_IDENTIFIER_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
26+
27+
// eslint-disable-next-line @typescript-eslint/no-empty-function
28+
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
29+
30+
export interface FlattenedBinding {
31+
key: string;
32+
handler: BridgeHandler;
33+
isAsync: boolean;
34+
}
35+
36+
/**
37+
* Validate and flatten a BindingTree into prefixed bridge handler entries.
38+
*
39+
* Throws on:
40+
* - Invalid JS identifiers as keys
41+
* - Nesting depth > 4
42+
* - More than 64 leaf functions
43+
* - Binding keys starting with `_` (reserved for internal bridge names)
44+
*/
45+
export function flattenBindingTree(tree: BindingTree): FlattenedBinding[] {
46+
const result: FlattenedBinding[] = [];
47+
const internalKeys = new Set<string>(BRIDGE_GLOBAL_KEY_LIST as readonly string[]);
48+
49+
function walk(node: BindingTree, path: string[], depth: number): void {
50+
if (depth > MAX_DEPTH) {
51+
throw new Error(
52+
`Binding tree exceeds maximum nesting depth of ${MAX_DEPTH} at path: ${path.join(".")}`,
53+
);
54+
}
55+
56+
for (const key of Object.keys(node)) {
57+
if (!JS_IDENTIFIER_RE.test(key)) {
58+
throw new Error(
59+
`Invalid binding key "${key}": must be a valid JavaScript identifier`,
60+
);
61+
}
62+
63+
// Reject keys starting with _ to avoid collision with internal bridge names
64+
if (key.startsWith("_")) {
65+
throw new Error(
66+
`Binding key "${key}" starts with "_" which is reserved for internal bridge names`,
67+
);
68+
}
69+
70+
const fullPath = [...path, key];
71+
const value = node[key];
72+
73+
if (typeof value === "function") {
74+
const flatKey = BINDING_PREFIX + fullPath.join(".");
75+
76+
// Double-check flattened key doesn't collide with known internals
77+
if (internalKeys.has(flatKey)) {
78+
throw new Error(
79+
`Binding "${fullPath.join(".")}" collides with internal bridge name "${flatKey}"`,
80+
);
81+
}
82+
83+
result.push({
84+
key: flatKey,
85+
handler: value as BridgeHandler,
86+
isAsync: value instanceof AsyncFunction,
87+
});
88+
89+
if (result.length > MAX_LEAVES) {
90+
throw new Error(
91+
`Binding tree exceeds maximum of ${MAX_LEAVES} leaf functions`,
92+
);
93+
}
94+
} else if (typeof value === "object" && value !== null) {
95+
walk(value as BindingTree, fullPath, depth + 1);
96+
} else {
97+
throw new Error(
98+
`Invalid binding value at "${fullPath.join(".")}": expected function or object, got ${typeof value}`,
99+
);
100+
}
101+
}
102+
}
103+
104+
walk(tree, [], 1);
105+
return result;
106+
}

packages/secure-exec-nodejs/src/execution-driver.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import type {
7373
ProcessConfig,
7474
} from "@secure-exec/core/internal/shared/api-types";
7575
import type { BudgetState } from "./isolate-bootstrap.js";
76+
import { type FlattenedBinding, flattenBindingTree } from "./bindings.js";
7677

7778
export { NodeExecutionDriverOptions };
7879

@@ -293,6 +294,7 @@ export class NodeExecutionDriver implements RuntimeDriver {
293294
private state: DriverState;
294295
private memoryLimit: number;
295296
private disposed: boolean = false;
297+
private flattenedBindings: FlattenedBinding[] | null = null;
296298

297299
constructor(options: NodeExecutionDriverOptions) {
298300
this.memoryLimit = options.memoryLimit ?? 128;
@@ -352,6 +354,11 @@ export class NodeExecutionDriver implements RuntimeDriver {
352354
activeHostTimers: new Set(),
353355
resolutionCache: createResolutionCache(),
354356
};
357+
358+
// Validate and flatten bindings once at construction time
359+
if (options.bindings) {
360+
this.flattenedBindings = flattenBindingTree(options.bindings);
361+
}
355362
}
356363

357364
get network(): Pick<NetworkAdapter, "fetch" | "dnsLookup" | "httpRequest"> {
@@ -527,6 +534,13 @@ export class NodeExecutionDriver implements RuntimeDriver {
527534
}),
528535
};
529536

537+
// Merge custom bindings into bridge handlers
538+
if (this.flattenedBindings) {
539+
for (const binding of this.flattenedBindings) {
540+
bridgeHandlers[binding.key] = binding.handler;
541+
}
542+
}
543+
530544
// Build process/os config for V8 execution
531545
const execProcessConfig = createProcessConfigForExecution(
532546
options.env || options.cwd

packages/secure-exec-nodejs/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ export {
3838
createProcessConfigForExecution,
3939
} from "./bridge-handlers.js";
4040

41+
// Custom bindings
42+
export type { BindingTree, BindingFunction } from "./bindings.js";
43+
export { BINDING_PREFIX, flattenBindingTree } from "./bindings.js";
44+
4145
// Kernel runtime driver (RuntimeDriver for kernel.mount())
4246
export { createNodeRuntime } from "./kernel-runtime.js";
4347
export type { NodeRuntimeOptions } from "./kernel-runtime.js";

packages/secure-exec-nodejs/src/isolate-bootstrap.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ import type {
1616
TimingMitigation,
1717
} from "@secure-exec/core/internal/shared/api-types";
1818
import type { ResolutionCache } from "./package-bundler.js";
19+
import type { BindingTree } from "./bindings.js";
1920

2021
export interface NodeExecutionDriverOptions extends RuntimeDriverOptions {
2122
createIsolate?(memoryLimit: number): unknown;
23+
bindings?: BindingTree;
2224
}
2325

2426
export interface BudgetState {

packages/secure-exec-nodejs/src/kernel-runtime.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type {
2121
} from '@secure-exec/core';
2222
import { NodeExecutionDriver } from './execution-driver.js';
2323
import { createNodeDriver } from './driver.js';
24+
import type { BindingTree } from './bindings.js';
2425
import {
2526
allowAllChildProcess,
2627
allowAllFs,
@@ -43,6 +44,11 @@ export interface NodeRuntimeOptions {
4344
* (fs/network/env deny-by-default). Use allowAll for full sandbox access.
4445
*/
4546
permissions?: Partial<Permissions>;
47+
/**
48+
* Host-side functions exposed to sandbox code via SecureExec.bindings.
49+
* Nested objects become dot-separated paths (max depth 4, max 64 leaves).
50+
*/
51+
bindings?: BindingTree;
4652
}
4753

4854
/**
@@ -318,11 +324,13 @@ class NodeRuntimeDriver implements RuntimeDriver {
318324
private _kernel: KernelInterface | null = null;
319325
private _memoryLimit: number;
320326
private _permissions: Partial<Permissions>;
327+
private _bindings?: BindingTree;
321328
private _activeDrivers = new Map<number, NodeExecutionDriver>();
322329

323330
constructor(options?: NodeRuntimeOptions) {
324331
this._memoryLimit = options?.memoryLimit ?? 128;
325332
this._permissions = options?.permissions ?? { ...allowAllChildProcess };
333+
this._bindings = options?.bindings;
326334
}
327335

328336
async init(kernel: KernelInterface): Promise<void> {
@@ -453,6 +461,7 @@ class NodeRuntimeDriver implements RuntimeDriver {
453461
system: systemDriver,
454462
runtime: systemDriver.runtime,
455463
memoryLimit: this._memoryLimit,
464+
bindings: this._bindings,
456465
});
457466
this._activeDrivers.set(ctx.pid, executionDriver);
458467

packages/secure-exec/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export type { Kernel, KernelInterface } from "@secure-exec/core";
4343

4444
// Re-export kernel Node runtime factory.
4545
export { createNodeRuntime } from "@secure-exec/nodejs";
46+
export type { BindingTree, BindingFunction } from "@secure-exec/nodejs";
4647

4748
export { createInMemoryFileSystem } from "./shared/in-memory-fs.js";
4849
export {

scripts/ralph/prd.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@
301301
"Typecheck passes"
302302
],
303303
"priority": 19,
304-
"passes": false,
304+
"passes": true,
305305
"notes": "Custom bindings Phase 1. ~40-50 LOC. No Rust changes needed — bridgeHandlers already accepts dynamic entries."
306306
},
307307
{

0 commit comments

Comments
 (0)