@artemiskit/core 0.6.0 → 0.6.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +12 -0
  3. package/dist/adapters/types.d.ts +7 -0
  4. package/dist/adapters/types.d.ts.map +1 -1
  5. package/dist/agent-workflow/catalog.d.ts +2 -1
  6. package/dist/agent-workflow/catalog.d.ts.map +1 -1
  7. package/dist/agent-workflow/environment.d.ts +47 -0
  8. package/dist/agent-workflow/environment.d.ts.map +1 -0
  9. package/dist/agent-workflow/index.d.ts +4 -1
  10. package/dist/agent-workflow/index.d.ts.map +1 -1
  11. package/dist/agent-workflow/parser.d.ts +1 -1
  12. package/dist/agent-workflow/parser.d.ts.map +1 -1
  13. package/dist/agent-workflow/sandbox-fixtures/qualify.d.ts +2 -0
  14. package/dist/agent-workflow/sandbox-fixtures/qualify.d.ts.map +1 -0
  15. package/dist/agent-workflow/sandbox.d.ts +12 -0
  16. package/dist/agent-workflow/sandbox.d.ts.map +1 -0
  17. package/dist/agent-workflow/schema.d.ts +117 -7
  18. package/dist/agent-workflow/schema.d.ts.map +1 -1
  19. package/dist/agent-workflow/session.d.ts +113 -0
  20. package/dist/agent-workflow/session.d.ts.map +1 -0
  21. package/dist/agent-workflow/target.d.ts +20 -7
  22. package/dist/agent-workflow/target.d.ts.map +1 -1
  23. package/dist/index.js +1366 -21
  24. package/package.json +1 -1
  25. package/src/adapters/types.ts +7 -0
  26. package/src/agent-workflow/catalog.ts +4 -3
  27. package/src/agent-workflow/environment.ts +207 -0
  28. package/src/agent-workflow/index.ts +5 -1
  29. package/src/agent-workflow/parser.ts +1 -1
  30. package/src/agent-workflow/sandbox-fixtures/qualify.ts +305 -0
  31. package/src/agent-workflow/sandbox.test.ts +117 -0
  32. package/src/agent-workflow/sandbox.ts +438 -0
  33. package/src/agent-workflow/schema.ts +18 -2
  34. package/src/agent-workflow/session.test.ts +629 -0
  35. package/src/agent-workflow/session.ts +1119 -0
  36. package/src/agent-workflow/target.test.ts +5 -1
  37. package/src/agent-workflow/target.ts +82 -17
@@ -0,0 +1,117 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { WorkflowEnvironmentInitializationError, workflowPathAllowed } from './environment';
3
+ import { validateAgentWorkflow } from './parser';
4
+ import { createDockerWorkflowEnvironment, createDockerWorkflowEnvironmentFactory } from './sandbox';
5
+
6
+ function workflow() {
7
+ return validateAgentWorkflow({
8
+ version: '1',
9
+ kind: 'agent_workflow',
10
+ name: 'sandbox',
11
+ target: { provider: 'custom', model: 'fixture' },
12
+ environment: {
13
+ type: 'sandbox',
14
+ policy: {
15
+ network: 'denied',
16
+ side_effects: 'denied',
17
+ permissions: { files: 'write' },
18
+ budgets: { max_actions: 5, timeout_ms: 1000 },
19
+ },
20
+ },
21
+ tools: ['read_file', 'write_file'],
22
+ workflow: {
23
+ system_instructions: 'Use declared tools.',
24
+ initial_state: {},
25
+ turns: [{ role: 'user', content: 'Read.' }],
26
+ },
27
+ outcomes: {
28
+ deterministic: [{ type: 'policy', rule: 'permissions_respected', expected: 'passed' }],
29
+ },
30
+ evidence: { trace: 'summary', artifacts: 'checksums', redact: true },
31
+ });
32
+ }
33
+ describe('Docker workflow environment offline admission', () => {
34
+ test('empty path grants deny present and absent fixture names equally', () => {
35
+ const value = workflow();
36
+ value.environment.policy.paths = { read: [], write: [] };
37
+ value.workflow.initial_state = { files: { 'present.txt': 'fixture' } };
38
+ expect(workflowPathAllowed(value, 'read_file', { path: 'present.txt' })).toBe(false);
39
+ expect(workflowPathAllowed(value, 'read_file', { path: 'absent.txt' })).toBe(false);
40
+ });
41
+
42
+ test('accepts qualified default cleanup and explicit bounded timeouts', () => {
43
+ expect(typeof createDockerWorkflowEnvironmentFactory()).toBe('function');
44
+ expect(
45
+ typeof createDockerWorkflowEnvironmentFactory({
46
+ operationTimeoutMs: 5000,
47
+ cleanupTimeoutMs: 3000,
48
+ })
49
+ ).toBe('function');
50
+ });
51
+ test.each([0, -1, 30_001, Number.NaN, 1.5])(
52
+ 'rejects invalid operation timeout %s',
53
+ (operationTimeoutMs) => {
54
+ expect(() => createDockerWorkflowEnvironmentFactory({ operationTimeoutMs })).toThrow(
55
+ 'Invalid Docker'
56
+ );
57
+ }
58
+ );
59
+ test.each([0, -1, 10_001, Number.NaN, 1.5])(
60
+ 'rejects invalid cleanup timeout %s',
61
+ (cleanupTimeoutMs) => {
62
+ expect(() => createDockerWorkflowEnvironmentFactory({ cleanupTimeoutMs })).toThrow(
63
+ 'Invalid Docker'
64
+ );
65
+ }
66
+ );
67
+ test('rejects executable, image, environment and command overrides', () => {
68
+ for (const key of ['image', 'dockerCommand', 'env', 'command', 'mount'])
69
+ expect(() =>
70
+ createDockerWorkflowEnvironmentFactory({ [key]: 'untrusted' } as never)
71
+ ).toThrow();
72
+ });
73
+ test('pre-cancelled startup never needs Docker or claims remaining resources', async () => {
74
+ const controller = new AbortController();
75
+ controller.abort();
76
+ try {
77
+ await createDockerWorkflowEnvironment({
78
+ workflow: workflow(),
79
+ initialState: {},
80
+ signal: controller.signal,
81
+ });
82
+ throw new Error('expected rejection');
83
+ } catch (error) {
84
+ expect(error).toBeInstanceOf(WorkflowEnvironmentInitializationError);
85
+ expect((error as WorkflowEnvironmentInitializationError).cleanup).toEqual({
86
+ status: 'completed',
87
+ artifacts: 'discarded',
88
+ pendingOperations: 0,
89
+ });
90
+ }
91
+ });
92
+ test.each([
93
+ { files: { '../escape.txt': 'no' } },
94
+ { files: { 'x.txt': 3 } },
95
+ { files: { 'x.txt': 'x'.repeat(16385) } },
96
+ { files: [] },
97
+ ])('rejects invalid fixture before Docker access', async (initialState) => {
98
+ await expect(
99
+ createDockerWorkflowEnvironment({
100
+ workflow: workflow(),
101
+ initialState,
102
+ signal: new AbortController().signal,
103
+ })
104
+ ).rejects.toBeInstanceOf(WorkflowEnvironmentInitializationError);
105
+ });
106
+ test('rejects wrong environment and ungranted policy before Docker access', async () => {
107
+ const value = workflow();
108
+ value.environment.type = 'simulated';
109
+ await expect(
110
+ createDockerWorkflowEnvironment({
111
+ workflow: value,
112
+ initialState: {},
113
+ signal: new AbortController().signal,
114
+ })
115
+ ).rejects.toBeInstanceOf(WorkflowEnvironmentInitializationError);
116
+ });
117
+ });
@@ -0,0 +1,438 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { getWorkflowTool } from './catalog';
4
+ import {
5
+ type WorkflowEnvironmentFactory,
6
+ WorkflowEnvironmentInitializationError,
7
+ isWorkflowState,
8
+ workflowPathAllowed,
9
+ } from './environment';
10
+ import { AgentWorkflowSchema, isWorkflowJson, isWorkflowRelativePath } from './schema';
11
+ import { type SimulatedToolResult, executeSimulatedTool } from './simulated-tools';
12
+
13
+ export const WORKFLOW_SANDBOX_IMAGE = 'oven/bun:1.3.10-alpine';
14
+ export interface DockerWorkflowEnvironmentOptions {
15
+ /** Per Docker operation. Run/session deadlines can abort sooner. Defaults to 5000. */
16
+ operationTimeoutMs?: number;
17
+ /** Exact owned-container cleanup deadline. Defaults to 3000; failures remain unresolved. */
18
+ cleanupTimeoutMs?: number;
19
+ }
20
+ const LIMIT = 1_048_576;
21
+ const OWNER_LABEL = 'artemiskit.workflow.owner';
22
+
23
+ // Fixed code, never assembled from an agent command, path, file content, or environment variable.
24
+ const FILE_PROGRAM = String.raw`
25
+ const fs = require('node:fs');
26
+ const root = '/workspace';
27
+ const limit = 1048576;
28
+ const safe = p => typeof p === 'string' && p.length <= 512 && /^[A-Za-z0-9_-][A-Za-z0-9_./-]*$/.test(p) && p.split('/').every(x => x && !['.','..','__proto__','prototype','constructor'].includes(x));
29
+ function checked(p, create=false) {
30
+ if (!safe(p)) throw 'invalid_input';
31
+ const parts = p.split('/'); let current = root;
32
+ for (let i=0;i<parts.length;i++) {
33
+ current += '/' + parts[i];
34
+ let stat;
35
+ try { stat = fs.lstatSync(current); } catch(e) { if(e.code !== 'ENOENT') throw 'tool_error'; }
36
+ if (stat && (stat.isSymbolicLink() || (i<parts.length-1 ? !stat.isDirectory() : !stat.isFile()))) throw 'invalid_input';
37
+ if (!stat && i<parts.length-1) { if(!create) throw 'not_found'; fs.mkdirSync(current, {mode: 0o700}); }
38
+ }
39
+ return current;
40
+ }
41
+ function write(p, content) {
42
+ if(typeof content !== 'string' || content.length>16384) throw 'invalid_input';
43
+ const path=checked(p,true); const fd=fs.openSync(path, fs.constants.O_WRONLY|fs.constants.O_CREAT|fs.constants.O_TRUNC|fs.constants.O_NOFOLLOW,0o600);
44
+ try { fs.writeFileSync(fd,content,'utf8'); } finally { fs.closeSync(fd); }
45
+ }
46
+ function read(p) {
47
+ const path=checked(p); let fd;
48
+ try { fd=fs.openSync(path,fs.constants.O_RDONLY|fs.constants.O_NOFOLLOW); } catch(e) { if(e.code==='ENOENT') throw 'not_found'; throw 'tool_error'; }
49
+ try { const stat=fs.fstatSync(fd); if(!stat.isFile() || stat.size>65536) throw 'output_limit'; const text=fs.readFileSync(fd,'utf8'); if(text.length>16384) throw 'output_limit'; return text; } finally { fs.closeSync(fd); }
50
+ }
51
+ function snapshot() {
52
+ const files={}; let count=0, bytes=0;
53
+ function walk(dir, prefix, depth) {
54
+ if(depth>16) throw 'output_limit';
55
+ for(const name of fs.readdirSync(dir).sort()) {
56
+ const p=prefix ? prefix+'/'+name : name;
57
+ if(!safe(p) || ++count>1000) throw 'output_limit';
58
+ const stat=fs.lstatSync(dir+'/'+name);
59
+ if(stat.isSymbolicLink()) throw 'invalid_input';
60
+ if(stat.isDirectory()) walk(dir+'/'+name,p,depth+1);
61
+ else if(stat.isFile()) { const value=read(p); bytes+=Buffer.byteLength(value)+Buffer.byteLength(p); if(bytes>limit) throw 'output_limit'; files[p]=value; }
62
+ else throw 'invalid_input';
63
+ }
64
+ }
65
+ walk(root,'',0); return files;
66
+ }
67
+ try {
68
+ const raw=await Bun.stdin.text(); if(Buffer.byteLength(raw)>limit) throw 'invalid_input';
69
+ const request=JSON.parse(raw); let output;
70
+ if(request.action==='init') { for(const [path,content] of Object.entries(request.files)) write(path,content); }
71
+ else if(request.action==='write') { write(request.path,request.content); output={path:request.path,written:true}; }
72
+ else if(request.action==='read') output={content:read(request.path)};
73
+ else if(request.action!=='snapshot') throw 'invalid_input';
74
+ const result=JSON.stringify({ok:true,files:snapshot(),...(output?{output}:{})}); if(Buffer.byteLength(result)>limit) throw 'output_limit'; process.stdout.write(result);
75
+ } catch(error) { process.stdout.write(JSON.stringify({ok:false,code:['invalid_input','not_found','output_limit'].includes(error)?error:'tool_error'})); process.exitCode=1; }
76
+ `;
77
+
78
+ class DockerFailure extends Error {
79
+ constructor(
80
+ readonly code: 'aborted' | 'timeout' | 'unavailable' | 'output_limit',
81
+ readonly uncertain = false
82
+ ) {
83
+ super(`sandbox_${code}`);
84
+ }
85
+ }
86
+ interface CommandResult {
87
+ code: number;
88
+ stdout: string;
89
+ }
90
+
91
+ /** Fixed local Docker image and host-owned tools. No command, environment, mount, or image override. */
92
+ export function createDockerWorkflowEnvironmentFactory(
93
+ options: DockerWorkflowEnvironmentOptions = {}
94
+ ): WorkflowEnvironmentFactory {
95
+ const operationTimeoutMs = options.operationTimeoutMs ?? 5000;
96
+ const cleanupTimeoutMs = options.cleanupTimeoutMs ?? 3000;
97
+ if (
98
+ Object.keys(options).some((key) => !['operationTimeoutMs', 'cleanupTimeoutMs'].includes(key)) ||
99
+ !Number.isInteger(operationTimeoutMs) ||
100
+ operationTimeoutMs < 1 ||
101
+ operationTimeoutMs > 30_000 ||
102
+ !Number.isInteger(cleanupTimeoutMs) ||
103
+ cleanupTimeoutMs < 1 ||
104
+ cleanupTimeoutMs > 10_000
105
+ )
106
+ throw new TypeError('Invalid Docker workflow environment options');
107
+ return async ({ workflow, initialState, signal }) => {
108
+ const parsed = AgentWorkflowSchema.safeParse(workflow);
109
+ if (
110
+ !parsed.success ||
111
+ parsed.data.environment.type !== 'sandbox' ||
112
+ !isWorkflowState(initialState) ||
113
+ signal.aborted
114
+ )
115
+ throw new WorkflowEnvironmentInitializationError({
116
+ status: 'completed',
117
+ artifacts: 'discarded',
118
+ pendingOperations: 0,
119
+ });
120
+ const configuration = parsed.data;
121
+ let state = structuredClone(initialState);
122
+ const files = state.files ?? {};
123
+ if (
124
+ !isWorkflowState(files) ||
125
+ Object.entries(files).some(
126
+ ([path, content]) =>
127
+ !isWorkflowRelativePath(path) || typeof content !== 'string' || content.length > 16_384
128
+ ) ||
129
+ Object.keys(files).length > 1000
130
+ )
131
+ throw new WorkflowEnvironmentInitializationError({
132
+ status: 'completed',
133
+ artifacts: 'discarded',
134
+ pendingOperations: 0,
135
+ });
136
+ const owner = randomUUID();
137
+ const name = `artemiskit-workflow-${owner}`;
138
+ const children = new Set<ReturnType<typeof spawn>>();
139
+ let closed = false;
140
+ let closing = false;
141
+ let creationAttempted = false;
142
+ let creationUncertain = false;
143
+ let busy = false;
144
+ const command = (
145
+ args: string[],
146
+ input: string,
147
+ commandSignal: AbortSignal,
148
+ timeoutMs = operationTimeoutMs
149
+ ): Promise<CommandResult> => {
150
+ if (commandSignal.aborted) return Promise.reject(new DockerFailure('aborted'));
151
+ if (Buffer.byteLength(input) > LIMIT)
152
+ return Promise.reject(new DockerFailure('output_limit'));
153
+ return new Promise((resolve, reject) => {
154
+ let child: ReturnType<typeof spawn>;
155
+ try {
156
+ child = spawn('docker', args, { stdio: ['pipe', 'pipe', 'pipe'] });
157
+ } catch {
158
+ reject(new DockerFailure('unavailable'));
159
+ return;
160
+ }
161
+ children.add(child);
162
+ const output: Buffer[] = [];
163
+ let size = 0;
164
+ let failure: DockerFailure | undefined;
165
+ let settled = false;
166
+ const stop = (error: DockerFailure) => {
167
+ failure ??= error;
168
+ child.kill('SIGKILL');
169
+ };
170
+ const abort = () => stop(new DockerFailure('aborted', true));
171
+ const timer = setTimeout(() => stop(new DockerFailure('timeout', true)), timeoutMs);
172
+ commandSignal.addEventListener('abort', abort, { once: true });
173
+ child.stdout?.on('data', (chunk: Buffer) => {
174
+ size += chunk.length;
175
+ if (size > LIMIT) stop(new DockerFailure('output_limit', true));
176
+ else output.push(Buffer.from(chunk));
177
+ });
178
+ child.stderr?.on('data', (chunk: Buffer) => {
179
+ size += chunk.length;
180
+ if (size > LIMIT) stop(new DockerFailure('output_limit', true));
181
+ });
182
+ child.stdin?.on('error', () => {
183
+ /* EPIPE is handled by process exit; no raw diagnostics. */
184
+ });
185
+ const finish = (code: number | null, error?: DockerFailure) => {
186
+ if (settled) return;
187
+ settled = true;
188
+ clearTimeout(timer);
189
+ commandSignal.removeEventListener('abort', abort);
190
+ children.delete(child);
191
+ if (error || failure) reject(error ?? failure);
192
+ else resolve({ code: code ?? 1, stdout: Buffer.concat(output).toString('utf8') });
193
+ };
194
+ child.once('error', () => finish(null, new DockerFailure('unavailable')));
195
+ child.once('close', (code) => finish(code));
196
+ child.stdin?.end(input);
197
+ if (commandSignal.aborted) abort();
198
+ });
199
+ };
200
+ async function close(closeSignal: AbortSignal) {
201
+ if (closed) return { status: 'completed' as const, artifacts: 'discarded' as const };
202
+ closing = true;
203
+ for (const child of children) child.kill('SIGKILL');
204
+ const cleanupController = new AbortController();
205
+ const onAbort = () => cleanupController.abort();
206
+ closeSignal.addEventListener('abort', onAbort, { once: true });
207
+ if (closeSignal.aborted) cleanupController.abort();
208
+ const timer = setTimeout(onAbort, cleanupTimeoutMs);
209
+ try {
210
+ if (!creationAttempted) {
211
+ closed = true;
212
+ state = {};
213
+ return { status: 'completed' as const, artifacts: 'discarded' as const };
214
+ }
215
+ const inspected = await command(
216
+ ['inspect', '--format', `{{index .Config.Labels "${OWNER_LABEL}"}}`, name],
217
+ '',
218
+ cleanupController.signal,
219
+ cleanupTimeoutMs
220
+ );
221
+ if (inspected.code === 0) {
222
+ if (inspected.stdout.trim() !== owner)
223
+ return { status: 'unresolved' as const, artifacts: 'unknown' as const };
224
+ const removed = await command(
225
+ ['rm', '--force', name],
226
+ '',
227
+ cleanupController.signal,
228
+ cleanupTimeoutMs
229
+ );
230
+ if (removed.code !== 0)
231
+ return { status: 'unresolved' as const, artifacts: 'unknown' as const };
232
+ creationUncertain = false;
233
+ }
234
+ const remaining = await command(
235
+ ['ps', '--all', '--filter', `name=^/${name}$`, '--format', '{{.Names}}'],
236
+ '',
237
+ cleanupController.signal,
238
+ cleanupTimeoutMs
239
+ );
240
+ if (
241
+ remaining.code !== 0 ||
242
+ remaining.stdout.trim() ||
243
+ creationUncertain ||
244
+ children.size > 0
245
+ )
246
+ return { status: 'unresolved' as const, artifacts: 'unknown' as const };
247
+ closed = true;
248
+ state = {};
249
+ return { status: 'completed' as const, artifacts: 'discarded' as const };
250
+ } catch {
251
+ return { status: 'unresolved' as const, artifacts: 'unknown' as const };
252
+ } finally {
253
+ clearTimeout(timer);
254
+ closeSignal.removeEventListener('abort', onAbort);
255
+ }
256
+ }
257
+ async function fileOperation(request: Record<string, unknown>, operationSignal: AbortSignal) {
258
+ const result = await command(
259
+ ['exec', '--interactive', name, 'bun', '--eval', FILE_PROGRAM],
260
+ JSON.stringify(request),
261
+ operationSignal
262
+ );
263
+ let response: unknown;
264
+ try {
265
+ response = JSON.parse(result.stdout);
266
+ } catch {
267
+ throw new DockerFailure('unavailable');
268
+ }
269
+ if (!isWorkflowState(response)) throw new DockerFailure('unavailable');
270
+ if (
271
+ response.ok === false &&
272
+ ['invalid_input', 'not_found', 'output_limit', 'tool_error'].includes(String(response.code))
273
+ )
274
+ return {
275
+ ok: false as const,
276
+ code: response.code as 'invalid_input' | 'not_found' | 'output_limit' | 'tool_error',
277
+ };
278
+ if (
279
+ result.code !== 0 ||
280
+ response.ok !== true ||
281
+ !isWorkflowState(response.files) ||
282
+ Object.entries(response.files).some(
283
+ ([path, content]) =>
284
+ !isWorkflowRelativePath(path) || typeof content !== 'string' || content.length > 16_384
285
+ )
286
+ )
287
+ throw new DockerFailure('unavailable');
288
+ const next = { ...state, files: response.files };
289
+ if (!isWorkflowState(next)) throw new DockerFailure('output_limit');
290
+ state = structuredClone(next);
291
+ return { ok: true as const, output: response.output };
292
+ }
293
+ try {
294
+ const image = await command(
295
+ ['image', 'inspect', WORKFLOW_SANDBOX_IMAGE, '--format', '{{.Id}}'],
296
+ '',
297
+ signal
298
+ );
299
+ if (image.code !== 0 || !/^sha256:[a-f0-9]{64}\s*$/.test(image.stdout))
300
+ throw new DockerFailure('unavailable');
301
+ // Pin the inspected local content ID so a concurrent tag update cannot change the selected image.
302
+ creationAttempted = true;
303
+ let created: CommandResult;
304
+ try {
305
+ created = await command(
306
+ [
307
+ 'create',
308
+ '--pull=never',
309
+ '--name',
310
+ name,
311
+ '--label',
312
+ 'artemiskit.workflow=true',
313
+ '--label',
314
+ `${OWNER_LABEL}=${owner}`,
315
+ '--network',
316
+ 'none',
317
+ '--read-only',
318
+ '--cap-drop',
319
+ 'ALL',
320
+ '--security-opt',
321
+ 'no-new-privileges',
322
+ '--memory',
323
+ '128m',
324
+ '--memory-swap',
325
+ '128m',
326
+ '--cpus',
327
+ '0.5',
328
+ '--pids-limit',
329
+ '64',
330
+ '--user',
331
+ '1000:1000',
332
+ '--tmpfs',
333
+ '/workspace:rw,noexec,nosuid,nodev,size=16777216,uid=1000,gid=1000,mode=0700',
334
+ '--tmpfs',
335
+ '/tmp:rw,noexec,nosuid,nodev,size=8388608,uid=1000,gid=1000,mode=0700',
336
+ '--workdir',
337
+ '/workspace',
338
+ '--entrypoint',
339
+ 'bun',
340
+ image.stdout.trim(),
341
+ '--eval',
342
+ 'setInterval(() => {}, 1000)',
343
+ ],
344
+ '',
345
+ signal
346
+ );
347
+ } catch (error) {
348
+ creationUncertain = error instanceof DockerFailure && error.uncertain;
349
+ throw error;
350
+ }
351
+ if (created.code !== 0) throw new DockerFailure('unavailable');
352
+ const started = await command(['start', name], '', signal);
353
+ if (started.code !== 0) throw new DockerFailure('unavailable');
354
+ const initialized = await fileOperation({ action: 'init', files }, signal);
355
+ if (!initialized.ok) throw new DockerFailure('unavailable');
356
+ } catch {
357
+ const cleanup = await close(new AbortController().signal);
358
+ throw new WorkflowEnvironmentInitializationError({
359
+ ...cleanup,
360
+ pendingOperations: children.size + (creationUncertain ? 1 : 0),
361
+ });
362
+ }
363
+ return {
364
+ type: 'sandbox',
365
+ capabilities: {
366
+ network: 'denied',
367
+ commands: 'denied',
368
+ externalSideEffects: 'denied',
369
+ isolation: 'container',
370
+ },
371
+ async execute(request, operationSignal): Promise<SimulatedToolResult> {
372
+ const tool = getWorkflowTool(request.tool)?.id ?? 'unknown';
373
+ const failure = (
374
+ status: 'denied' | 'invalid' | 'failed',
375
+ code: 'permission_denied' | 'invalid_input' | 'tool_error' | 'not_found' | 'output_limit'
376
+ ): SimulatedToolResult => ({
377
+ status,
378
+ code,
379
+ evidence: { tool, version: '1', status, code },
380
+ });
381
+ if (closed || closing || busy || operationSignal.aborted || signal.aborted)
382
+ return failure('failed', 'tool_error');
383
+ const checked = executeSimulatedTool({
384
+ tool: request.tool,
385
+ input: request.input,
386
+ state,
387
+ policy: configuration.environment.policy,
388
+ declaredTools: configuration.tools,
389
+ });
390
+ // Reject malformed original arguments, then apply authority before revealing lookup results.
391
+ if (checked.status === 'invalid' && checked.code === 'invalid_input') return checked;
392
+ if (!workflowPathAllowed(configuration, request.tool, request.input))
393
+ return failure('denied', 'permission_denied');
394
+ if (checked.status !== 'succeeded') return checked;
395
+ busy = true;
396
+ try {
397
+ if (request.tool === 'read_file' || request.tool === 'write_file') {
398
+ if (!isWorkflowState(request.input)) return failure('invalid', 'invalid_input');
399
+ const result = await fileOperation(
400
+ { action: request.tool === 'read_file' ? 'read' : 'write', ...request.input },
401
+ operationSignal
402
+ );
403
+ if (!result.ok)
404
+ return failure(result.code === 'invalid_input' ? 'invalid' : 'failed', result.code);
405
+ if (!isWorkflowJson(result.output)) return failure('failed', 'tool_error');
406
+ return {
407
+ status: 'succeeded',
408
+ output: structuredClone(result.output),
409
+ state: structuredClone(state),
410
+ evidence: { tool, version: '1', status: 'succeeded' },
411
+ };
412
+ }
413
+ state = structuredClone(checked.state);
414
+ return checked;
415
+ } catch {
416
+ return failure('failed', 'tool_error');
417
+ } finally {
418
+ busy = false;
419
+ }
420
+ },
421
+ async snapshot(snapshotSignal) {
422
+ if (closed || closing || busy || snapshotSignal.aborted)
423
+ throw new DockerFailure('unavailable');
424
+ busy = true;
425
+ try {
426
+ const result = await fileOperation({ action: 'snapshot' }, snapshotSignal);
427
+ if (!result.ok) throw new DockerFailure('unavailable');
428
+ return structuredClone(state);
429
+ } finally {
430
+ busy = false;
431
+ }
432
+ },
433
+ close,
434
+ };
435
+ };
436
+ }
437
+ export const createDockerWorkflowEnvironment: WorkflowEnvironmentFactory =
438
+ createDockerWorkflowEnvironmentFactory();
@@ -39,7 +39,8 @@ export function isWorkflowJson(value: unknown): value is WorkflowJson {
39
39
  )
40
40
  return false;
41
41
  ancestors.add(item);
42
- for (const key of Object.keys(item)) {
42
+ for (const key of Object.getOwnPropertyNames(item)) {
43
+ if (Array.isArray(item) && key === 'length') continue;
43
44
  textBytes += Buffer.byteLength(key);
44
45
  const entry = Object.getOwnPropertyDescriptor(item, key);
45
46
  if (
@@ -47,6 +48,7 @@ export function isWorkflowJson(value: unknown): value is WorkflowJson {
47
48
  forbiddenKeys.has(key) ||
48
49
  !entry ||
49
50
  !('value' in entry) ||
51
+ !entry.enumerable ||
50
52
  !visit(entry.value, depth + 1)
51
53
  )
52
54
  return false;
@@ -100,9 +102,14 @@ export const WorkflowPolicySchema = z
100
102
  coordination: permission.optional(),
101
103
  })
102
104
  .strict(),
105
+ paths: z
106
+ .object({ read: z.array(relativePath).max(1000), write: z.array(relativePath).max(1000) })
107
+ .strict()
108
+ .optional(),
103
109
  budgets: z
104
110
  .object({
105
111
  max_actions: z.number().int().min(1).max(1000),
112
+ max_model_requests: z.number().int().min(1).max(1000).optional(),
106
113
  max_tool_calls: z.number().int().min(1).max(1000).optional(),
107
114
  timeout_ms: z.number().int().min(1).max(3_600_000),
108
115
  max_tokens: z.number().int().min(1).max(1_000_000).optional(),
@@ -163,9 +170,18 @@ const definition = z
163
170
  .max(64)
164
171
  .regex(/^[a-z0-9_-]+$/),
165
172
  model: z.string().min(1).max(256),
173
+ generation: z
174
+ .object({
175
+ max_tokens: z.number().int().min(1).max(1_000_000).optional(),
176
+ temperature: z.number().min(0).max(2).optional(),
177
+ })
178
+ .strict()
179
+ .optional(),
166
180
  })
167
181
  .strict(),
168
- environment: z.object({ type: z.literal('simulated'), policy: WorkflowPolicySchema }).strict(),
182
+ environment: z
183
+ .object({ type: z.enum(['simulated', 'sandbox']), policy: WorkflowPolicySchema })
184
+ .strict(),
169
185
  tools: z.array(z.enum(WORKFLOW_TOOL_IDS)).min(1).max(WORKFLOW_TOOL_IDS.length),
170
186
  workflow: z
171
187
  .object({