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 +29 -4
- package/package.json +1 -2
- package/src/cli.ts +64 -4
- package/src/db/workflow-db.ts +16 -7
- package/src/expression/evaluator.audit.test.ts +67 -0
- package/src/expression/evaluator.test.ts +15 -2
- package/src/expression/evaluator.ts +102 -29
- package/src/parser/agent-parser.test.ts +6 -2
- package/src/parser/schema.ts +2 -0
- package/src/parser/workflow-parser.test.ts +6 -2
- package/src/parser/workflow-parser.ts +22 -11
- package/src/runner/audit-verification.test.ts +12 -8
- package/src/runner/llm-adapter.ts +49 -12
- package/src/runner/llm-executor.test.ts +75 -13
- package/src/runner/llm-executor.ts +84 -47
- package/src/runner/mcp-client.audit.test.ts +79 -0
- package/src/runner/mcp-client.ts +102 -19
- package/src/runner/shell-executor.test.ts +33 -15
- package/src/runner/shell-executor.ts +110 -39
- package/src/runner/step-executor.test.ts +30 -2
- package/src/runner/timeout.ts +2 -2
- package/src/runner/tool-integration.test.ts +8 -2
- package/src/runner/workflow-runner.ts +95 -29
- package/src/templates/agents/keystone-architect.md +5 -3
- package/src/types/status.ts +25 -0
- package/src/ui/dashboard.tsx +3 -1
- package/src/utils/auth-manager.test.ts +3 -1
- package/src/utils/auth-manager.ts +12 -2
- package/src/utils/config-loader.test.ts +2 -17
- package/src/utils/mermaid.ts +0 -8
- package/src/utils/redactor.ts +115 -22
- package/src/utils/sandbox.test.ts +9 -13
- package/src/utils/sandbox.ts +40 -53
- package/src/utils/workflow-registry.test.ts +6 -2
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
|
|
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
|
|
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.
|
|
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, {
|
|
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
|
-
.
|
|
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
|
|
439
|
-
|
|
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
|
|
package/src/db/workflow-db.ts
CHANGED
|
@@ -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
|
|
4
|
-
export type
|
|
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 =
|
|
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:
|
|
72
|
-
const delayMs =
|
|
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
|
|
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
|
-
/
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
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
|
|
470
|
-
return
|
|
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 =
|
|
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---');
|
package/src/parser/schema.ts
CHANGED
|
@@ -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
|
)
|