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.
- package/README.md +55 -8
- package/package.json +8 -17
- package/src/cli.ts +219 -166
- package/src/db/memory-db.test.ts +54 -0
- package/src/db/memory-db.ts +128 -0
- package/src/db/sqlite-setup.test.ts +47 -0
- package/src/db/sqlite-setup.ts +49 -0
- package/src/db/workflow-db.test.ts +41 -10
- package/src/db/workflow-db.ts +90 -28
- package/src/expression/evaluator.test.ts +19 -0
- package/src/expression/evaluator.ts +134 -39
- package/src/parser/schema.ts +41 -0
- package/src/runner/audit-verification.test.ts +23 -0
- package/src/runner/auto-heal.test.ts +64 -0
- package/src/runner/debug-repl.test.ts +308 -0
- package/src/runner/debug-repl.ts +225 -0
- package/src/runner/foreach-executor.ts +327 -0
- package/src/runner/llm-adapter.test.ts +37 -18
- package/src/runner/llm-adapter.ts +90 -112
- package/src/runner/llm-executor.test.ts +47 -6
- package/src/runner/llm-executor.ts +18 -3
- package/src/runner/mcp-client.audit.test.ts +69 -0
- package/src/runner/mcp-client.test.ts +12 -3
- package/src/runner/mcp-client.ts +199 -19
- package/src/runner/mcp-manager.ts +19 -8
- package/src/runner/mcp-server.test.ts +8 -5
- package/src/runner/mcp-server.ts +31 -17
- package/src/runner/optimization-runner.ts +305 -0
- package/src/runner/reflexion.test.ts +87 -0
- package/src/runner/shell-executor.test.ts +12 -0
- package/src/runner/shell-executor.ts +9 -6
- package/src/runner/step-executor.test.ts +240 -2
- package/src/runner/step-executor.ts +183 -68
- package/src/runner/stream-utils.test.ts +171 -0
- package/src/runner/stream-utils.ts +186 -0
- package/src/runner/workflow-runner.test.ts +4 -4
- package/src/runner/workflow-runner.ts +438 -259
- package/src/templates/agents/keystone-architect.md +6 -4
- package/src/templates/full-feature-demo.yaml +4 -4
- package/src/types/assets.d.ts +14 -0
- package/src/types/status.ts +1 -1
- package/src/ui/dashboard.tsx +38 -26
- package/src/utils/auth-manager.ts +3 -1
- package/src/utils/logger.test.ts +76 -0
- package/src/utils/logger.ts +39 -0
- package/src/utils/prompt.ts +75 -0
- package/src/utils/redactor.test.ts +86 -4
- package/src/utils/redactor.ts +48 -13
|
@@ -1,6 +1,19 @@
|
|
|
1
|
-
import {
|
|
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 =
|
|
51
|
-
|
|
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,
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
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: ${
|
|
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(
|
|
280
|
+
const file = Bun.file(targetPath);
|
|
244
281
|
if (!(await file.exists())) {
|
|
245
|
-
throw new Error(`File not found: ${
|
|
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
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
298
|
+
const dir = path.dirname(targetPath);
|
|
299
|
+
if (!fs.existsSync(dir)) {
|
|
300
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
301
|
+
}
|
|
265
302
|
|
|
266
|
-
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
317
|
+
const dir = path.dirname(targetPath);
|
|
318
|
+
if (!fs.existsSync(dir)) {
|
|
319
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
320
|
+
}
|
|
284
321
|
|
|
285
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|