keystone-cli 0.4.4 → 0.5.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 CHANGED
@@ -247,6 +247,17 @@ steps:
247
247
  Result: ${{ steps.build.output }}
248
248
  Please write a concise 1-sentence summary for Slack.
249
249
 
250
+ - id: cleanup
251
+ type: shell
252
+ # Run whether previous steps succeeded or failed
253
+ if: true
254
+ run: rm -rf ./temp_build
255
+
256
+ finally:
257
+ - id: final_cleanup
258
+ type: shell
259
+ run: echo "Workflow finished"
260
+
250
261
  outputs:
251
262
  slack_message: ${{ steps.notify.output }}
252
263
  ```
@@ -260,16 +271,20 @@ Keystone supports several specialized step types:
260
271
  - `shell`: Run arbitrary shell commands.
261
272
  - `llm`: Prompt an agent and get structured or unstructured responses. Supports `schema` (JSON Schema) for structured output.
262
273
  - `allowClarification`: Boolean (default `false`). If `true`, allows the LLM to ask clarifying questions back to the user or suspend the workflow if no human is available.
274
+ - `maxIterations`: Number (default `10`). Maximum number of tool-calling loops allowed for the agent.
263
275
  - `request`: Make HTTP requests (GET, POST, etc.).
264
276
  - `file`: Read, write, or append to files.
265
277
  - `human`: Pause execution for manual confirmation or text input.
266
278
  - `inputType: confirm`: Simple Enter-to-continue prompt.
267
279
  - `inputType: text`: Prompt for a string input, available via `${{ steps.id.output }}`.
268
280
  - `workflow`: Trigger another workflow as a sub-step.
269
- - `script`: Run arbitrary JavaScript in a secure sandbox (`isolated-vm` with fallback to `node:vm`).
281
+ - `script`: Run arbitrary JavaScript in a sandbox. On Bun, uses `node:vm` (since `isolated-vm` requires V8).
282
+ - ⚠️ **Security Note:** The `node:vm` sandbox is not secure against malicious code. Only run scripts from trusted sources.
270
283
  - `sleep`: Pause execution for a specified duration.
271
284
 
272
- All steps support common features like `needs` (dependencies), `if` (conditionals), `retry`, `timeout`, `foreach` (parallel iteration), and `transform` (post-process output using expressions).
285
+ All steps support common features like `needs` (dependencies), `if` (conditionals), `retry`, `timeout`, `foreach` (parallel iteration), `concurrency` (max parallel items for foreach), and `transform` (post-process output using expressions).
286
+
287
+ Workflows also support a top-level `concurrency` field to limit how many steps can run in parallel across the entire workflow.
273
288
 
274
289
  #### Example: Transform & Foreach Concurrency
275
290
  ```yaml
@@ -284,6 +299,15 @@ All steps support common features like `needs` (dependencies), `if` (conditional
284
299
  foreach: ${{ steps.list_files.output }}
285
300
  concurrency: 5 # Process 5 files at a time
286
301
  run: echo "Processing ${{ item }}"
302
+
303
+ #### Example: Script Step
304
+ ```yaml
305
+ - id: calculate
306
+ type: script
307
+ run: |
308
+ const data = context.steps.fetch_data.output;
309
+ return data.map(i => i.value * 2).reduce((a, b) => a + b, 0);
310
+ ```
287
311
  ```
288
312
 
289
313
  ---
@@ -368,6 +392,7 @@ mcp_servers:
368
392
  type: local
369
393
  command: npx
370
394
  args: ["-y", "mcp-remote", "https://mcp.atlassian.com/v1/sse"]
395
+ timeout: 60000 # Optional connection timeout in ms
371
396
  oauth:
372
397
  scope: tools:read
373
398
  ```
@@ -402,12 +427,12 @@ In these examples, the agent will have access to all tools provided by the MCP s
402
427
  | Command | Description |
403
428
  | :--- | :--- |
404
429
  | `init` | Initialize a new Keystone project |
405
- | `run <workflow>` | Execute a workflow (use `-i key=val` for inputs) |
430
+ | `run <workflow>` | Execute a workflow (use `-i key=val` for inputs, `--dry-run` to test) |
406
431
  | `resume <run_id>` | Resume a failed or paused workflow |
407
432
  | `validate [path]` | Check workflow files for errors |
408
433
  | `workflows` | List available workflows |
409
434
  | `history` | Show recent workflow runs |
410
- | `logs <run_id>` | View logs and step status for a specific run |
435
+ | `logs <run_id>` | View logs, outputs, and errors for a specific run (`-v` for full output) |
411
436
  | `graph <workflow>` | Generate a Mermaid diagram of the workflow |
412
437
  | `config` | Show current configuration and providers |
413
438
  | `auth status [provider]` | Show authentication status |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keystone-cli",
3
- "version": "0.4.4",
3
+ "version": "0.5.1",
4
4
  "description": "A local-first, declarative, agentic workflow orchestrator built on Bun",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,7 +42,6 @@
42
42
  "ink": "^6.5.1",
43
43
  "ink-select-input": "3.1.2",
44
44
  "ink-spinner": "^5.0.0",
45
- "isolated-vm": "^6.0.2",
46
45
  "js-yaml": "^4.1.0",
47
46
  "jsep": "^1.4.0",
48
47
  "react": "^19.2.3",
package/src/cli.ts CHANGED
@@ -225,6 +225,7 @@ program
225
225
  .description('Execute a workflow')
226
226
  .argument('<workflow>', 'Workflow name or path to workflow file')
227
227
  .option('-i, --input <key=value...>', 'Input values')
228
+ .option('--dry-run', 'Show what would be executed without actually running it')
228
229
  .action(async (workflowPath, options) => {
229
230
  // Parse inputs
230
231
  const inputs: Record<string, unknown> = {};
@@ -264,7 +265,11 @@ program
264
265
 
265
266
  // Import WorkflowRunner dynamically
266
267
  const { WorkflowRunner } = await import('./runner/workflow-runner.ts');
267
- const runner = new WorkflowRunner(workflow, { inputs, workflowDir: dirname(resolvedPath) });
268
+ const runner = new WorkflowRunner(workflow, {
269
+ inputs,
270
+ workflowDir: dirname(resolvedPath),
271
+ dryRun: !!options.dryRun,
272
+ });
268
273
 
269
274
  const outputs = await runner.run();
270
275
 
@@ -417,7 +422,8 @@ program
417
422
  .command('logs')
418
423
  .description('Show logs for a workflow run')
419
424
  .argument('<run_id>', 'Run ID')
420
- .action((runId) => {
425
+ .option('-v, --verbose', 'Show full output without truncation')
426
+ .action((runId, options) => {
421
427
  try {
422
428
  const db = new WorkflowDb();
423
429
  const run = db.getRun(runId);
@@ -430,13 +436,67 @@ program
430
436
  console.log(`\n📋 Workflow: ${run.workflow_name}`);
431
437
  console.log(`Status: ${run.status}`);
432
438
  console.log(`Started: ${new Date(run.started_at).toLocaleString()}`);
439
+ if (run.completed_at) {
440
+ console.log(`Completed: ${new Date(run.completed_at).toLocaleString()}`);
441
+ }
442
+ if (run.error) {
443
+ console.log(`\n❌ Error: ${run.error}`);
444
+ }
433
445
 
434
446
  const steps = db.getStepsByRun(runId);
435
447
  if (steps.length > 0) {
436
448
  console.log('\nSteps:');
437
449
  for (const step of steps) {
438
- const status = step.status.toUpperCase().padEnd(10);
439
- console.log(` ${step.step_id.padEnd(20)} ${status}`);
450
+ const statusColors: Record<string, string> = {
451
+ success: '\x1b[32m', // green
452
+ failed: '\x1b[31m', // red
453
+ pending: '\x1b[33m', // yellow
454
+ skipped: '\x1b[90m', // gray
455
+ suspended: '\x1b[35m', // magenta
456
+ };
457
+ const RESET = '\x1b[0m';
458
+ const color = statusColors[step.status] || '';
459
+ const status = `${color}${step.status.toUpperCase().padEnd(10)}${RESET}`;
460
+ const iteration = step.iteration_index !== null ? ` [${step.iteration_index}]` : '';
461
+ console.log(` ${(step.step_id + iteration).padEnd(25)} ${status}`);
462
+
463
+ // Show error if present
464
+ if (step.error) {
465
+ console.log(` ❌ Error: ${step.error}`);
466
+ }
467
+
468
+ // Show output if present
469
+ if (step.output) {
470
+ try {
471
+ const output = JSON.parse(step.output);
472
+ let outputStr = JSON.stringify(output, null, 2);
473
+ if (!options.verbose && outputStr.length > 500) {
474
+ outputStr = `${outputStr.substring(0, 500)}... (use --verbose for full output)`;
475
+ }
476
+ // Indent output
477
+ const indentedOutput = outputStr
478
+ .split('\n')
479
+ .map((line: string) => ` ${line}`)
480
+ .join('\n');
481
+ console.log(` 📤 Output:\n${indentedOutput}`);
482
+ } catch {
483
+ console.log(` 📤 Output: ${step.output.substring(0, 200)}`);
484
+ }
485
+ }
486
+
487
+ // Show usage if present
488
+ if (step.usage) {
489
+ try {
490
+ const usage = JSON.parse(step.usage);
491
+ if (usage.total_tokens) {
492
+ console.log(
493
+ ` 📊 Tokens: ${usage.total_tokens} (prompt: ${usage.prompt_tokens}, completion: ${usage.completion_tokens})`
494
+ );
495
+ }
496
+ } catch {
497
+ // Ignore parse errors
498
+ }
499
+ }
440
500
  }
441
501
  }
442
502
 
@@ -1,7 +1,14 @@
1
1
  import { Database } from 'bun:sqlite';
2
+ import {
3
+ StepStatus as StepStatusConst,
4
+ type StepStatusType,
5
+ WorkflowStatus as WorkflowStatusConst,
6
+ type WorkflowStatusType,
7
+ } from '../types/status';
2
8
 
3
- export type RunStatus = 'pending' | 'running' | 'completed' | 'failed' | 'paused';
4
- export type StepStatus = 'pending' | 'running' | 'success' | 'failed' | 'skipped' | 'suspended';
9
+ // Re-export for backward compatibility - these map to the database column values
10
+ export type RunStatus = WorkflowStatusType | 'pending' | 'completed';
11
+ export type StepStatus = StepStatusType;
5
12
 
6
13
  export interface WorkflowRun {
7
14
  id: string;
@@ -35,6 +42,7 @@ export class WorkflowDb {
35
42
  this.db = new Database(dbPath, { create: true });
36
43
  this.db.exec('PRAGMA journal_mode = WAL;'); // Write-ahead logging
37
44
  this.db.exec('PRAGMA foreign_keys = ON;'); // Enable foreign key enforcement
45
+ this.db.exec('PRAGMA busy_timeout = 5000;'); // Retry busy signals for up to 5s
38
46
  this.initSchema();
39
47
  }
40
48
 
@@ -58,7 +66,7 @@ export class WorkflowDb {
58
66
  * Retry wrapper for SQLite operations that may encounter SQLITE_BUSY errors
59
67
  * during high concurrency scenarios (e.g., foreach loops)
60
68
  */
61
- private async withRetry<T>(operation: () => T, maxRetries = 5): Promise<T> {
69
+ private async withRetry<T>(operation: () => T, maxRetries = 10): Promise<T> {
62
70
  let lastError: Error | undefined;
63
71
 
64
72
  for (let attempt = 0; attempt < maxRetries; attempt++) {
@@ -68,8 +76,8 @@ export class WorkflowDb {
68
76
  // Check if this is a SQLITE_BUSY error
69
77
  if (this.isSQLiteBusyError(error)) {
70
78
  lastError = error instanceof Error ? error : new Error(String(error));
71
- // Exponential backoff: 10ms, 20ms, 40ms, 80ms, 160ms
72
- const delayMs = 10 * 2 ** attempt;
79
+ // Exponential backoff with jitter: 20ms base
80
+ const delayMs = 20 * 1.5 ** attempt + Math.random() * 20;
73
81
  await Bun.sleep(delayMs);
74
82
  continue;
75
83
  }
@@ -262,13 +270,14 @@ export class WorkflowDb {
262
270
  return stmt.get(runId, stepId, iterationIndex) as StepExecution | null;
263
271
  }
264
272
 
265
- getStepsByRun(runId: string): StepExecution[] {
273
+ getStepsByRun(runId: string, limit = -1, offset = 0): StepExecution[] {
266
274
  const stmt = this.db.prepare(`
267
275
  SELECT * FROM step_executions
268
276
  WHERE run_id = ?
269
277
  ORDER BY started_at ASC, iteration_index ASC, rowid ASC
278
+ LIMIT ? OFFSET ?
270
279
  `);
271
- return stmt.all(runId) as StepExecution[];
280
+ return stmt.all(runId, limit, offset) as StepExecution[];
272
281
  }
273
282
 
274
283
  close(): void {
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { ExpressionEvaluator } from './evaluator';
3
+ import type { ExpressionContext } from './evaluator';
4
+
5
+ describe('ExpressionEvaluator Audit Fixes', () => {
6
+ const context = { inputs: { a: 1 } };
7
+
8
+ it('should use loose equality for ==', () => {
9
+ expect(ExpressionEvaluator.evaluate("${{ 5 == '5' }}", context)).toBe(true);
10
+ expect(ExpressionEvaluator.evaluate("${{ '5' == 5 }}", context)).toBe(true);
11
+ // Strict should still work
12
+ expect(ExpressionEvaluator.evaluate("${{ 5 === '5' }}", context)).toBe(false);
13
+ });
14
+
15
+ it('should use loose inequality for !=', () => {
16
+ expect(ExpressionEvaluator.evaluate("${{ 5 != '5' }}", context)).toBe(false);
17
+ expect(ExpressionEvaluator.evaluate("${{ '5' != 5 }}", context)).toBe(false);
18
+ expect(ExpressionEvaluator.evaluate("${{ 5 != '6' }}", context)).toBe(true);
19
+ // Strict should still work
20
+ expect(ExpressionEvaluator.evaluate("${{ 5 !== '5' }}", context)).toBe(true);
21
+ });
22
+
23
+ it('should block Array constructor', () => {
24
+ expect(() => ExpressionEvaluator.evaluate('${{ Array(10) }}', context)).toThrow();
25
+ });
26
+
27
+ it('should block repeat method', () => {
28
+ expect(() => ExpressionEvaluator.evaluate("${{ 'a'.repeat(10) }}", context)).toThrow();
29
+ });
30
+
31
+ describe('Nesting Support', () => {
32
+ const nestedContext: ExpressionContext = {
33
+ inputs: {
34
+ a: { b: { c: { d: 1 } } },
35
+ arr: [[[1]]],
36
+ },
37
+ };
38
+
39
+ it('should support level 1 nesting', () => {
40
+ // ${{ { a: 1 } }}
41
+ expect(ExpressionEvaluator.evaluate('${{ { x: 1 }.x }}', nestedContext)).toBe(1);
42
+ });
43
+
44
+ it('should support level 2 nesting', () => {
45
+ // ${{ { a: { b: 1 } } }}
46
+ expect(ExpressionEvaluator.evaluate('${{ { x: { y: 1 } }.x.y }}', nestedContext)).toBe(1);
47
+ });
48
+
49
+ it('should support level 3 nesting', () => {
50
+ // ${{ { a: { b: { c: 1 } } } }}
51
+ expect(
52
+ ExpressionEvaluator.evaluate('${{ { x: { y: { z: 1 } } }.x.y.z }}', nestedContext)
53
+ ).toBe(1);
54
+ });
55
+
56
+ it('should support level 3 object access in context', () => {
57
+ expect(ExpressionEvaluator.evaluate('${{ inputs.a.b.c.d }}', nestedContext)).toBe(1);
58
+ });
59
+
60
+ it('should support level 3 array nesting', () => {
61
+ // ${{ [ [ [ 1 ] ] ] }}
62
+ // biome-ignore lint/suspicious/noExplicitAny: generic loose validation for test
63
+ const res = ExpressionEvaluator.evaluate('${{ [ [ [ 1 ] ] ] }}', nestedContext) as any;
64
+ expect(res[0][0][0]).toBe(1);
65
+ });
66
+ });
67
+ });
@@ -74,6 +74,18 @@ describe('ExpressionEvaluator', () => {
74
74
  expect(ExpressionEvaluator.evaluate('${{ 1 >= 1 }}', context)).toBe(true);
75
75
  });
76
76
 
77
+ test('should support loose equality with type coercion', () => {
78
+ // == should perform type coercion (unlike ===)
79
+ expect(ExpressionEvaluator.evaluate('${{ 5 == "5" }}', context)).toBe(true);
80
+ expect(ExpressionEvaluator.evaluate('${{ 5 === "5" }}', context)).toBe(false);
81
+ // != should perform type coercion (unlike !==)
82
+ expect(ExpressionEvaluator.evaluate('${{ 5 != "6" }}', context)).toBe(true);
83
+ expect(ExpressionEvaluator.evaluate('${{ 5 != "5" }}', context)).toBe(false);
84
+ // null == undefined should be true
85
+ const ctxWithNull = { ...context, nullVal: null };
86
+ expect(ExpressionEvaluator.evaluate('${{ nullVal == undefined }}', ctxWithNull)).toBe(true);
87
+ });
88
+
77
89
  test('should support more globals and complex expressions', () => {
78
90
  expect(ExpressionEvaluator.evaluate('${{ Math.max(1, 2) }}', context)).toBe(2);
79
91
  expect(ExpressionEvaluator.evaluate('${{ JSON.stringify({a: 1}) }}', context)).toBe('{"a":1}');
@@ -196,7 +208,7 @@ describe('ExpressionEvaluator', () => {
196
208
 
197
209
  test('should throw error for forbidden properties', () => {
198
210
  expect(() => ExpressionEvaluator.evaluate("${{ inputs['constructor'] }}", context)).toThrow(
199
- /Access to property constructor is forbidden/
211
+ /Access to property.*constructor.*is forbidden/
200
212
  );
201
213
  });
202
214
 
@@ -211,8 +223,9 @@ describe('ExpressionEvaluator', () => {
211
223
  });
212
224
 
213
225
  test('should throw error for method not allowed', () => {
226
+ // 'reverse' is now a safe method but strings don't have it
214
227
  expect(() => ExpressionEvaluator.evaluate("${{ 'abc'.reverse() }}", context)).toThrow(
215
- /Method reverse is not allowed/
228
+ /Cannot call method reverse on string/
216
229
  );
217
230
  });
218
231
 
@@ -56,18 +56,34 @@ interface ObjectExpression extends jsep.Expression {
56
56
  }
57
57
 
58
58
  export class ExpressionEvaluator {
59
+ // Pre-compiled regex for performance - handles nested braces (up to 3 levels)
60
+ private static readonly EXPRESSION_REGEX =
61
+ /\$\{\{(?:[^{}]|\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\})*\}\}/g;
62
+ private static readonly SINGLE_EXPRESSION_REGEX =
63
+ /^\s*\$\{\{(?:[^{}]|\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\})*\}\}\s*$/;
64
+ // Non-global version for hasExpression to avoid lastIndex state issues with global regex
65
+ private static readonly HAS_EXPRESSION_REGEX =
66
+ /\$\{\{(?:[^{}]|\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\})*\}\}/;
67
+
68
+ // Forbidden properties for security - prevents prototype pollution
69
+ private static readonly FORBIDDEN_PROPERTIES = new Set([
70
+ 'constructor',
71
+ '__proto__',
72
+ 'prototype',
73
+ '__defineGetter__',
74
+ '__defineSetter__',
75
+ '__lookupGetter__',
76
+ '__lookupSetter__',
77
+ ]);
78
+
59
79
  /**
60
80
  * Evaluate a string that may contain ${{ }} expressions
61
81
  */
62
82
  static evaluate(template: string, context: ExpressionContext): unknown {
63
- // Improved regex that handles nested braces (up to 2 levels of nesting)
64
- // Matches ${{ ... }} and allows { ... } inside for object literals and arrow functions
65
- const expressionRegex = /\$\{\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\}\}/g;
83
+ const expressionRegex = new RegExp(ExpressionEvaluator.EXPRESSION_REGEX.source, 'g');
66
84
 
67
85
  // If the entire string is a single expression, return the evaluated value directly
68
- const singleExprMatch = template.match(
69
- /^\s*\$\{\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\}\}\s*$/
70
- );
86
+ const singleExprMatch = template.match(ExpressionEvaluator.SINGLE_EXPRESSION_REGEX);
71
87
  if (singleExprMatch) {
72
88
  // Extract the expression content between ${{ and }}
73
89
  const expr = singleExprMatch[0].replace(/^\s*\$\{\{\s*|\s*\}\}\s*$/g, '');
@@ -91,7 +107,7 @@ export class ExpressionEvaluator {
91
107
  'exitCode' in result &&
92
108
  typeof (result as Record<string, unknown>).stdout === 'string'
93
109
  ) {
94
- return (result as Record<string, unknown>).stdout.trim();
110
+ return ((result as Record<string, unknown>).stdout as string).trim();
95
111
  }
96
112
  return JSON.stringify(result, null, 2);
97
113
  }
@@ -150,7 +166,7 @@ export class ExpressionEvaluator {
150
166
  Boolean: Boolean,
151
167
  Number: Number,
152
168
  String: String,
153
- Array: Array,
169
+ // Array: Array, // Disabled for security (DoS prevention)
154
170
  Object: Object,
155
171
  Math: Math,
156
172
  Date: Date,
@@ -162,23 +178,23 @@ export class ExpressionEvaluator {
162
178
  undefined: undefined,
163
179
  null: null,
164
180
  NaN: Number.NaN,
165
- Infinity: Number.Infinity,
181
+ Infinity: Number.POSITIVE_INFINITY,
166
182
  true: true,
167
183
  false: false,
168
184
  escape: escapeShellArg, // Shell argument escaping for safe command execution
169
185
  };
170
186
 
171
- // Check safe globals first
172
- if (name in safeGlobals) {
173
- return safeGlobals[name];
174
- }
175
-
176
187
  // Check if it's an arrow function parameter (stored directly in context)
177
188
  const contextAsRecord = context as Record<string, unknown>;
178
189
  if (name in contextAsRecord && contextAsRecord[name] !== undefined) {
179
190
  return contextAsRecord[name];
180
191
  }
181
192
 
193
+ // Check safe globals
194
+ if (name in safeGlobals) {
195
+ return safeGlobals[name];
196
+ }
197
+
182
198
  // Root context variables
183
199
  const rootContext: Record<string, unknown> = {
184
200
  inputs: context.inputs || {},
@@ -218,10 +234,21 @@ export class ExpressionEvaluator {
218
234
 
219
235
  const propertyAsRecord = object as Record<string | number, unknown>;
220
236
 
221
- // Security check for sensitive properties
222
- const forbiddenProperties = ['constructor', '__proto__', 'prototype'];
223
- if (typeof property === 'string' && forbiddenProperties.includes(property)) {
224
- throw new Error(`Access to property ${property} is forbidden`);
237
+ // Security check for sensitive properties - normalize and check
238
+ if (typeof property === 'string') {
239
+ // Normalize the property name to catch bypass attempts (e.g., via unicode or encoded strings)
240
+ const normalizedProperty = property.normalize('NFKC').toLowerCase();
241
+ const propertyLower = property.toLowerCase();
242
+
243
+ // Check both original and normalized forms
244
+ if (
245
+ ExpressionEvaluator.FORBIDDEN_PROPERTIES.has(property) ||
246
+ ExpressionEvaluator.FORBIDDEN_PROPERTIES.has(propertyLower) ||
247
+ normalizedProperty.includes('proto') ||
248
+ normalizedProperty.includes('constructor')
249
+ ) {
250
+ throw new Error(`Access to property "${property}" is forbidden for security reasons`);
251
+ }
225
252
  }
226
253
 
227
254
  return propertyAsRecord[property];
@@ -253,11 +280,15 @@ export class ExpressionEvaluator {
253
280
  case '%':
254
281
  return (left as number) % (right as number);
255
282
  case '==':
256
- return left === right;
283
+ // Use loose equality to match non-programmer expectations (e.g. "5" == 5)
284
+ // Strict equality is available via ===
285
+ // biome-ignore lint/suspicious/noDoubleEquals: Intentional loose equality for expression language
286
+ return left == right;
257
287
  case '===':
258
288
  return left === right;
259
289
  case '!=':
260
- return left !== right;
290
+ // biome-ignore lint/suspicious/noDoubleEquals: Intentional loose inequality for expression language
291
+ return left != right;
261
292
  case '!==':
262
293
  return left !== right;
263
294
  case '<':
@@ -290,7 +321,7 @@ export class ExpressionEvaluator {
290
321
  }
291
322
 
292
323
  case 'LogicalExpression': {
293
- const logicalNode = node as jsep.LogicalExpression;
324
+ const logicalNode = node as unknown as { left: ASTNode; right: ASTNode; operator: string };
294
325
  const left = ExpressionEvaluator.evaluateNode(logicalNode.left, context);
295
326
 
296
327
  // Short-circuit evaluation
@@ -314,7 +345,9 @@ export class ExpressionEvaluator {
314
345
 
315
346
  case 'ArrayExpression': {
316
347
  const arrayNode = node as jsep.ArrayExpression;
317
- return arrayNode.elements.map((elem) => ExpressionEvaluator.evaluateNode(elem, context));
348
+ return arrayNode.elements.map((elem) =>
349
+ elem ? ExpressionEvaluator.evaluateNode(elem, context) : null
350
+ );
318
351
  }
319
352
 
320
353
  case 'ObjectExpression': {
@@ -337,7 +370,14 @@ export class ExpressionEvaluator {
337
370
  if (callNode.callee.type === 'MemberExpression') {
338
371
  const memberNode = callNode.callee as jsep.MemberExpression;
339
372
  const object = ExpressionEvaluator.evaluateNode(memberNode.object, context);
340
- const methodName = (memberNode.property as jsep.Identifier).name;
373
+
374
+ let methodName: string;
375
+ if (memberNode.computed) {
376
+ const evaluated = ExpressionEvaluator.evaluateNode(memberNode.property, context);
377
+ methodName = String(evaluated);
378
+ } else {
379
+ methodName = (memberNode.property as jsep.Identifier).name;
380
+ }
341
381
 
342
382
  // Evaluate arguments, handling arrow functions specially
343
383
  const args = callNode.arguments.map((arg) => {
@@ -350,8 +390,9 @@ export class ExpressionEvaluator {
350
390
  return ExpressionEvaluator.evaluateNode(arg, context);
351
391
  });
352
392
 
353
- // Allow only safe array/string methods
393
+ // Allow only safe array/string/number methods
354
394
  const safeMethods = [
395
+ // Array methods
355
396
  'map',
356
397
  'filter',
357
398
  'reduce',
@@ -364,27 +405,56 @@ export class ExpressionEvaluator {
364
405
  'slice',
365
406
  'concat',
366
407
  'join',
408
+ 'flat',
409
+ 'flatMap',
410
+ 'reverse',
411
+ 'sort',
412
+ // String methods
367
413
  'split',
368
414
  'toLowerCase',
369
415
  'toUpperCase',
370
416
  'trim',
417
+ 'trimStart',
418
+ 'trimEnd',
371
419
  'startsWith',
372
420
  'endsWith',
373
421
  'replace',
422
+ 'replaceAll',
374
423
  'match',
375
424
  'toString',
376
- 'length',
425
+ 'charAt',
426
+ 'charCodeAt',
427
+ 'substring',
428
+ 'substr',
429
+ 'padStart',
430
+ 'padEnd',
431
+ // 'repeat', // Disabled for security (DoS prevention)
432
+ 'normalize',
433
+ 'localeCompare',
434
+ // Number methods
435
+ 'toFixed',
436
+ 'toPrecision',
437
+ 'toExponential',
438
+ 'toLocaleString',
439
+ // Math methods
377
440
  'max',
378
441
  'min',
379
442
  'abs',
380
443
  'round',
381
444
  'floor',
382
445
  'ceil',
446
+ 'pow',
447
+ 'sqrt',
448
+ 'random',
449
+ // Object/JSON methods
383
450
  'stringify',
384
451
  'parse',
385
452
  'keys',
386
453
  'values',
387
454
  'entries',
455
+ 'hasOwnProperty',
456
+ // General
457
+ 'length',
388
458
  ];
389
459
 
390
460
  if (!safeMethods.includes(methodName)) {
@@ -394,7 +464,10 @@ export class ExpressionEvaluator {
394
464
  // For methods that take callbacks (map, filter, etc.), we need special handling
395
465
  // Since we can't pass AST nodes directly, we'll handle the most common patterns
396
466
  if (object && typeof (object as Record<string, unknown>)[methodName] === 'function') {
397
- return (object as Record<string, unknown>)[methodName](...args);
467
+ const method = (object as Record<string, unknown>)[methodName] as (
468
+ ...args: unknown[]
469
+ ) => unknown;
470
+ return method.call(object, ...args);
398
471
  }
399
472
 
400
473
  throw new Error(`Cannot call method ${methodName} on ${typeof object}`);
@@ -466,8 +539,8 @@ export class ExpressionEvaluator {
466
539
  * Check if a string contains any expressions
467
540
  */
468
541
  static hasExpression(str: string): boolean {
469
- // Use same improved regex that handles nested braces (up to 2 levels)
470
- return /\$\{\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\}\}/.test(str);
542
+ // Use non-global regex to avoid lastIndex state issues
543
+ return ExpressionEvaluator.HAS_EXPRESSION_REGEX.test(str);
471
544
  }
472
545
 
473
546
  /**
@@ -498,7 +571,7 @@ export class ExpressionEvaluator {
498
571
  */
499
572
  static findStepDependencies(template: string): string[] {
500
573
  const dependencies = new Set<string>();
501
- const expressionRegex = /\$\{\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\}\}/g;
574
+ const expressionRegex = new RegExp(ExpressionEvaluator.EXPRESSION_REGEX.source, 'g');
502
575
  const matches = template.matchAll(expressionRegex);
503
576
 
504
577
  for (const match of matches) {
@@ -18,7 +18,9 @@ describe('agent-parser', () => {
18
18
  afterAll(() => {
19
19
  try {
20
20
  rmSync(tempDir, { recursive: true, force: true });
21
- } catch (e) {}
21
+ } catch (e) {
22
+ // Ignore cleanup error
23
+ }
22
24
  });
23
25
 
24
26
  describe('parseAgent', () => {
@@ -100,7 +102,9 @@ Prompt`;
100
102
  const agentsDir = join(process.cwd(), '.keystone', 'workflows', 'agents');
101
103
  try {
102
104
  mkdirSync(agentsDir, { recursive: true });
103
- } catch (e) {}
105
+ } catch (e) {
106
+ // Ignore cleanup error
107
+ }
104
108
 
105
109
  const filePath = join(agentsDir, 'my-agent.md');
106
110
  writeFileSync(filePath, '---name: my-agent---');
@@ -38,6 +38,7 @@ const ShellStepSchema = BaseStepSchema.extend({
38
38
  run: z.string(),
39
39
  dir: z.string().optional(),
40
40
  env: z.record(z.string()).optional(),
41
+ allowInsecure: z.boolean().optional(),
41
42
  });
42
43
 
43
44
  // Forward declaration for AgentToolSchema which depends on StepSchema
@@ -71,6 +72,7 @@ const LlmStepSchema = BaseStepSchema.extend({
71
72
  env: z.record(z.string()).optional(),
72
73
  url: z.string().optional(),
73
74
  headers: z.record(z.string()).optional(),
75
+ timeout: z.number().int().positive().optional(),
74
76
  }),
75
77
  ])
76
78
  )