keystone-cli 0.6.0 → 0.7.0

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.
@@ -0,0 +1,270 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { ExpressionEvaluator } from '../expression/evaluator';
4
+ import type { AgentTool, Step } from '../parser/schema';
5
+ import { detectShellInjectionRisk } from './shell-executor';
6
+
7
+ export const STANDARD_TOOLS: AgentTool[] = [
8
+ {
9
+ name: 'read_file',
10
+ description: 'Read the contents of a file',
11
+ parameters: {
12
+ type: 'object',
13
+ properties: {
14
+ path: { type: 'string', description: 'Path to the file to read' },
15
+ },
16
+ required: ['path'],
17
+ },
18
+ execution: {
19
+ id: 'std_read_file',
20
+ type: 'file',
21
+ op: 'read',
22
+ path: '${{ args.path }}',
23
+ },
24
+ },
25
+ {
26
+ name: 'read_file_lines',
27
+ description: 'Read a specific range of lines from a file',
28
+ parameters: {
29
+ type: 'object',
30
+ properties: {
31
+ path: { type: 'string', description: 'Path to the file to read' },
32
+ start: { type: 'number', description: 'Starting line number (1-indexed)', default: 1 },
33
+ count: { type: 'number', description: 'Number of lines to read', default: 100 },
34
+ },
35
+ required: ['path'],
36
+ },
37
+ execution: {
38
+ id: 'std_read_file_lines',
39
+ type: 'script',
40
+ run: `
41
+ const fs = require('node:fs');
42
+ const path = require('node:path');
43
+ const filePath = args.path;
44
+ const start = args.start || 1;
45
+ const count = args.count || 100;
46
+
47
+ if (!fs.existsSync(filePath)) {
48
+ throw new Error('File not found: ' + filePath);
49
+ }
50
+
51
+ const content = fs.readFileSync(filePath, 'utf8');
52
+ const lines = content.split('\\n');
53
+ return lines.slice(start - 1, start - 1 + count).join('\\n');
54
+ `,
55
+ allowInsecure: true,
56
+ },
57
+ },
58
+ {
59
+ name: 'write_file',
60
+ description: 'Write or overwrite a file with content',
61
+ parameters: {
62
+ type: 'object',
63
+ properties: {
64
+ path: { type: 'string', description: 'Path to the file to write' },
65
+ content: { type: 'string', description: 'Content to write to the file' },
66
+ },
67
+ required: ['path', 'content'],
68
+ },
69
+ execution: {
70
+ id: 'std_write_file',
71
+ type: 'file',
72
+ op: 'write',
73
+ path: '${{ args.path }}',
74
+ content: '${{ args.content }}',
75
+ },
76
+ },
77
+ {
78
+ name: 'list_files',
79
+ description: 'List files in a directory',
80
+ parameters: {
81
+ type: 'object',
82
+ properties: {
83
+ path: {
84
+ type: 'string',
85
+ description: 'Directory path (defaults to current directory)',
86
+ default: '.',
87
+ },
88
+ },
89
+ },
90
+ execution: {
91
+ id: 'std_list_files',
92
+ type: 'script',
93
+ run: `
94
+ const fs = require('node:fs');
95
+ const path = require('node:path');
96
+ const dir = args.path || '.';
97
+ if (fs.existsSync(dir)) {
98
+ const files = fs.readdirSync(dir, { withFileTypes: true });
99
+ return files.map(f => ({
100
+ name: f.name,
101
+ type: f.isDirectory() ? 'directory' : 'file',
102
+ size: f.isFile() ? fs.statSync(path.join(dir, f.name)).size : undefined
103
+ }));
104
+ }
105
+ throw new Error('Directory not found: ' + dir);
106
+ `,
107
+ allowInsecure: true,
108
+ },
109
+ },
110
+ {
111
+ name: 'search_files',
112
+ description: 'Search for files by pattern (glob)',
113
+ parameters: {
114
+ type: 'object',
115
+ properties: {
116
+ pattern: { type: 'string', description: 'Glob pattern (e.g. **/*.ts)' },
117
+ dir: { type: 'string', description: 'Directory to search in', default: '.' },
118
+ },
119
+ required: ['pattern'],
120
+ },
121
+ execution: {
122
+ id: 'std_search_files',
123
+ type: 'script',
124
+ run: `
125
+ const fs = require('node:fs');
126
+ const path = require('node:path');
127
+ const { globSync } = require('glob');
128
+ const dir = args.dir || '.';
129
+ const pattern = args.pattern;
130
+ try {
131
+ return globSync(pattern, { cwd: dir, nodir: true });
132
+ } catch (e) {
133
+ throw new Error('Search failed: ' + e.message);
134
+ }
135
+ `,
136
+ allowInsecure: true,
137
+ },
138
+ },
139
+ {
140
+ name: 'search_content',
141
+ description: 'Search for a string or regex within files',
142
+ parameters: {
143
+ type: 'object',
144
+ properties: {
145
+ query: { type: 'string', description: 'String or regex to search for' },
146
+ pattern: {
147
+ type: 'string',
148
+ description: 'Glob pattern of files to search in',
149
+ default: '**/*',
150
+ },
151
+ dir: { type: 'string', description: 'Directory to search in', default: '.' },
152
+ },
153
+ required: ['query'],
154
+ },
155
+ execution: {
156
+ id: 'std_search_content',
157
+ type: 'script',
158
+ run: `
159
+ const fs = require('node:fs');
160
+ const path = require('node:path');
161
+ const { globSync } = require('glob');
162
+ const dir = args.dir || '.';
163
+ const pattern = args.pattern || '**/*';
164
+ const query = args.query;
165
+ if (query.length > 500) {
166
+ throw new Error('Search query exceeds maximum length of 500 characters');
167
+ }
168
+ const isRegex = query.startsWith('/') && query.endsWith('/');
169
+ let regex;
170
+ try {
171
+ regex = isRegex ? new RegExp(query.slice(1, -1)) : new RegExp(query.replace(/[.*+?^$\\{}()|[\\]\\\\]/g, '\\\\$&'), 'i');
172
+ } catch (e) {
173
+ throw new Error('Invalid regular expression: ' + e.message);
174
+ }
175
+
176
+ const files = globSync(pattern, { cwd: dir, nodir: true });
177
+ const results = [];
178
+ for (const file of files) {
179
+ const fullPath = path.join(dir, file);
180
+ const content = fs.readFileSync(fullPath, 'utf8');
181
+ const lines = content.split('\\n');
182
+ for (let i = 0; i < lines.length; i++) {
183
+ if (regex.test(lines[i])) {
184
+ results.push({
185
+ file,
186
+ line: i + 1,
187
+ content: lines[i].trim()
188
+ });
189
+ }
190
+ if (results.length > 100) break; // Limit results
191
+ }
192
+ if (results.length > 100) break;
193
+ }
194
+ return results;
195
+ `,
196
+ allowInsecure: true,
197
+ },
198
+ },
199
+ {
200
+ name: 'run_command',
201
+ description: 'Run a shell command',
202
+ parameters: {
203
+ type: 'object',
204
+ properties: {
205
+ command: { type: 'string', description: 'The shell command to run' },
206
+ dir: { type: 'string', description: 'Working directory for the command' },
207
+ },
208
+ required: ['command'],
209
+ },
210
+ execution: {
211
+ id: 'std_run_command',
212
+ type: 'shell',
213
+ run: '${{ args.command }}',
214
+ dir: '${{ args.dir }}',
215
+ },
216
+ },
217
+ ];
218
+
219
+ /**
220
+ * Validate that a tool call is safe to execute based on the LLM step's security flags.
221
+ */
222
+ export function validateStandardToolSecurity(
223
+ toolName: string,
224
+ // biome-ignore lint/suspicious/noExplicitAny: arguments can be any shape
225
+ args: any,
226
+ options: { allowOutsideCwd?: boolean; allowInsecure?: boolean }
227
+ ): void {
228
+ // 1. Check path traversal for file tools
229
+ if (
230
+ [
231
+ 'read_file',
232
+ 'read_file_lines',
233
+ 'write_file',
234
+ 'list_files',
235
+ 'search_files',
236
+ 'search_content',
237
+ ].includes(toolName)
238
+ ) {
239
+ const rawPath = args.path || args.dir || '.';
240
+ const cwd = process.cwd();
241
+ const resolvedPath = path.resolve(cwd, rawPath);
242
+ const realCwd = fs.realpathSync(cwd);
243
+
244
+ const isWithin = (target: string) => {
245
+ // Find the first existing ancestor to resolve the real path correctly
246
+ let current = target;
247
+ while (current !== path.dirname(current) && !fs.existsSync(current)) {
248
+ current = path.dirname(current);
249
+ }
250
+ const realTarget = fs.existsSync(current) ? fs.realpathSync(current) : current;
251
+ const relativePath = path.relative(realCwd, realTarget);
252
+ return !(relativePath.startsWith('..') || path.isAbsolute(relativePath));
253
+ };
254
+
255
+ if (!options.allowOutsideCwd && !isWithin(resolvedPath)) {
256
+ throw new Error(
257
+ `Access denied: Path '${rawPath}' resolves outside the working directory. Use 'allowOutsideCwd: true' to override.`
258
+ );
259
+ }
260
+ }
261
+
262
+ // 2. Check shell risk for run_command
263
+ if (toolName === 'run_command' && !options.allowInsecure) {
264
+ if (detectShellInjectionRisk(args.command)) {
265
+ throw new Error(
266
+ `Security Error: Command contains risky shell characters. Use 'allowInsecure: true' on the llm step to execute this.`
267
+ );
268
+ }
269
+ }
270
+ }
@@ -13,6 +13,7 @@ import * as dns from 'node:dns/promises';
13
13
  import { mkdirSync, rmSync } from 'node:fs';
14
14
  import { tmpdir } from 'node:os';
15
15
  import { join } from 'node:path';
16
+ import type { MemoryDb } from '../db/memory-db';
16
17
  import type { ExpressionContext } from '../expression/evaluator';
17
18
  import type {
18
19
  FileStep,
@@ -22,6 +23,8 @@ import type {
22
23
  SleepStep,
23
24
  WorkflowStep,
24
25
  } from '../parser/schema';
26
+ import type { SafeSandbox } from '../utils/sandbox';
27
+ import type { getAdapter } from './llm-adapter';
25
28
  import { executeStep } from './step-executor';
26
29
 
27
30
  // Mock executeLlmStep
@@ -227,6 +230,196 @@ describe('step-executor', () => {
227
230
  }
228
231
  }
229
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
+ });
230
423
  });
231
424
 
232
425
  describe('sleep', () => {
@@ -517,7 +710,7 @@ describe('step-executor', () => {
517
710
  );
518
711
 
519
712
  // @ts-ignore
520
- const result = await executeStep(step, context, undefined, executeWorkflowFn);
713
+ const result = await executeStep(step, context, undefined, { executeWorkflowFn });
521
714
  expect(result.status).toBe('success');
522
715
  expect(result.output).toBe('child-output');
523
716
  expect(executeWorkflowFn).toHaveBeenCalled();
@@ -48,6 +48,20 @@ export interface StepResult {
48
48
  };
49
49
  }
50
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
+
51
65
  /**
52
66
  * Execute a single step based on its type
53
67
  */
@@ -55,12 +69,18 @@ export async function executeStep(
55
69
  step: Step,
56
70
  context: ExpressionContext,
57
71
  logger: Logger = new ConsoleLogger(),
58
- executeWorkflowFn?: (step: WorkflowStep, context: ExpressionContext) => Promise<StepResult>,
59
- mcpManager?: MCPManager,
60
- memoryDb?: MemoryDb,
61
- workflowDir?: string,
62
- dryRun?: boolean
72
+ options: StepExecutorOptions = {}
63
73
  ): Promise<StepResult> {
74
+ const {
75
+ executeWorkflowFn,
76
+ mcpManager,
77
+ memoryDb,
78
+ workflowDir,
79
+ dryRun,
80
+ getAdapter: injectedGetAdapter,
81
+ sandbox: injectedSandbox,
82
+ } = options;
83
+
64
84
  try {
65
85
  let result: StepResult;
66
86
  switch (step.type) {
@@ -83,15 +103,14 @@ export async function executeStep(
83
103
  result = await executeLlmStep(
84
104
  step,
85
105
  context,
86
- (s, c) =>
87
- executeStep(s, c, logger, executeWorkflowFn, mcpManager, memoryDb, workflowDir, dryRun),
106
+ (s, c) => executeStep(s, c, logger, options),
88
107
  logger,
89
108
  mcpManager,
90
109
  workflowDir
91
110
  );
92
111
  break;
93
112
  case 'memory':
94
- result = await executeMemoryStep(step, context, logger, memoryDb);
113
+ result = await executeMemoryStep(step, context, logger, memoryDb, injectedGetAdapter);
95
114
  break;
96
115
  case 'workflow':
97
116
  if (!executeWorkflowFn) {
@@ -100,7 +119,7 @@ export async function executeStep(
100
119
  result = await executeWorkflowFn(step, context);
101
120
  break;
102
121
  case 'script':
103
- result = await executeScriptStep(step, context, logger);
122
+ result = await executeScriptStep(step, context, logger, injectedSandbox);
104
123
  break;
105
124
  default:
106
125
  throw new Error(`Unknown step type: ${(step as Step).type}`);
@@ -383,7 +402,13 @@ async function executeRequestStep(
383
402
  output: {
384
403
  status: response.status,
385
404
  statusText: response.statusText,
386
- headers: Object.fromEntries(response.headers.entries()),
405
+ headers: (() => {
406
+ const h: Record<string, string> = {};
407
+ response.headers.forEach((v, k) => {
408
+ h[k] = v;
409
+ });
410
+ return h;
411
+ })(),
387
412
  data: responseData,
388
413
  },
389
414
  status: response.ok ? 'success' : 'failed',
@@ -416,7 +441,11 @@ async function executeHumanStep(
416
441
  return {
417
442
  output:
418
443
  step.inputType === 'confirm'
419
- ? answer === true || answer === 'true' || answer === 'yes' || answer === 'y'
444
+ ? answer === true ||
445
+ (typeof answer === 'string' &&
446
+ (answer.toLowerCase() === 'true' ||
447
+ answer.toLowerCase() === 'yes' ||
448
+ answer.toLowerCase() === 'y'))
420
449
  : answer,
421
450
  status: 'success',
422
451
  };
@@ -503,7 +532,8 @@ async function executeSleepStep(
503
532
  async function executeScriptStep(
504
533
  step: ScriptStep,
505
534
  context: ExpressionContext,
506
- _logger: Logger
535
+ _logger: Logger,
536
+ sandbox = SafeSandbox
507
537
  ): Promise<StepResult> {
508
538
  try {
509
539
  if (!step.allowInsecure) {
@@ -513,7 +543,7 @@ async function executeScriptStep(
513
543
  );
514
544
  }
515
545
 
516
- const result = await SafeSandbox.execute(
546
+ const result = await sandbox.execute(
517
547
  step.run,
518
548
  {
519
549
  inputs: context.inputs,
@@ -546,14 +576,15 @@ async function executeMemoryStep(
546
576
  step: MemoryStep,
547
577
  context: ExpressionContext,
548
578
  logger: Logger,
549
- memoryDb?: MemoryDb
579
+ memoryDb?: MemoryDb,
580
+ getAdapterFn = getAdapter
550
581
  ): Promise<StepResult> {
551
582
  if (!memoryDb) {
552
583
  throw new Error('Memory database not initialized');
553
584
  }
554
585
 
555
586
  try {
556
- const { adapter, resolvedModel } = getAdapter(step.model || 'local');
587
+ const { adapter, resolvedModel } = getAdapterFn(step.model || 'local');
557
588
  if (!adapter.embed) {
558
589
  throw new Error(`Provider for model ${step.model || 'local'} does not support embeddings`);
559
590
  }