Skip to content

Commit 3d91055

Browse files
NathanFlurryclaude
andcommitted
docs: add custom bindings spec (SecureExec.bindings)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 99e3ae4 commit 3d91055

2 files changed

Lines changed: 220 additions & 0 deletions

File tree

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
# Custom Bindings
2+
3+
## Summary
4+
5+
Allow users to expose host-side functions to sandbox code via `SecureExec.bindings`. The host registers a nested object tree of functions; the sandbox receives them as a frozen namespace on the `SecureExec` global.
6+
7+
## Motivation
8+
9+
Users need to give sandbox code access to host capabilities beyond the built-in fs/network/process bridge — databases, caches, queues, AI models, custom APIs. Today there's no supported way to do this. Custom bindings close that gap with minimal surface area.
10+
11+
## Design principles
12+
13+
- The user owns the wrapper. We provide transport, not abstractions.
14+
- Same serialization as all bridge calls (V8 structured clone). No special cases.
15+
- No Rust changes. Bindings are additional entries in `bridgeHandlers`.
16+
- Frozen and namespaced. User code cannot mutate or shadow bindings.
17+
18+
## Host API
19+
20+
```ts
21+
const runtime = new NodeRuntime({
22+
systemDriver: createNodeDriver(),
23+
runtimeDriverFactory: createNodeRuntimeDriverFactory(),
24+
bindings: {
25+
db: {
26+
query: async (sql: string, params: unknown[]) => db.query(sql, params),
27+
insert: async (sql: string, values: unknown[]) => db.insert(sql, values),
28+
},
29+
cache: {
30+
get: async (key: string) => redis.get(key),
31+
set: async (key: string, val: unknown) => redis.set(key, val),
32+
},
33+
greet: (name: string) => `Hello, ${name}!`,
34+
},
35+
});
36+
```
37+
38+
### Type
39+
40+
```ts
41+
type BindingFunction = (...args: unknown[]) => unknown | Promise<unknown>;
42+
43+
interface BindingTree {
44+
[key: string]: BindingFunction | BindingTree;
45+
}
46+
47+
interface NodeRuntimeOptions {
48+
// ... existing fields ...
49+
bindings?: BindingTree;
50+
}
51+
```
52+
53+
### Constraints
54+
55+
- Binding keys must be valid JS identifiers (`/^[a-zA-Z_$][a-zA-Z0-9_$]*$/`).
56+
- Max nesting depth: 4 levels.
57+
- Max leaf functions: 64 per runtime.
58+
- Bindings are set at construction and immutable for the runtime's lifetime.
59+
- Collisions with internal bridge names (anything starting with `_`) are rejected at registration time.
60+
61+
## Sandbox API
62+
63+
```js
64+
// Flat calls
65+
const rows = await SecureExec.bindings.db.query("SELECT * FROM users", []);
66+
await SecureExec.bindings.cache.set("key", "value");
67+
68+
// Sync bindings work too
69+
const msg = SecureExec.bindings.greet("world");
70+
71+
// Destructure for convenience
72+
const { db, cache } = SecureExec.bindings;
73+
const rows = await db.query("SELECT * FROM users", []);
74+
```
75+
76+
### The `SecureExec` global
77+
78+
`globalThis.SecureExec` is a frozen object owned by the runtime. It follows the Deno/Bun convention of a PascalCase product-named global.
79+
80+
```js
81+
SecureExec.bindings // user-provided bindings (this spec)
82+
SecureExec.version // package version (future)
83+
SecureExec.runtime // runtime metadata (future)
84+
```
85+
86+
- `SecureExec` is non-writable, non-configurable on `globalThis`.
87+
- `SecureExec.bindings` is a recursively frozen object tree.
88+
- Each leaf is a callable function that routes through the bridge.
89+
- If no bindings are registered, `SecureExec.bindings` is an empty frozen object.
90+
- `SecureExec` is always present, even with no bindings (stable API surface).
91+
92+
## Internal mechanics
93+
94+
### Flattening
95+
96+
At registration time, the nested `BindingTree` is walked and flattened into dot-separated keys:
97+
98+
```
99+
{ db: { query: fn, insert: fn }, cache: { get: fn } }
100+
-> Map {
101+
"__bind.db.query" => fn,
102+
"__bind.db.insert" => fn,
103+
"__bind.cache.get" => fn,
104+
}
105+
```
106+
107+
The `__bind.` prefix separates user bindings from internal bridge names. These flattened entries are merged into `bridgeHandlers` before passing to `V8Session.execute()`.
108+
109+
### Rust side
110+
111+
No changes. The Rust side registers whatever function names appear in `bridgeHandlers`. New `__bind.*` names are registered as sync or async bridge functions automatically (async if the handler returns a Promise, sync otherwise).
112+
113+
Note: sync/async detection happens on the host TS side at registration time. The IPC message (`BridgeCall`) already carries the call mode. The Rust side dispatches to `sync_bridge_callback` or `async_bridge_callback` based on how the function was registered.
114+
115+
### Sandbox-side inflation
116+
117+
During bridge code composition, a small JS snippet is appended that:
118+
119+
1. Reads the list of `__bind.*` globals registered on `globalThis`.
120+
2. Splits each key on `.` and builds a nested object tree.
121+
3. Each leaf wraps the raw `__bind.*` global in a function call.
122+
4. Freezes the tree recursively.
123+
5. Sets `globalThis.SecureExec = Object.freeze({ bindings: tree })`.
124+
125+
Pseudocode for the inflation snippet (~15 lines):
126+
127+
```js
128+
(function() {
129+
const tree = {};
130+
for (const key of __bindingKeys__) {
131+
const parts = key.split(".");
132+
let node = tree;
133+
for (let i = 0; i < parts.length - 1; i++) {
134+
node[parts[i]] = node[parts[i]] || {};
135+
node = node[parts[i]];
136+
}
137+
node[parts[parts.length - 1]] = globalThis["__bind." + key];
138+
}
139+
function deepFreeze(obj) {
140+
for (const v of Object.values(obj)) {
141+
if (typeof v === "object" && v !== null) deepFreeze(v);
142+
}
143+
return Object.freeze(obj);
144+
}
145+
globalThis.SecureExec = Object.freeze({ bindings: deepFreeze(tree) });
146+
})();
147+
```
148+
149+
The `__bindingKeys__` array is injected as a JSON literal during bridge code composition. The raw `__bind.*` globals are deleted from `globalThis` after inflation (the tree holds the only references).
150+
151+
## Serialization
152+
153+
Arguments and return values use the existing V8 ValueSerializer pipeline (structured clone). Supported types:
154+
155+
- Primitives (string, number, boolean, null, undefined)
156+
- Plain objects and arrays
157+
- Uint8Array / ArrayBuffer
158+
- Date, Map, Set, RegExp
159+
- Error objects
160+
- Nested/circular references
161+
162+
Not supported (will throw):
163+
- Functions (cannot cross the bridge)
164+
- Symbols
165+
- WeakMap / WeakSet
166+
- DOM objects (not applicable)
167+
168+
Same payload size limits as all bridge calls (`ERR_SANDBOX_PAYLOAD_TOO_LARGE`).
169+
170+
## Implementation plan
171+
172+
### Phase 1: Core plumbing
173+
174+
1. Add `bindings?: BindingTree` to `NodeRuntimeOptions`.
175+
2. Thread through `RuntimeDriverOptions` to `NodeExecutionDriver`.
176+
3. In `NodeExecutionDriver`, flatten `BindingTree` to `Map<string, BridgeHandler>` with `__bind.` prefix.
177+
4. Merge into `bridgeHandlers` in `executeInternal()`.
178+
5. Validate: no key collisions with internal names, all keys are valid identifiers, depth <= 4, leaf count <= 64.
179+
180+
### Phase 2: Sandbox-side injection
181+
182+
6. In `composeStaticBridgeCode()` or `composePostRestoreScript()`, append the inflation snippet.
183+
7. Pass binding keys list as a JSON literal injected into the snippet.
184+
8. Delete raw `__bind.*` globals after inflation.
185+
9. Ensure `SecureExec` global is present even with zero bindings.
186+
187+
### Phase 3: Tests
188+
189+
10. Test: host registers nested bindings, sandbox calls them, values round-trip correctly.
190+
11. Test: sync and async bindings both work.
191+
12. Test: `SecureExec.bindings` is frozen (cannot be mutated by sandbox code).
192+
13. Test: binding key validation rejects invalid identifiers, depth > 4, > 64 leaves.
193+
14. Test: binding name collision with internal bridge name is rejected.
194+
15. Test: complex types (objects, arrays, Uint8Array, Date) serialize correctly through bindings.
195+
16. Test: `SecureExec` global exists even with no bindings registered.
196+
17. Test: raw `__bind.*` globals are not accessible after inflation.
197+
198+
## Estimated effort
199+
200+
- ~80-100 LOC TypeScript (flattening, validation, inflation snippet, threading)
201+
- ~0 LOC Rust
202+
- ~200 LOC tests
203+
- No IPC protocol changes
204+
- No bridge contract changes (bindings are dynamic, not hardcoded)
205+
206+
## Future extensions
207+
208+
- `SecureExec.version` — package version string
209+
- `SecureExec.runtime` — runtime metadata (memoryLimit, timingMitigation, etc.)
210+
- Per-execution binding overrides (different bindings per `exec()`/`run()` call)
211+
- Binding middleware (logging, rate limiting, timeout per binding call)
212+
- TypeScript type generation for sandbox-side bindings (from host registration)

docs-internal/todo.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,14 @@ See `docs-internal/specs/v8-perf-research.md` for detailed profiling data and an
224224
- Current approach reads/writes entire file contents; offset-based ops reduce large-file pressure
225225
- Files: `packages/secure-exec-core/src/bridge/fs.ts`
226226

227+
## Custom Bindings
228+
229+
- [ ] Implement `SecureExec.bindings` API for exposing host functions to sandbox code.
230+
- Spec: `docs-internal/specs/custom-bindings.md`
231+
- Nested object registration on host, auto-inflated to frozen `SecureExec.bindings.*` namespace in sandbox.
232+
- No Rust changes needed — piggybacks on existing `bridgeHandlers` mechanism.
233+
- Files: `packages/secure-exec-core/src/runtime.ts`, `packages/secure-exec-node/src/execution-driver.ts`, `packages/secure-exec-core/src/runtime-driver.ts`
234+
227235
## CI and Automation
228236

229237
- [ ] Automated rusty_v8 version update PR

0 commit comments

Comments
 (0)