@johnhenry/andbox 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +207 -0
- package/package.json +40 -0
- package/src/capability-gate.mjs +98 -0
- package/src/constants.mjs +24 -0
- package/src/deferred.mjs +23 -0
- package/src/import-map-resolver.mjs +71 -0
- package/src/index.d.ts +268 -0
- package/src/index.mjs +14 -0
- package/src/network-policy.mjs +48 -0
- package/src/sandbox.mjs +394 -0
- package/src/stdio.mjs +58 -0
- package/src/worker-source.mjs +216 -0
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* andbox — Sandboxed JavaScript runtime.
|
|
3
|
+
*
|
|
4
|
+
* TypeScript type definitions for all public API exports.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// ── constants ──
|
|
8
|
+
|
|
9
|
+
/** Default timeout for evaluate() in milliseconds (30 000). */
|
|
10
|
+
export declare const DEFAULT_TIMEOUT_MS: 30000;
|
|
11
|
+
|
|
12
|
+
/** Default resource limits for capability gating. */
|
|
13
|
+
export declare const DEFAULT_LIMITS: Readonly<{
|
|
14
|
+
/** Max capability calls per sandbox lifetime (0 = unlimited). */
|
|
15
|
+
maxCalls: 0;
|
|
16
|
+
/** Max total argument bytes across all calls (0 = unlimited). */
|
|
17
|
+
maxArgBytes: 0;
|
|
18
|
+
/** Max concurrent pending capability calls. */
|
|
19
|
+
maxConcurrent: 16;
|
|
20
|
+
}>;
|
|
21
|
+
|
|
22
|
+
/** Default per-capability limits. */
|
|
23
|
+
export declare const DEFAULT_CAPABILITY_LIMITS: Readonly<{
|
|
24
|
+
/** Max argument bytes for a single call to this capability (0 = unlimited). */
|
|
25
|
+
maxArgBytes: 0;
|
|
26
|
+
/** Max calls to this specific capability (0 = unlimited). */
|
|
27
|
+
maxCalls: 0;
|
|
28
|
+
}>;
|
|
29
|
+
|
|
30
|
+
// ── deferred ──
|
|
31
|
+
|
|
32
|
+
/** A deferred promise with externally accessible resolve/reject. */
|
|
33
|
+
export interface Deferred<T = unknown> {
|
|
34
|
+
promise: Promise<T>;
|
|
35
|
+
resolve: (value: T | PromiseLike<T>) => void;
|
|
36
|
+
reject: (reason?: unknown) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Create a deferred promise with external resolve/reject. */
|
|
40
|
+
export declare function makeDeferred<T = unknown>(): Deferred<T>;
|
|
41
|
+
|
|
42
|
+
/** Create a DOMException with name "AbortError". */
|
|
43
|
+
export declare function makeAbortError(message?: string): DOMException;
|
|
44
|
+
|
|
45
|
+
/** Create an Error with name "TimeoutError". */
|
|
46
|
+
export declare function makeTimeoutError(ms: number): Error;
|
|
47
|
+
|
|
48
|
+
// ── capability-gate ──
|
|
49
|
+
|
|
50
|
+
/** Resource limits for capability gating. */
|
|
51
|
+
export interface GateLimits {
|
|
52
|
+
/** Max capability calls per sandbox lifetime (0 = unlimited). */
|
|
53
|
+
maxCalls?: number;
|
|
54
|
+
/** Max total argument bytes across all calls (0 = unlimited). */
|
|
55
|
+
maxArgBytes?: number;
|
|
56
|
+
/** Max concurrent pending capability calls. */
|
|
57
|
+
maxConcurrent?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Per-capability limits. */
|
|
61
|
+
export interface CapabilityLimits {
|
|
62
|
+
/** Max argument bytes for a single call to this capability (0 = unlimited). */
|
|
63
|
+
maxArgBytes?: number;
|
|
64
|
+
/** Max calls to this specific capability (0 = unlimited). */
|
|
65
|
+
maxCalls?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Rate limiting policy for gateCapabilities. */
|
|
69
|
+
export interface GatePolicy {
|
|
70
|
+
/** Global resource limits. */
|
|
71
|
+
limits?: GateLimits;
|
|
72
|
+
/** Per-capability limit overrides, keyed by capability name. */
|
|
73
|
+
capabilities?: Record<string, CapabilityLimits>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Per-capability usage statistics. */
|
|
77
|
+
export interface CapabilityStats {
|
|
78
|
+
calls: number;
|
|
79
|
+
argBytes: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Aggregate gate statistics returned by stats(). */
|
|
83
|
+
export interface GateStatsResult {
|
|
84
|
+
totalCalls: number;
|
|
85
|
+
totalArgBytes: number;
|
|
86
|
+
concurrent: number;
|
|
87
|
+
perCapability: Record<string, CapabilityStats>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Result of gateCapabilities(). */
|
|
91
|
+
export interface GateResult {
|
|
92
|
+
/** Capability functions wrapped with rate-limiting enforcement. */
|
|
93
|
+
gated: Record<string, (...args: unknown[]) => Promise<unknown>>;
|
|
94
|
+
/** Returns current gate statistics. */
|
|
95
|
+
stats: () => GateStatsResult;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Gate capabilities with rate limiting and payload caps.
|
|
100
|
+
*
|
|
101
|
+
* Wraps a capabilities object with a Proxy that enforces global call count
|
|
102
|
+
* limits, global argument byte limits, per-capability limits, and concurrent
|
|
103
|
+
* call limits.
|
|
104
|
+
*/
|
|
105
|
+
export declare function gateCapabilities(
|
|
106
|
+
capabilities: Record<string, (...args: any[]) => any>,
|
|
107
|
+
policy?: GatePolicy,
|
|
108
|
+
): GateResult;
|
|
109
|
+
|
|
110
|
+
// ── import-map-resolver ──
|
|
111
|
+
|
|
112
|
+
/** An import map following the WICG import maps spec (subset). */
|
|
113
|
+
export interface ImportMap {
|
|
114
|
+
/** Bare specifier to URL mappings. */
|
|
115
|
+
imports?: Record<string, string>;
|
|
116
|
+
/** Per-scope specifier to URL mappings. */
|
|
117
|
+
scopes?: Record<string, Record<string, string>>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve a specifier using an import map.
|
|
122
|
+
*
|
|
123
|
+
* Supports `imports` (bare specifier to URL) and `scopes` (per-prefix overrides).
|
|
124
|
+
*
|
|
125
|
+
* @param specifier The import specifier (bare or relative).
|
|
126
|
+
* @param importMap The import map to resolve against.
|
|
127
|
+
* @param parentURL The URL of the importing module (for scopes).
|
|
128
|
+
* @returns Resolved URL or null if no match.
|
|
129
|
+
*/
|
|
130
|
+
export declare function resolveWithImportMap(
|
|
131
|
+
specifier: string,
|
|
132
|
+
importMap: ImportMap | null | undefined,
|
|
133
|
+
parentURL?: string,
|
|
134
|
+
): string | null;
|
|
135
|
+
|
|
136
|
+
// ── network-policy ──
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Create a fetch function that enforces a URL hostname allowlist.
|
|
140
|
+
*
|
|
141
|
+
* @param allowedHosts Array of allowed hostnames. If empty/null, all hosts are allowed.
|
|
142
|
+
* @param fetchFn The fetch implementation to wrap (defaults to globalThis.fetch).
|
|
143
|
+
* @returns A gated fetch function.
|
|
144
|
+
*/
|
|
145
|
+
export declare function createNetworkFetch(
|
|
146
|
+
allowedHosts?: string[] | null,
|
|
147
|
+
fetchFn?: typeof globalThis.fetch,
|
|
148
|
+
): (url: string, init?: RequestInit) => Promise<Response>;
|
|
149
|
+
|
|
150
|
+
// ── stdio ──
|
|
151
|
+
|
|
152
|
+
/** An async iterable stdio stream with push/end controls. */
|
|
153
|
+
export interface StdioStream {
|
|
154
|
+
/** Push a message to the stream. No-op after end(). */
|
|
155
|
+
push: (msg: string) => void;
|
|
156
|
+
/** Signal the end of the stream. */
|
|
157
|
+
end: () => void;
|
|
158
|
+
/** The async iterable stream of messages. */
|
|
159
|
+
stream: AsyncIterable<string>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Create an async iterable stdio stream.
|
|
164
|
+
* Push messages via push(), close via end().
|
|
165
|
+
*/
|
|
166
|
+
export declare function createStdio(): StdioStream;
|
|
167
|
+
|
|
168
|
+
// ── worker-source ──
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Generate the Worker source code as a string.
|
|
172
|
+
*
|
|
173
|
+
* The Worker supports configure, defineModule, evaluate, and dispose messages
|
|
174
|
+
* from the host, and sends configured, moduleDefined, result, capabilityCall,
|
|
175
|
+
* and console messages back.
|
|
176
|
+
*
|
|
177
|
+
* @returns The complete Worker script source code as a string.
|
|
178
|
+
*/
|
|
179
|
+
export declare function makeWorkerSource(): string;
|
|
180
|
+
|
|
181
|
+
// ── sandbox ──
|
|
182
|
+
|
|
183
|
+
/** Options for evaluate(). */
|
|
184
|
+
export interface EvaluateOptions {
|
|
185
|
+
/** Timeout in milliseconds for this evaluation (overrides sandbox default). */
|
|
186
|
+
timeoutMs?: number;
|
|
187
|
+
/** AbortSignal to cancel the evaluation. */
|
|
188
|
+
signal?: AbortSignal;
|
|
189
|
+
/** Console output handler for this evaluation (overrides sandbox-level handler). */
|
|
190
|
+
onConsole?: (level: string, ...args: string[]) => void;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Options for createSandbox(). */
|
|
194
|
+
export interface SandboxOptions {
|
|
195
|
+
/** Import map for module resolution inside the sandbox. */
|
|
196
|
+
importMap?: ImportMap;
|
|
197
|
+
/** Host functions callable from sandbox code via host.call(name, ...args). */
|
|
198
|
+
capabilities?: Record<string, (...args: any[]) => any>;
|
|
199
|
+
/** Default timeout in milliseconds for evaluate() calls. */
|
|
200
|
+
defaultTimeoutMs?: number;
|
|
201
|
+
/** Base URL for resolving relative imports inside the sandbox. */
|
|
202
|
+
baseURL?: string;
|
|
203
|
+
/** Rate limiting policy for capability calls. */
|
|
204
|
+
policy?: GatePolicy;
|
|
205
|
+
/** Console output handler. Called when sandboxed code uses console.log/warn/error/etc. */
|
|
206
|
+
onConsole?: (level: string, ...args: string[]) => void;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Sandbox statistics. */
|
|
210
|
+
export interface SandboxStats {
|
|
211
|
+
disposed: boolean;
|
|
212
|
+
pendingEvaluations: number;
|
|
213
|
+
virtualModules: string[];
|
|
214
|
+
gate: GateStatsResult;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** A sandboxed JavaScript runtime instance. */
|
|
218
|
+
export interface Sandbox {
|
|
219
|
+
/**
|
|
220
|
+
* Evaluate JavaScript code in the sandbox.
|
|
221
|
+
*
|
|
222
|
+
* The code is wrapped in an async IIFE and can use `sandboxImport(specifier)`
|
|
223
|
+
* to load modules and `host.call(name, ...args)` to invoke host capabilities.
|
|
224
|
+
*
|
|
225
|
+
* @param code JavaScript code to execute.
|
|
226
|
+
* @param opts Evaluation options (timeout, signal, console handler).
|
|
227
|
+
* @returns The return value of the evaluated code.
|
|
228
|
+
*/
|
|
229
|
+
evaluate(code: string, opts?: EvaluateOptions): Promise<unknown>;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Define a virtual module accessible via sandboxImport() inside the sandbox.
|
|
233
|
+
*
|
|
234
|
+
* @param name Module specifier (e.g. 'std/hello').
|
|
235
|
+
* @param source Module source code (ES module).
|
|
236
|
+
*/
|
|
237
|
+
defineModule(name: string, source: string): Promise<void>;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Terminate the sandbox. Rejects all pending evaluations.
|
|
241
|
+
* Calling dispose() multiple times is safe (subsequent calls are no-ops).
|
|
242
|
+
*/
|
|
243
|
+
dispose(): Promise<void>;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Get sandbox statistics (gate stats, pending count, virtual module list, disposed state).
|
|
247
|
+
*/
|
|
248
|
+
stats(): SandboxStats;
|
|
249
|
+
|
|
250
|
+
/** Returns true if the sandbox has been disposed. */
|
|
251
|
+
isDisposed(): boolean;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Create a new sandboxed JavaScript runtime.
|
|
256
|
+
*
|
|
257
|
+
* The sandbox runs in an isolated Web Worker with:
|
|
258
|
+
* - RPC-based capability calls (host.call)
|
|
259
|
+
* - Import map resolution
|
|
260
|
+
* - Virtual module definitions
|
|
261
|
+
* - Timeout + hard kill + restart
|
|
262
|
+
* - Console forwarding
|
|
263
|
+
* - Capability gating with rate limits
|
|
264
|
+
*
|
|
265
|
+
* @param options Sandbox configuration options.
|
|
266
|
+
* @returns A promise that resolves to the sandbox instance.
|
|
267
|
+
*/
|
|
268
|
+
export declare function createSandbox(options?: SandboxOptions): Promise<Sandbox>;
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* andbox — Sandboxed JavaScript runtime.
|
|
3
|
+
*
|
|
4
|
+
* Public API re-exports.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { createSandbox } from './sandbox.mjs';
|
|
8
|
+
export { resolveWithImportMap } from './import-map-resolver.mjs';
|
|
9
|
+
export { gateCapabilities } from './capability-gate.mjs';
|
|
10
|
+
export { createStdio } from './stdio.mjs';
|
|
11
|
+
export { createNetworkFetch } from './network-policy.mjs';
|
|
12
|
+
export { makeDeferred, makeAbortError, makeTimeoutError } from './deferred.mjs';
|
|
13
|
+
export { DEFAULT_TIMEOUT_MS, DEFAULT_LIMITS, DEFAULT_CAPABILITY_LIMITS } from './constants.mjs';
|
|
14
|
+
export { makeWorkerSource } from './worker-source.mjs';
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network policy — gated fetch with URL allowlist.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create a fetch function that enforces a URL allowlist.
|
|
7
|
+
*
|
|
8
|
+
* @param {string[]} [allowedHosts] - Array of allowed hostnames. If empty/null, all hosts allowed.
|
|
9
|
+
* @param {typeof fetch} [fetchFn] - The fetch implementation to wrap (defaults to globalThis.fetch).
|
|
10
|
+
* @returns {(url: string, init?: RequestInit) => Promise<Response>}
|
|
11
|
+
*/
|
|
12
|
+
export function createNetworkFetch(allowedHosts, fetchFn) {
|
|
13
|
+
const realFetch = fetchFn || globalThis.fetch?.bind(globalThis);
|
|
14
|
+
|
|
15
|
+
if (!allowedHosts || allowedHosts.length === 0) {
|
|
16
|
+
return async (url, init) => {
|
|
17
|
+
if (!realFetch) throw new Error('fetch is not available');
|
|
18
|
+
return realFetch(url, init);
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const hostSet = new Set(allowedHosts.map(h => h.toLowerCase()));
|
|
23
|
+
|
|
24
|
+
return async (url, init) => {
|
|
25
|
+
if (!realFetch) throw new Error('fetch is not available');
|
|
26
|
+
let hostname;
|
|
27
|
+
try {
|
|
28
|
+
hostname = new URL(url).hostname.toLowerCase();
|
|
29
|
+
} catch {
|
|
30
|
+
throw new Error(`Invalid URL: ${url}`);
|
|
31
|
+
}
|
|
32
|
+
if (!hostSet.has(hostname)) {
|
|
33
|
+
throw new Error(`Network access denied: ${hostname} is not in the allowlist`);
|
|
34
|
+
}
|
|
35
|
+
// Fetch with redirect: 'manual' so an allowlisted host can't silently
|
|
36
|
+
// redirect the request to a non-allowlisted host (SSRF via redirect).
|
|
37
|
+
// We don't re-validate and follow the redirect target -- we just reject
|
|
38
|
+
// it outright, since that's the safer default and callers can retry
|
|
39
|
+
// against the redirect target explicitly if they trust it.
|
|
40
|
+
const response = await realFetch(url, { ...init, redirect: 'manual' });
|
|
41
|
+
if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400)) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`Network access denied: ${hostname} attempted to redirect the request; redirects are not followed by the network policy`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return response;
|
|
47
|
+
};
|
|
48
|
+
}
|