keystone-cli 0.5.1 → 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 (48) hide show
  1. package/README.md +55 -8
  2. package/package.json +8 -17
  3. package/src/cli.ts +219 -166
  4. package/src/db/memory-db.test.ts +54 -0
  5. package/src/db/memory-db.ts +128 -0
  6. package/src/db/sqlite-setup.test.ts +47 -0
  7. package/src/db/sqlite-setup.ts +49 -0
  8. package/src/db/workflow-db.test.ts +41 -10
  9. package/src/db/workflow-db.ts +90 -28
  10. package/src/expression/evaluator.test.ts +19 -0
  11. package/src/expression/evaluator.ts +134 -39
  12. package/src/parser/schema.ts +41 -0
  13. package/src/runner/audit-verification.test.ts +23 -0
  14. package/src/runner/auto-heal.test.ts +64 -0
  15. package/src/runner/debug-repl.test.ts +308 -0
  16. package/src/runner/debug-repl.ts +225 -0
  17. package/src/runner/foreach-executor.ts +327 -0
  18. package/src/runner/llm-adapter.test.ts +37 -18
  19. package/src/runner/llm-adapter.ts +90 -112
  20. package/src/runner/llm-executor.test.ts +47 -6
  21. package/src/runner/llm-executor.ts +18 -3
  22. package/src/runner/mcp-client.audit.test.ts +69 -0
  23. package/src/runner/mcp-client.test.ts +12 -3
  24. package/src/runner/mcp-client.ts +199 -19
  25. package/src/runner/mcp-manager.ts +19 -8
  26. package/src/runner/mcp-server.test.ts +8 -5
  27. package/src/runner/mcp-server.ts +31 -17
  28. package/src/runner/optimization-runner.ts +305 -0
  29. package/src/runner/reflexion.test.ts +87 -0
  30. package/src/runner/shell-executor.test.ts +12 -0
  31. package/src/runner/shell-executor.ts +9 -6
  32. package/src/runner/step-executor.test.ts +240 -2
  33. package/src/runner/step-executor.ts +183 -68
  34. package/src/runner/stream-utils.test.ts +171 -0
  35. package/src/runner/stream-utils.ts +186 -0
  36. package/src/runner/workflow-runner.test.ts +4 -4
  37. package/src/runner/workflow-runner.ts +438 -259
  38. package/src/templates/agents/keystone-architect.md +6 -4
  39. package/src/templates/full-feature-demo.yaml +4 -4
  40. package/src/types/assets.d.ts +14 -0
  41. package/src/types/status.ts +1 -1
  42. package/src/ui/dashboard.tsx +38 -26
  43. package/src/utils/auth-manager.ts +3 -1
  44. package/src/utils/logger.test.ts +76 -0
  45. package/src/utils/logger.ts +39 -0
  46. package/src/utils/prompt.ts +75 -0
  47. package/src/utils/redactor.test.ts +86 -4
  48. package/src/utils/redactor.ts +48 -13
@@ -1,6 +1,19 @@
1
- import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ beforeAll,
5
+ beforeEach,
6
+ describe,
7
+ expect,
8
+ it,
9
+ mock,
10
+ spyOn,
11
+ } from 'bun:test';
12
+ import * as dns from 'node:dns/promises';
2
13
  import { mkdirSync, rmSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
3
15
  import { join } from 'node:path';
16
+ import type { MemoryDb } from '../db/memory-db';
4
17
  import type { ExpressionContext } from '../expression/evaluator';
5
18
  import type {
6
19
  FileStep,
@@ -10,6 +23,8 @@ import type {
10
23
  SleepStep,
11
24
  WorkflowStep,
12
25
  } from '../parser/schema';
26
+ import type { SafeSandbox } from '../utils/sandbox';
27
+ import type { getAdapter } from './llm-adapter';
13
28
  import { executeStep } from './step-executor';
14
29
 
15
30
  // Mock executeLlmStep
@@ -187,6 +202,224 @@ describe('step-executor', () => {
187
202
  expect(result.status).toBe('failed');
188
203
  expect(result.error).toContain('Unknown file operation');
189
204
  });
205
+
206
+ it('should allow file paths outside cwd when allowOutsideCwd is true', async () => {
207
+ const outsidePath = join(tmpdir(), `keystone-test-${Date.now()}.txt`);
208
+
209
+ const writeStep: FileStep = {
210
+ id: 'w-outside',
211
+ type: 'file',
212
+ needs: [],
213
+ op: 'write',
214
+ path: outsidePath,
215
+ content: 'outside',
216
+ allowOutsideCwd: true,
217
+ };
218
+
219
+ try {
220
+ const writeResult = await executeStep(writeStep, context);
221
+ expect(writeResult.status).toBe('success');
222
+
223
+ const content = await Bun.file(outsidePath).text();
224
+ expect(content).toBe('outside');
225
+ } finally {
226
+ try {
227
+ rmSync(outsidePath);
228
+ } catch (e) {
229
+ // Ignore cleanup errors
230
+ }
231
+ }
232
+ });
233
+
234
+ it('should block path traversal outside cwd by default', async () => {
235
+ const outsidePath = join(process.cwd(), '..', 'outside.txt');
236
+ const step: FileStep = {
237
+ id: 'f1',
238
+ type: 'file',
239
+ needs: [],
240
+ op: 'read',
241
+ path: outsidePath,
242
+ };
243
+
244
+ const result = await executeStep(step, context);
245
+ expect(result.status).toBe('failed');
246
+ expect(result.error).toContain('Access denied');
247
+ });
248
+
249
+ it('should block path traversal with .. inside path resolving outside', async () => {
250
+ const outsidePath = 'foo/../../passwd';
251
+ const step: FileStep = {
252
+ id: 'f1',
253
+ type: 'file',
254
+ needs: [],
255
+ op: 'read',
256
+ path: outsidePath,
257
+ };
258
+
259
+ const result = await executeStep(step, context);
260
+ expect(result.status).toBe('failed');
261
+ expect(result.error).toContain('Access denied');
262
+ });
263
+ });
264
+
265
+ describe('script', () => {
266
+ const mockSandbox = {
267
+ execute: mock((code) => {
268
+ if (code === 'fail') throw new Error('Script failed');
269
+ return Promise.resolve('script-result');
270
+ }),
271
+ };
272
+
273
+ it('should fail if allowInsecure is not set', async () => {
274
+ // @ts-ignore
275
+ const step = {
276
+ id: 's1',
277
+ type: 'script',
278
+ run: 'console.log("hello")',
279
+ };
280
+ const result = await executeStep(step, context, undefined, {
281
+ sandbox: mockSandbox as unknown as typeof SafeSandbox,
282
+ });
283
+ expect(result.status).toBe('failed');
284
+ expect(result.error).toContain('Script execution is disabled by default');
285
+ });
286
+
287
+ it('should execute script if allowInsecure is true', async () => {
288
+ // @ts-ignore
289
+ const step = {
290
+ id: 's1',
291
+ type: 'script',
292
+ run: 'console.log("hello")',
293
+ allowInsecure: true,
294
+ };
295
+ const result = await executeStep(step, context, undefined, {
296
+ sandbox: mockSandbox as unknown as typeof SafeSandbox,
297
+ });
298
+ expect(result.status).toBe('success');
299
+ expect(result.output).toBe('script-result');
300
+ });
301
+
302
+ it('should handle script failure', async () => {
303
+ // @ts-ignore
304
+ const step = {
305
+ id: 's1',
306
+ type: 'script',
307
+ run: 'fail',
308
+ allowInsecure: true,
309
+ };
310
+ const result = await executeStep(step, context, undefined, {
311
+ sandbox: mockSandbox as unknown as typeof SafeSandbox,
312
+ });
313
+ expect(result.status).toBe('failed');
314
+ expect(result.error).toBe('Script failed');
315
+ });
316
+ });
317
+
318
+ describe('memory', () => {
319
+ const mockMemoryDb = {
320
+ store: mock(() => Promise.resolve('mem-id')),
321
+ search: mock(() => Promise.resolve([{ content: 'found', similarity: 0.9 }])),
322
+ };
323
+
324
+ const mockGetAdapter = mock((model) => {
325
+ if (model === 'no-embed') return { adapter: {}, resolvedModel: model };
326
+ return {
327
+ adapter: {
328
+ embed: mock((text) => Promise.resolve([0.1, 0.2, 0.3])),
329
+ },
330
+ resolvedModel: model,
331
+ };
332
+ });
333
+
334
+ it('should fail if memoryDb is not provided', async () => {
335
+ // @ts-ignore
336
+ const step = { id: 'm1', type: 'memory', op: 'store', text: 'foo' };
337
+ const result = await executeStep(step, context, undefined, {
338
+ getAdapter: mockGetAdapter as unknown as typeof getAdapter,
339
+ });
340
+ expect(result.status).toBe('failed');
341
+ expect(result.error).toBe('Memory database not initialized');
342
+ });
343
+
344
+ it('should fail if adapter does not support embedding', async () => {
345
+ // @ts-ignore
346
+ const step = { id: 'm1', type: 'memory', op: 'store', text: 'foo', model: 'no-embed' };
347
+ // @ts-ignore
348
+ const result = await executeStep(step, context, undefined, {
349
+ memoryDb: mockMemoryDb as unknown as MemoryDb,
350
+ getAdapter: mockGetAdapter as unknown as typeof getAdapter,
351
+ });
352
+ expect(result.status).toBe('failed');
353
+ expect(result.error).toContain('does not support embeddings');
354
+ });
355
+
356
+ it('should store memory', async () => {
357
+ // @ts-ignore
358
+ const step = {
359
+ id: 'm1',
360
+ type: 'memory',
361
+ op: 'store',
362
+ text: 'foo',
363
+ metadata: { source: 'test' },
364
+ };
365
+ // @ts-ignore
366
+ const result = await executeStep(step, context, undefined, {
367
+ memoryDb: mockMemoryDb as unknown as MemoryDb,
368
+ getAdapter: mockGetAdapter as unknown as typeof getAdapter,
369
+ });
370
+ expect(result.status).toBe('success');
371
+ expect(result.output).toEqual({ id: 'mem-id', status: 'stored' });
372
+ expect(mockMemoryDb.store).toHaveBeenCalledWith('foo', [0.1, 0.2, 0.3], { source: 'test' });
373
+ });
374
+
375
+ it('should search memory', async () => {
376
+ // @ts-ignore
377
+ const step = { id: 'm1', type: 'memory', op: 'search', query: 'foo', limit: 5 };
378
+ // @ts-ignore
379
+ const result = await executeStep(step, context, undefined, {
380
+ memoryDb: mockMemoryDb as unknown as MemoryDb,
381
+ getAdapter: mockGetAdapter as unknown as typeof getAdapter,
382
+ });
383
+ expect(result.status).toBe('success');
384
+ expect(result.output).toEqual([{ content: 'found', similarity: 0.9 }]);
385
+ expect(mockMemoryDb.search).toHaveBeenCalledWith([0.1, 0.2, 0.3], 5);
386
+ });
387
+
388
+ it('should fail store if text is missing', async () => {
389
+ // @ts-ignore
390
+ const step = { id: 'm1', type: 'memory', op: 'store' };
391
+ // @ts-ignore
392
+ const result = await executeStep(step, context, undefined, {
393
+ memoryDb: mockMemoryDb as unknown as MemoryDb,
394
+ getAdapter: mockGetAdapter as unknown as typeof getAdapter,
395
+ });
396
+ expect(result.status).toBe('failed');
397
+ expect(result.error).toBe('Text is required for memory store operation');
398
+ });
399
+
400
+ it('should fail search if query is missing', async () => {
401
+ // @ts-ignore
402
+ const step = { id: 'm1', type: 'memory', op: 'search' };
403
+ // @ts-ignore
404
+ const result = await executeStep(step, context, undefined, {
405
+ memoryDb: mockMemoryDb as unknown as MemoryDb,
406
+ getAdapter: mockGetAdapter as unknown as typeof getAdapter,
407
+ });
408
+ expect(result.status).toBe('failed');
409
+ expect(result.error).toBe('Query is required for memory search operation');
410
+ });
411
+
412
+ it('should fail for unknown memory operation', async () => {
413
+ // @ts-ignore
414
+ const step = { id: 'm1', type: 'memory', op: 'unknown', text: 'foo' };
415
+ // @ts-ignore
416
+ const result = await executeStep(step, context, undefined, {
417
+ memoryDb: mockMemoryDb as unknown as MemoryDb,
418
+ getAdapter: mockGetAdapter as unknown as typeof getAdapter,
419
+ });
420
+ expect(result.status).toBe('failed');
421
+ expect(result.error).toContain('Unknown memory operation');
422
+ });
190
423
  });
191
424
 
192
425
  describe('sleep', () => {
@@ -207,14 +440,19 @@ describe('step-executor', () => {
207
440
 
208
441
  describe('request', () => {
209
442
  const originalFetch = global.fetch;
443
+ let lookupSpy: ReturnType<typeof spyOn>;
210
444
 
211
445
  beforeEach(() => {
212
446
  // @ts-ignore
213
447
  global.fetch = mock();
448
+ lookupSpy = spyOn(dns, 'lookup').mockResolvedValue([
449
+ { address: '93.184.216.34', family: 4 },
450
+ ] as unknown as Awaited<ReturnType<typeof dns.lookup>>);
214
451
  });
215
452
 
216
453
  afterEach(() => {
217
454
  global.fetch = originalFetch;
455
+ lookupSpy.mockRestore();
218
456
  });
219
457
 
220
458
  it('should perform an HTTP request', async () => {
@@ -472,7 +710,7 @@ describe('step-executor', () => {
472
710
  );
473
711
 
474
712
  // @ts-ignore
475
- const result = await executeStep(step, context, undefined, executeWorkflowFn);
713
+ const result = await executeStep(step, context, undefined, { executeWorkflowFn });
476
714
  expect(result.status).toBe('success');
477
715
  expect(result.output).toBe('child-output');
478
716
  expect(executeWorkflowFn).toHaveBeenCalled();
@@ -1,9 +1,11 @@
1
+ import type { MemoryDb } from '../db/memory-db.ts';
1
2
  import type { ExpressionContext } from '../expression/evaluator.ts';
2
3
  import { ExpressionEvaluator } from '../expression/evaluator.ts';
3
4
  // Removed synchronous file I/O imports - using Bun's async file API instead
4
5
  import type {
5
6
  FileStep,
6
7
  HumanStep,
8
+ MemoryStep,
7
9
  RequestStep,
8
10
  ScriptStep,
9
11
  ShellStep,
@@ -11,12 +13,17 @@ import type {
11
13
  Step,
12
14
  WorkflowStep,
13
15
  } from '../parser/schema.ts';
16
+ import { ConsoleLogger, type Logger } from '../utils/logger.ts';
17
+ import { getAdapter } from './llm-adapter.ts';
14
18
  import { detectShellInjectionRisk, executeShell } from './shell-executor.ts';
15
- import type { Logger } from './workflow-runner.ts';
16
19
 
20
+ import * as fs from 'node:fs';
21
+ import * as os from 'node:os';
22
+ import * as path from 'node:path';
17
23
  import * as readline from 'node:readline/promises';
18
24
  import { SafeSandbox } from '../utils/sandbox.ts';
19
25
  import { executeLlmStep } from './llm-executor.ts';
26
+ import { validateRemoteUrl } from './mcp-client.ts';
20
27
  import type { MCPManager } from './mcp-manager.ts';
21
28
 
22
29
  export class WorkflowSuspendedError extends Error {
@@ -41,18 +48,39 @@ export interface StepResult {
41
48
  };
42
49
  }
43
50
 
51
+ /**
52
+ * Execute a single step based on its type
53
+ */
54
+ export interface StepExecutorOptions {
55
+ executeWorkflowFn?: (step: WorkflowStep, context: ExpressionContext) => Promise<StepResult>;
56
+ mcpManager?: MCPManager;
57
+ memoryDb?: MemoryDb;
58
+ workflowDir?: string;
59
+ dryRun?: boolean;
60
+ // Dependency injection for testing
61
+ getAdapter?: typeof getAdapter;
62
+ sandbox?: typeof SafeSandbox;
63
+ }
64
+
44
65
  /**
45
66
  * Execute a single step based on its type
46
67
  */
47
68
  export async function executeStep(
48
69
  step: Step,
49
70
  context: ExpressionContext,
50
- logger: Logger = console,
51
- executeWorkflowFn?: (step: WorkflowStep, context: ExpressionContext) => Promise<StepResult>,
52
- mcpManager?: MCPManager,
53
- workflowDir?: string,
54
- dryRun?: boolean
71
+ logger: Logger = new ConsoleLogger(),
72
+ options: StepExecutorOptions = {}
55
73
  ): Promise<StepResult> {
74
+ const {
75
+ executeWorkflowFn,
76
+ mcpManager,
77
+ memoryDb,
78
+ workflowDir,
79
+ dryRun,
80
+ getAdapter: injectedGetAdapter,
81
+ sandbox: injectedSandbox,
82
+ } = options;
83
+
56
84
  try {
57
85
  let result: StepResult;
58
86
  switch (step.type) {
@@ -75,12 +103,15 @@ export async function executeStep(
75
103
  result = await executeLlmStep(
76
104
  step,
77
105
  context,
78
- (s, c) => executeStep(s, c, logger, executeWorkflowFn, mcpManager, workflowDir, dryRun),
106
+ (s, c) => executeStep(s, c, logger, options),
79
107
  logger,
80
108
  mcpManager,
81
109
  workflowDir
82
110
  );
83
111
  break;
112
+ case 'memory':
113
+ result = await executeMemoryStep(step, context, logger, memoryDb, injectedGetAdapter);
114
+ break;
84
115
  case 'workflow':
85
116
  if (!executeWorkflowFn) {
86
117
  throw new Error('Workflow executor not provided');
@@ -88,7 +119,7 @@ export async function executeStep(
88
119
  result = await executeWorkflowFn(step, context);
89
120
  break;
90
121
  case 'script':
91
- result = await executeScriptStep(step, context, logger);
122
+ result = await executeScriptStep(step, context, logger, injectedSandbox);
92
123
  break;
93
124
  default:
94
125
  throw new Error(`Unknown step type: ${(step as Step).type}`);
@@ -150,44 +181,10 @@ async function executeShellStep(
150
181
  const command = ExpressionEvaluator.evaluateString(step.run, context);
151
182
  const isRisky = detectShellInjectionRisk(command);
152
183
 
153
- if (isRisky) {
154
- // Check if we have a resume approval
155
- const stepInputs = context.inputs
156
- ? (context.inputs as Record<string, unknown>)[step.id]
157
- : undefined;
158
- if (
159
- stepInputs &&
160
- typeof stepInputs === 'object' &&
161
- '__approved' in stepInputs &&
162
- stepInputs.__approved === true
163
- ) {
164
- // Already approved, proceed
165
- } else {
166
- const message = `Potentially risky shell command detected: ${command}`;
167
-
168
- if (!process.stdin.isTTY) {
169
- return {
170
- output: null,
171
- status: 'suspended',
172
- error: `APPROVAL_REQUIRED: ${message}`,
173
- };
174
- }
175
-
176
- const rl = readline.createInterface({
177
- input: process.stdin,
178
- output: process.stdout,
179
- });
180
-
181
- try {
182
- logger.warn(`\n⚠️ ${message}`);
183
- const answer = (await rl.question('Do you want to execute this command? (y/N): ')).trim();
184
- if (answer.toLowerCase() !== 'y' && answer.toLowerCase() !== 'yes') {
185
- throw new Error('Command execution denied by user');
186
- }
187
- } finally {
188
- rl.close();
189
- }
190
- }
184
+ if (isRisky && !step.allowInsecure) {
185
+ throw new Error(
186
+ `Security Error: Command contains shell metacharacters that may indicate injection risk.\n Command: ${command.substring(0, 100)}${command.length > 100 ? '...' : ''}\n To execute this command, set 'allowInsecure: true' on the step definition.`
187
+ );
191
188
  }
192
189
 
193
190
  const result = await executeShell(step, context, logger);
@@ -227,22 +224,62 @@ async function executeFileStep(
227
224
  _logger: Logger,
228
225
  dryRun?: boolean
229
226
  ): Promise<StepResult> {
230
- const path = ExpressionEvaluator.evaluateString(step.path, context);
227
+ const rawPath = ExpressionEvaluator.evaluateString(step.path, context);
228
+
229
+ // Security: Prevent path traversal
230
+ const cwd = process.cwd();
231
+ const resolvedPath = path.resolve(cwd, rawPath);
232
+ const realCwd = fs.realpathSync(cwd);
233
+ const isWithin = (target: string) => {
234
+ const relativePath = path.relative(realCwd, target);
235
+ return !(relativePath.startsWith('..') || path.isAbsolute(relativePath));
236
+ };
237
+ const getExistingAncestorRealPath = (start: string) => {
238
+ let current = start;
239
+ while (!fs.existsSync(current)) {
240
+ const parent = path.dirname(current);
241
+ if (parent === current) {
242
+ break;
243
+ }
244
+ current = parent;
245
+ }
246
+ if (!fs.existsSync(current)) {
247
+ return realCwd;
248
+ }
249
+ return fs.realpathSync(current);
250
+ };
251
+
252
+ if (!step.allowOutsideCwd) {
253
+ if (fs.existsSync(resolvedPath)) {
254
+ const realTarget = fs.realpathSync(resolvedPath);
255
+ if (!isWithin(realTarget)) {
256
+ throw new Error(`Access denied: Path '${rawPath}' resolves outside the working directory.`);
257
+ }
258
+ } else {
259
+ const realParent = getExistingAncestorRealPath(path.dirname(resolvedPath));
260
+ if (!isWithin(realParent)) {
261
+ throw new Error(`Access denied: Path '${rawPath}' resolves outside the working directory.`);
262
+ }
263
+ }
264
+ }
265
+
266
+ // Use resolved path for operations
267
+ const targetPath = resolvedPath;
231
268
 
232
269
  if (dryRun && step.op !== 'read') {
233
270
  const opVerb = step.op === 'write' ? 'write to' : 'append to';
234
- _logger.log(`[DRY RUN] Would ${opVerb} file: ${path}`);
271
+ _logger.log(`[DRY RUN] Would ${opVerb} file: ${targetPath}`);
235
272
  return {
236
- output: { path, bytes: 0 },
273
+ output: { path: targetPath, bytes: 0 },
237
274
  status: 'success',
238
275
  };
239
276
  }
240
277
 
241
278
  switch (step.op) {
242
279
  case 'read': {
243
- const file = Bun.file(path);
280
+ const file = Bun.file(targetPath);
244
281
  if (!(await file.exists())) {
245
- throw new Error(`File not found: ${path}`);
282
+ throw new Error(`File not found: ${targetPath}`);
246
283
  }
247
284
  const content = await file.text();
248
285
  return {
@@ -258,14 +295,14 @@ async function executeFileStep(
258
295
  const content = ExpressionEvaluator.evaluateString(step.content, context);
259
296
 
260
297
  // Ensure parent directory exists
261
- const fs = await import('node:fs/promises');
262
- const pathModule = await import('node:path');
263
- const dir = pathModule.dirname(path);
264
- await fs.mkdir(dir, { recursive: true });
298
+ const dir = path.dirname(targetPath);
299
+ if (!fs.existsSync(dir)) {
300
+ fs.mkdirSync(dir, { recursive: true });
301
+ }
265
302
 
266
- const bytes = await Bun.write(path, content);
303
+ await Bun.write(targetPath, content);
267
304
  return {
268
- output: { path, bytes },
305
+ output: { path: targetPath, bytes: content.length },
269
306
  status: 'success',
270
307
  };
271
308
  }
@@ -277,16 +314,15 @@ async function executeFileStep(
277
314
  const content = ExpressionEvaluator.evaluateString(step.content, context);
278
315
 
279
316
  // Ensure parent directory exists
280
- const fs = await import('node:fs/promises');
281
- const pathModule = await import('node:path');
282
- const dir = pathModule.dirname(path);
283
- await fs.mkdir(dir, { recursive: true });
317
+ const dir = path.dirname(targetPath);
318
+ if (!fs.existsSync(dir)) {
319
+ fs.mkdirSync(dir, { recursive: true });
320
+ }
284
321
 
285
- // Use Node.js fs for efficient append operation
286
- await fs.appendFile(path, content, 'utf-8');
322
+ fs.appendFileSync(targetPath, content);
287
323
 
288
324
  return {
289
- output: { path, bytes: content.length },
325
+ output: { path: targetPath, bytes: content.length },
290
326
  status: 'success',
291
327
  };
292
328
  }
@@ -306,6 +342,9 @@ async function executeRequestStep(
306
342
  ): Promise<StepResult> {
307
343
  const url = ExpressionEvaluator.evaluateString(step.url, context);
308
344
 
345
+ // Validate URL to prevent SSRF
346
+ await validateRemoteUrl(url);
347
+
309
348
  // Evaluate headers
310
349
  const headers: Record<string, string> = {};
311
350
  if (step.headers) {
@@ -363,7 +402,7 @@ async function executeRequestStep(
363
402
  output: {
364
403
  status: response.status,
365
404
  statusText: response.statusText,
366
- headers: Object.fromEntries(response.headers.entries()),
405
+ headers: Object.fromEntries(response.headers as unknown as Iterable<[string, string]>),
367
406
  data: responseData,
368
407
  },
369
408
  status: response.ok ? 'success' : 'failed',
@@ -483,10 +522,18 @@ async function executeSleepStep(
483
522
  async function executeScriptStep(
484
523
  step: ScriptStep,
485
524
  context: ExpressionContext,
486
- _logger: Logger
525
+ _logger: Logger,
526
+ sandbox = SafeSandbox
487
527
  ): Promise<StepResult> {
488
528
  try {
489
- const result = await SafeSandbox.execute(
529
+ if (!step.allowInsecure) {
530
+ throw new Error(
531
+ 'Script execution is disabled by default because Bun uses an insecure VM sandbox. ' +
532
+ "Set 'allowInsecure: true' on the script step to run it anyway."
533
+ );
534
+ }
535
+
536
+ const result = await sandbox.execute(
490
537
  step.run,
491
538
  {
492
539
  inputs: context.inputs,
@@ -495,7 +542,7 @@ async function executeScriptStep(
495
542
  env: context.env,
496
543
  },
497
544
  {
498
- allowInsecureFallback: step.allowInsecure,
545
+ timeout: step.timeout,
499
546
  }
500
547
  );
501
548
 
@@ -511,3 +558,71 @@ async function executeScriptStep(
511
558
  };
512
559
  }
513
560
  }
561
+
562
+ /**
563
+ * Execute a memory operation (search or store)
564
+ */
565
+ async function executeMemoryStep(
566
+ step: MemoryStep,
567
+ context: ExpressionContext,
568
+ logger: Logger,
569
+ memoryDb?: MemoryDb,
570
+ getAdapterFn = getAdapter
571
+ ): Promise<StepResult> {
572
+ if (!memoryDb) {
573
+ throw new Error('Memory database not initialized');
574
+ }
575
+
576
+ try {
577
+ const { adapter, resolvedModel } = getAdapterFn(step.model || 'local');
578
+ if (!adapter.embed) {
579
+ throw new Error(`Provider for model ${step.model || 'local'} does not support embeddings`);
580
+ }
581
+
582
+ if (step.op === 'store') {
583
+ const text = step.text ? ExpressionEvaluator.evaluateString(step.text, context) : '';
584
+ if (!text) {
585
+ throw new Error('Text is required for memory store operation');
586
+ }
587
+
588
+ logger.log(
589
+ ` 💾 Storing in memory: ${text.substring(0, 50)}${text.length > 50 ? '...' : ''}`
590
+ );
591
+ const embedding = await adapter.embed(text, resolvedModel);
592
+ const metadata = step.metadata
593
+ ? // biome-ignore lint/suspicious/noExplicitAny: metadata typing
594
+ (ExpressionEvaluator.evaluateObject(step.metadata, context) as Record<string, any>)
595
+ : {};
596
+
597
+ const id = await memoryDb.store(text, embedding, metadata);
598
+ return {
599
+ output: { id, status: 'stored' },
600
+ status: 'success',
601
+ };
602
+ }
603
+
604
+ if (step.op === 'search') {
605
+ const query = step.query ? ExpressionEvaluator.evaluateString(step.query, context) : '';
606
+ if (!query) {
607
+ throw new Error('Query is required for memory search operation');
608
+ }
609
+
610
+ logger.log(` 🔍 Recalling memory: "${query}"`);
611
+ const embedding = await adapter.embed(query, resolvedModel);
612
+ const results = await memoryDb.search(embedding, step.limit);
613
+
614
+ return {
615
+ output: results,
616
+ status: 'success',
617
+ };
618
+ }
619
+
620
+ throw new Error(`Unknown memory operation: ${step.op}`);
621
+ } catch (error) {
622
+ return {
623
+ output: null,
624
+ status: 'failed',
625
+ error: error instanceof Error ? error.message : String(error),
626
+ };
627
+ }
628
+ }