@skanl/brambo-sandbox 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SKANL
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ export { createSandboxProviderResolver } from './sandbox.ts';
2
+ export type { ResolvedSandboxSession, SandboxProviderResolver } from './sandbox.ts';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { createSandboxProviderResolver } from './sandbox.js';
@@ -0,0 +1,16 @@
1
+ import type { SandboxExecutionRequest, SandboxExecutionResult, SandboxCapabilityFacts, SandboxPolicy, SandboxProvider, SandboxSessionRequest, SandboxSnapshot, SandboxStdioSession } from '@skanl/brambo-contracts';
2
+ export interface ResolvedSandboxSession {
3
+ readonly id: string;
4
+ readonly providerId: string;
5
+ readonly capabilities?: SandboxCapabilityFacts;
6
+ execute(request: SandboxExecutionRequest): Promise<SandboxExecutionResult>;
7
+ openStdio?(request: SandboxExecutionRequest): Promise<SandboxStdioSession>;
8
+ snapshot?(paths: readonly string[]): Promise<readonly SandboxSnapshot[]>;
9
+ restore?(snapshots: readonly SandboxSnapshot[]): Promise<void>;
10
+ dispose(): Promise<void>;
11
+ }
12
+ export interface SandboxProviderResolver {
13
+ select(policy: SandboxPolicy): SandboxProvider;
14
+ createSession(request: SandboxSessionRequest): Promise<ResolvedSandboxSession>;
15
+ }
16
+ export declare function createSandboxProviderResolver(providers: readonly SandboxProvider[]): SandboxProviderResolver;
@@ -0,0 +1,252 @@
1
+ import { BRAMBO_ERROR_CODES, BramboError, validateSandboxCapabilities, validateSandboxExecutionRequest, validateSandboxExecutionResult, validateSandboxPolicy, validateSandboxSnapshot, } from '@skanl/brambo-contracts';
2
+ function unavailable(message, cause) {
3
+ return new BramboError(BRAMBO_ERROR_CODES.sandboxUnavailable, message, cause === undefined ? {} : { cause });
4
+ }
5
+ function requestInvalid(message) {
6
+ return new BramboError(BRAMBO_ERROR_CODES.sandboxRequestInvalid, message);
7
+ }
8
+ function validatedSessionRequest(request) {
9
+ const policy = validateSandboxPolicy(request.policy);
10
+ if (!Array.isArray(request.snapshots))
11
+ throw requestInvalid('sandbox session snapshots must be an array');
12
+ const snapshots = Object.freeze(request.snapshots.map((snapshot) => validateSandboxSnapshot(snapshot)));
13
+ return Object.freeze({ policy, snapshots });
14
+ }
15
+ function snapshotCapabilities(value) {
16
+ if (typeof value !== 'object' || value === null)
17
+ return value;
18
+ const candidate = value;
19
+ const controls = candidate['controls'];
20
+ return Object.freeze({
21
+ version: candidate['version'],
22
+ providerId: candidate['providerId'],
23
+ enforcement: candidate['enforcement'],
24
+ controls: typeof controls !== 'object' || controls === null
25
+ ? controls
26
+ : Object.freeze({
27
+ filesystem: controls['filesystem'],
28
+ network: controls['network'],
29
+ process: controls['process'],
30
+ resources: controls['resources'],
31
+ }),
32
+ });
33
+ }
34
+ function snapshotExecutionResult(value) {
35
+ if (typeof value !== 'object' || value === null)
36
+ return value;
37
+ const candidate = value;
38
+ const error = candidate['error'];
39
+ const normalizedError = typeof error !== 'object' || error === null
40
+ ? error
41
+ : Object.freeze({ code: error['code'], message: error['message'] });
42
+ return Object.freeze({
43
+ status: candidate['status'],
44
+ stdout: candidate['stdout'],
45
+ stderr: candidate['stderr'],
46
+ enforcement: snapshotCapabilities(candidate['enforcement']),
47
+ exitCode: candidate['exitCode'],
48
+ ...(normalizedError === undefined ? {} : { error: normalizedError }),
49
+ });
50
+ }
51
+ function selectProvider(providers, policy) {
52
+ let firstCapabilityFailure;
53
+ for (const provider of providers) {
54
+ try {
55
+ // Provider-owned getters run once, at this boundary. Everything past this
56
+ // point uses frozen values brambo owns rather than mutable provider state.
57
+ const providerId = provider.id;
58
+ const capabilities = validateSandboxCapabilities(policy, snapshotCapabilities(provider.capabilities));
59
+ const createSession = provider.createSession;
60
+ if (typeof createSession !== 'function')
61
+ continue;
62
+ if (providerId !== capabilities.providerId)
63
+ continue;
64
+ return Object.freeze({ provider, providerId, capabilities, createSession: createSession.bind(provider) });
65
+ }
66
+ catch (error) {
67
+ if (error instanceof BramboError && error.code === BRAMBO_ERROR_CODES.sandboxCapabilityUnavailable && firstCapabilityFailure === undefined) {
68
+ firstCapabilityFailure = error;
69
+ }
70
+ }
71
+ }
72
+ if (firstCapabilityFailure !== undefined)
73
+ throw firstCapabilityFailure;
74
+ throw new BramboError(BRAMBO_ERROR_CODES.sandboxCapabilityUnavailable, 'no sandbox provider proves every required capability');
75
+ }
76
+ function normalizedSession(value) {
77
+ if (typeof value !== 'object' || value === null)
78
+ throw new Error('sandbox provider returned a non-object session');
79
+ const candidate = value;
80
+ const { id, execute, openStdio, snapshot, restore, dispose } = candidate;
81
+ if (typeof id !== 'string' || id.length === 0 || typeof execute !== 'function' || typeof dispose !== 'function') {
82
+ throw new Error('sandbox provider returned an invalid session');
83
+ }
84
+ return Object.freeze({
85
+ id,
86
+ execute: execute.bind(value),
87
+ ...(typeof openStdio === 'function' ? { openStdio: openStdio.bind(value) } : {}),
88
+ ...(typeof snapshot === 'function' ? { snapshot: snapshot.bind(value) } : {}),
89
+ ...(typeof restore === 'function' ? { restore: restore.bind(value) } : {}),
90
+ dispose: dispose.bind(value),
91
+ });
92
+ }
93
+ class ManagedSandboxSession {
94
+ session;
95
+ policy;
96
+ providerId;
97
+ capabilitiesValue;
98
+ #state = 'active';
99
+ #disposePromise;
100
+ constructor(session, policy, providerId, capabilitiesValue) {
101
+ this.session = session;
102
+ this.policy = policy;
103
+ this.providerId = providerId;
104
+ this.capabilitiesValue = capabilitiesValue;
105
+ }
106
+ get id() {
107
+ return this.session.id;
108
+ }
109
+ get capabilities() {
110
+ return this.capabilitiesValue;
111
+ }
112
+ async execute(request) {
113
+ if (this.#state !== 'active')
114
+ throw unavailable(`sandbox session '${this.id}' is not reusable after teardown begins`);
115
+ const validated = validateSandboxExecutionRequest(request);
116
+ if (!samePolicy(this.policy, validated.policy)) {
117
+ throw requestInvalid(`sandbox execution policy does not match session '${this.id}' policy`);
118
+ }
119
+ let executionResult;
120
+ try {
121
+ executionResult = await this.session.execute(validated);
122
+ }
123
+ catch (error) {
124
+ this.#state = 'uncertain';
125
+ throw unavailable(`sandbox session '${this.id}' execution failed without a structured result`, error);
126
+ }
127
+ try {
128
+ let resultSnapshot;
129
+ try {
130
+ // Snapshot every provider-owned result property before local validation so
131
+ // hostile getters cannot choose brambo's outward error vocabulary.
132
+ resultSnapshot = snapshotExecutionResult(executionResult);
133
+ }
134
+ catch (error) {
135
+ throw unavailable(`sandbox session '${this.id}' execution result could not be normalized`, error);
136
+ }
137
+ const result = validateSandboxExecutionResult(resultSnapshot);
138
+ const enforcement = validateSandboxCapabilities(this.policy, result.enforcement);
139
+ if (enforcement.providerId !== this.capabilitiesValue.providerId) {
140
+ throw new BramboError(BRAMBO_ERROR_CODES.sandboxResponseInvalid, `sandbox session '${this.id}' returned enforcement for provider '${enforcement.providerId}', not selected provider '${this.providerId}'`);
141
+ }
142
+ return result;
143
+ }
144
+ catch (error) {
145
+ this.#state = 'uncertain';
146
+ if (error instanceof BramboError)
147
+ throw error;
148
+ throw unavailable(`sandbox session '${this.id}' execution result could not be validated`, error);
149
+ }
150
+ }
151
+ async openStdio(request) {
152
+ if (this.#state !== 'active')
153
+ throw unavailable(`sandbox session '${this.id}' is not reusable after teardown begins`);
154
+ const validated = validateSandboxExecutionRequest(request);
155
+ if (!samePolicy(this.policy, validated.policy))
156
+ throw requestInvalid(`sandbox execution policy does not match session '${this.id}' policy`);
157
+ const openStdio = this.session.openStdio;
158
+ if (typeof openStdio !== 'function')
159
+ throw unavailable(`sandbox session '${this.id}' does not expose stdio`);
160
+ try {
161
+ return await openStdio(validated);
162
+ }
163
+ catch (error) {
164
+ this.#state = 'uncertain';
165
+ throw unavailable(`sandbox session '${this.id}' stdio could not be opened`, error);
166
+ }
167
+ }
168
+ async snapshot(paths) {
169
+ if (this.#state !== 'active')
170
+ throw unavailable(`sandbox session '${this.id}' is not reusable after teardown begins`);
171
+ const snapshot = this.session.snapshot;
172
+ if (typeof snapshot !== 'function')
173
+ throw unavailable(`sandbox session '${this.id}' does not expose snapshots`);
174
+ try {
175
+ return Object.freeze((await snapshot.call(this.session, Object.freeze([...paths]))).map((entry) => validateSandboxSnapshot(entry)));
176
+ }
177
+ catch (error) {
178
+ this.#state = 'uncertain';
179
+ throw unavailable(`sandbox session '${this.id}' snapshots could not be created`, error);
180
+ }
181
+ }
182
+ async restore(snapshots) {
183
+ if (this.#state !== 'active')
184
+ throw unavailable(`sandbox session '${this.id}' is not reusable after teardown begins`);
185
+ const restore = this.session.restore;
186
+ if (typeof restore !== 'function')
187
+ throw unavailable(`sandbox session '${this.id}' does not expose snapshot restore`);
188
+ try {
189
+ await restore.call(this.session, Object.freeze(snapshots.map((entry) => validateSandboxSnapshot(entry))));
190
+ }
191
+ catch (error) {
192
+ this.#state = 'uncertain';
193
+ throw unavailable(`sandbox session '${this.id}' snapshots could not be restored`, error);
194
+ }
195
+ }
196
+ dispose() {
197
+ if (this.#disposePromise !== undefined)
198
+ return this.#disposePromise;
199
+ this.#state = 'disposing';
200
+ this.#disposePromise = Promise.resolve()
201
+ .then(() => this.session.dispose())
202
+ .then(() => {
203
+ this.#state = 'disposed';
204
+ }, (error) => {
205
+ this.#state = 'uncertain';
206
+ throw unavailable(`sandbox session '${this.id}' teardown outcome is uncertain`, error);
207
+ });
208
+ return this.#disposePromise;
209
+ }
210
+ }
211
+ function samePolicy(left, right) {
212
+ if (left.version !== right.version ||
213
+ left.mode !== right.mode ||
214
+ left.workspaceRoot !== right.workspaceRoot ||
215
+ (left.networkMode ?? 'deny') !== (right.networkMode ?? 'deny') ||
216
+ left.allowDangerous !== right.allowDangerous) {
217
+ return false;
218
+ }
219
+ const leftEntries = Object.entries(left.requiredCapabilities).sort(([a], [b]) => a.localeCompare(b));
220
+ const rightEntries = Object.entries(right.requiredCapabilities).sort(([a], [b]) => a.localeCompare(b));
221
+ if (!(leftEntries.length === rightEntries.length && leftEntries.every(([key, value], index) => rightEntries[index]?.[0] === key && rightEntries[index]?.[1] === value)))
222
+ return false;
223
+ const leftLimits = Object.entries(left.resourceLimits ?? {}).sort(([a], [b]) => a.localeCompare(b));
224
+ const rightLimits = Object.entries(right.resourceLimits ?? {}).sort(([a], [b]) => a.localeCompare(b));
225
+ return leftLimits.length === rightLimits.length && leftLimits.every(([key, value], index) => rightLimits[index]?.[0] === key && rightLimits[index]?.[1] === value);
226
+ }
227
+ export function createSandboxProviderResolver(providers) {
228
+ const available = Object.freeze([...providers]);
229
+ function select(policy) {
230
+ const validated = validateSandboxPolicy(policy);
231
+ if (available.length === 0)
232
+ throw unavailable('no sandbox providers are registered');
233
+ return selectProvider(available, validated).provider;
234
+ }
235
+ return Object.freeze({
236
+ select,
237
+ async createSession(request) {
238
+ const validated = validatedSessionRequest(request);
239
+ if (available.length === 0)
240
+ throw unavailable('no sandbox providers are registered');
241
+ const selected = selectProvider(available, validated.policy);
242
+ let session;
243
+ try {
244
+ session = normalizedSession(await selected.createSession(validated));
245
+ }
246
+ catch (error) {
247
+ throw unavailable('sandbox provider could not create a session', error);
248
+ }
249
+ return new ManagedSandboxSession(session, validated.policy, selected.providerId, selected.capabilities);
250
+ },
251
+ });
252
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@skanl/brambo-sandbox",
3
+ "version": "0.1.1",
4
+ "description": "Provider-neutral sandbox selection, capability negotiation, and session lifecycle management.",
5
+ "keywords": [
6
+ "ai-agent",
7
+ "brambo",
8
+ "sandbox",
9
+ "capability"
10
+ ],
11
+ "homepage": "https://github.com/SKANL/brambo#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/SKANL/brambo/issues"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/SKANL/brambo.git",
18
+ "directory": "packages/sandbox"
19
+ },
20
+ "license": "MIT",
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "type": "module",
25
+ "engines": {
26
+ "node": ">=20"
27
+ },
28
+ "exports": {
29
+ ".": {
30
+ "brambo-source": "./src/index.ts",
31
+ "types": "./dist/index.d.ts",
32
+ "default": "./dist/index.js"
33
+ }
34
+ },
35
+ "dependencies": {
36
+ "@skanl/brambo-contracts": "0.1.1"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^24.13.3",
40
+ "typescript": "~7.0.2",
41
+ "vitest": "^4.1.11"
42
+ },
43
+ "files": [
44
+ "dist"
45
+ ],
46
+ "scripts": {
47
+ "typecheck": "tsc --noEmit",
48
+ "test": "vitest run",
49
+ "lint": "eslint .",
50
+ "build": "node ../../scripts/clean-dist.mjs && tsc -p tsconfig.build.json"
51
+ }
52
+ }