keystone-cli 1.1.2 → 1.3.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.
- package/README.md +111 -34
- package/package.json +1 -1
- package/src/commands/init.ts +8 -0
- package/src/db/dynamic-state-manager.test.ts +319 -0
- package/src/db/dynamic-state-manager.ts +411 -0
- package/src/db/workflow-db.ts +64 -0
- package/src/parser/schema.ts +84 -17
- package/src/parser/workflow-parser.test.ts +3 -4
- package/src/parser/workflow-parser.ts +3 -62
- package/src/runner/executors/dynamic-executor.test.ts +613 -0
- package/src/runner/executors/dynamic-executor.ts +718 -0
- package/src/runner/executors/dynamic-types.ts +69 -0
- package/src/runner/executors/file-executor.test.ts +7 -5
- package/src/runner/executors/file-executor.ts +2 -2
- package/src/runner/executors/git-executor.test.ts +278 -0
- package/src/runner/executors/git-executor.ts +100 -0
- package/src/runner/executors/security.test.ts +69 -0
- package/src/runner/executors/shell-executor.ts +30 -5
- package/src/runner/memoization-leak.test.ts +83 -0
- package/src/runner/recovery-security.test.ts +132 -0
- package/src/runner/services/context-builder.ts +110 -7
- package/src/runner/services/secret-manager.ts +12 -6
- package/src/runner/step-executor.ts +24 -0
- package/src/runner/workflow-runner.ts +20 -182
- package/src/templates/basics/git-worktree.yaml +25 -0
- package/src/templates/dynamic-demo.yaml +31 -0
- package/src/templates/scaffolding/decompose-problem.yaml +1 -1
- package/src/templates/scaffolding/dynamic-decompose.yaml +39 -0
- package/src/utils/env-constants.ts +19 -0
- package/src/utils/topo-sort.ts +47 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for Dynamic Step feature
|
|
3
|
+
*/
|
|
4
|
+
import type { StepResult } from './types.ts';
|
|
5
|
+
|
|
6
|
+
export interface GeneratedStep {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
type: 'llm' | 'shell' | 'workflow' | 'file' | 'request';
|
|
10
|
+
agent?: string;
|
|
11
|
+
prompt?: string;
|
|
12
|
+
run?: string;
|
|
13
|
+
path?: string;
|
|
14
|
+
op?: 'read' | 'write' | 'append';
|
|
15
|
+
content?: string;
|
|
16
|
+
needs?: string[];
|
|
17
|
+
inputs?: Record<string, unknown>;
|
|
18
|
+
allowStepFailure?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DynamicPlan {
|
|
22
|
+
workflow_id?: string;
|
|
23
|
+
steps: GeneratedStep[];
|
|
24
|
+
notes?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* State for tracking dynamic step execution
|
|
29
|
+
*/
|
|
30
|
+
export interface DynamicStepState {
|
|
31
|
+
// Identity
|
|
32
|
+
id?: string; // Database ID (optional for in-memory)
|
|
33
|
+
workflowId: string; // The workflow instance ID
|
|
34
|
+
runId?: string; // The higher-level run ID
|
|
35
|
+
stepId?: string; // The step ID within the workflow
|
|
36
|
+
|
|
37
|
+
// State
|
|
38
|
+
status: 'planning' | 'awaiting_confirmation' | 'executing' | 'completed' | 'failed';
|
|
39
|
+
generatedPlan: DynamicPlan;
|
|
40
|
+
|
|
41
|
+
// Execution tracking
|
|
42
|
+
stepResults: Map<string, StepResult>;
|
|
43
|
+
currentStepIndex: number;
|
|
44
|
+
replanCount: number;
|
|
45
|
+
|
|
46
|
+
// Timing & Metadata
|
|
47
|
+
startedAt: string;
|
|
48
|
+
completedAt?: string;
|
|
49
|
+
error?: string;
|
|
50
|
+
metadata?: Record<string, unknown>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Individual generated step execution record
|
|
55
|
+
*/
|
|
56
|
+
export interface DynamicStepExecution {
|
|
57
|
+
id: string;
|
|
58
|
+
stateId: string;
|
|
59
|
+
stepId: string;
|
|
60
|
+
stepName: string;
|
|
61
|
+
stepType: string;
|
|
62
|
+
stepDefinition: GeneratedStep;
|
|
63
|
+
status: 'pending' | 'running' | 'success' | 'failed' | 'skipped';
|
|
64
|
+
output?: unknown;
|
|
65
|
+
error?: string;
|
|
66
|
+
startedAt?: string;
|
|
67
|
+
completedAt?: string;
|
|
68
|
+
executionOrder: number;
|
|
69
|
+
}
|
|
@@ -36,13 +36,15 @@ describe('file-executor', () => {
|
|
|
36
36
|
const blocks: SearchReplaceBlock[] = [
|
|
37
37
|
{ search: ' indent', replace: 'dedent' }, // one space vs two
|
|
38
38
|
];
|
|
39
|
-
// Should not find ' indent' because actual is ' indent' (double space) if strict?
|
|
40
|
-
// Actually ' indent'.split(' indent') -> [' ', ''] -> found once!
|
|
41
|
-
// Wait ' indent'.includes(' indent') is true.
|
|
42
|
-
// ' indent'.split(' indent') -> [' ', ''] (length 2).
|
|
43
|
-
// So it works.
|
|
44
39
|
const result = applySearchReplaceBlocks(content, blocks);
|
|
45
40
|
expect(result).toBe(' dedent');
|
|
46
41
|
});
|
|
42
|
+
|
|
43
|
+
it('should handle CRLF line endings by treating them as LF', () => {
|
|
44
|
+
const content = 'line1\r\nline2\r\nline3';
|
|
45
|
+
const blocks: SearchReplaceBlock[] = [{ search: 'line2', replace: 'modified' }];
|
|
46
|
+
const result = applySearchReplaceBlocks(content, blocks);
|
|
47
|
+
expect(result).toBe('line1\nmodified\nline3');
|
|
48
|
+
});
|
|
47
49
|
});
|
|
48
50
|
});
|
|
@@ -116,7 +116,7 @@ export function applyUnifiedDiff(content: string, patch: string, targetPath: str
|
|
|
116
116
|
const diff = parseUnifiedDiff(patch);
|
|
117
117
|
assertDiffMatchesTarget(diff.newFile || diff.originalFile, targetPath);
|
|
118
118
|
|
|
119
|
-
const lines = content.split('\n');
|
|
119
|
+
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
|
120
120
|
const resultLines = [...lines];
|
|
121
121
|
|
|
122
122
|
// Apply hunks in reverse order to keep line numbers valid
|
|
@@ -188,7 +188,7 @@ export function parseSearchReplaceBlocks(patch: string): SearchReplaceBlock[] {
|
|
|
188
188
|
}
|
|
189
189
|
|
|
190
190
|
export function applySearchReplaceBlocks(content: string, blocks: SearchReplaceBlock[]): string {
|
|
191
|
-
let result = content;
|
|
191
|
+
let result = content.replace(/\r\n/g, '\n');
|
|
192
192
|
|
|
193
193
|
for (const block of blocks) {
|
|
194
194
|
const parts = result.split(block.search);
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, jest, spyOn } from 'bun:test';
|
|
2
|
+
import type { GitStep } from '../../parser/schema.ts';
|
|
3
|
+
import { ConsoleLogger } from '../../utils/logger.ts';
|
|
4
|
+
import { executeGitStep } from './git-executor.ts';
|
|
5
|
+
import * as shellExecutor from './shell-executor.ts';
|
|
6
|
+
|
|
7
|
+
describe('git-executor', () => {
|
|
8
|
+
const logger = new ConsoleLogger();
|
|
9
|
+
const context = {
|
|
10
|
+
env: {},
|
|
11
|
+
inputs: {},
|
|
12
|
+
steps: {},
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
let executeShellSpy: any;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
executeShellSpy = spyOn(shellExecutor, 'executeShell').mockResolvedValue({
|
|
19
|
+
stdout: 'git output',
|
|
20
|
+
stderr: '',
|
|
21
|
+
exitCode: 0,
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
executeShellSpy.mockRestore();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should execute clone operation', async () => {
|
|
30
|
+
const step: GitStep = {
|
|
31
|
+
id: 'git-clone',
|
|
32
|
+
type: 'git',
|
|
33
|
+
op: 'clone',
|
|
34
|
+
url: 'https://github.com/mhingston/keystone-cli.git',
|
|
35
|
+
needs: [],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const result = await executeGitStep(step, context, logger);
|
|
39
|
+
expect(result.status).toBe('success');
|
|
40
|
+
expect(executeShellSpy).toHaveBeenCalledWith(
|
|
41
|
+
expect.objectContaining({
|
|
42
|
+
run: 'git clone https://github.com/mhingston/keystone-cli.git ',
|
|
43
|
+
}),
|
|
44
|
+
context,
|
|
45
|
+
logger,
|
|
46
|
+
undefined,
|
|
47
|
+
'git clone https://github.com/mhingston/keystone-cli.git '
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should execute clone with branch', async () => {
|
|
52
|
+
const step: GitStep = {
|
|
53
|
+
id: 'git-clone-branch',
|
|
54
|
+
type: 'git',
|
|
55
|
+
op: 'clone',
|
|
56
|
+
url: 'https://github.com/mhingston/keystone-cli.git',
|
|
57
|
+
branch: 'main',
|
|
58
|
+
needs: [],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const result = await executeGitStep(step, context, logger);
|
|
62
|
+
expect(result.status).toBe('success');
|
|
63
|
+
expect(executeShellSpy).toHaveBeenCalledWith(
|
|
64
|
+
expect.objectContaining({
|
|
65
|
+
run: 'git clone -b main https://github.com/mhingston/keystone-cli.git ',
|
|
66
|
+
}),
|
|
67
|
+
context,
|
|
68
|
+
logger,
|
|
69
|
+
undefined,
|
|
70
|
+
'git clone -b main https://github.com/mhingston/keystone-cli.git '
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should execute worktree_add operation', async () => {
|
|
75
|
+
const step: GitStep = {
|
|
76
|
+
id: 'git-worktree-add',
|
|
77
|
+
type: 'git',
|
|
78
|
+
op: 'worktree_add',
|
|
79
|
+
path: './tmp-worktree',
|
|
80
|
+
branch: 'feature-branch',
|
|
81
|
+
needs: [],
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const result = await executeGitStep(step, context, logger);
|
|
85
|
+
expect(result.status).toBe('success');
|
|
86
|
+
expect((result.output as any).worktreePath).toBe('./tmp-worktree');
|
|
87
|
+
expect(executeShellSpy).toHaveBeenCalledWith(
|
|
88
|
+
expect.objectContaining({
|
|
89
|
+
run: 'git worktree add ./tmp-worktree feature-branch',
|
|
90
|
+
}),
|
|
91
|
+
context,
|
|
92
|
+
logger,
|
|
93
|
+
undefined,
|
|
94
|
+
'git worktree add ./tmp-worktree feature-branch'
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should execute worktree_remove operation', async () => {
|
|
99
|
+
const step: GitStep = {
|
|
100
|
+
id: 'git-worktree-remove',
|
|
101
|
+
type: 'git',
|
|
102
|
+
op: 'worktree_remove',
|
|
103
|
+
path: './tmp-worktree',
|
|
104
|
+
needs: [],
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const result = await executeGitStep(step, context, logger);
|
|
108
|
+
expect(result.status).toBe('success');
|
|
109
|
+
expect(executeShellSpy).toHaveBeenCalledWith(
|
|
110
|
+
expect.objectContaining({
|
|
111
|
+
run: 'git worktree remove ./tmp-worktree',
|
|
112
|
+
}),
|
|
113
|
+
context,
|
|
114
|
+
logger,
|
|
115
|
+
undefined,
|
|
116
|
+
'git worktree remove ./tmp-worktree'
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should execute checkout operation', async () => {
|
|
121
|
+
const step: GitStep = {
|
|
122
|
+
id: 'git-checkout',
|
|
123
|
+
type: 'git',
|
|
124
|
+
op: 'checkout',
|
|
125
|
+
branch: 'main',
|
|
126
|
+
needs: [],
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const result = await executeGitStep(step, context, logger);
|
|
130
|
+
expect(result.status).toBe('success');
|
|
131
|
+
expect(executeShellSpy).toHaveBeenCalledWith(
|
|
132
|
+
expect.objectContaining({
|
|
133
|
+
run: 'git checkout main',
|
|
134
|
+
}),
|
|
135
|
+
context,
|
|
136
|
+
logger,
|
|
137
|
+
undefined,
|
|
138
|
+
'git checkout main'
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should execute pull operation', async () => {
|
|
143
|
+
const step: GitStep = {
|
|
144
|
+
id: 'git-pull',
|
|
145
|
+
type: 'git',
|
|
146
|
+
op: 'pull',
|
|
147
|
+
branch: 'main',
|
|
148
|
+
needs: [],
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const result = await executeGitStep(step, context, logger);
|
|
152
|
+
expect(result.status).toBe('success');
|
|
153
|
+
expect(executeShellSpy).toHaveBeenCalledWith(
|
|
154
|
+
expect.objectContaining({
|
|
155
|
+
run: 'git pull origin main',
|
|
156
|
+
}),
|
|
157
|
+
context,
|
|
158
|
+
logger,
|
|
159
|
+
undefined,
|
|
160
|
+
'git pull origin main'
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should execute push operation', async () => {
|
|
165
|
+
const step: GitStep = {
|
|
166
|
+
id: 'git-push',
|
|
167
|
+
type: 'git',
|
|
168
|
+
op: 'push',
|
|
169
|
+
branch: 'main',
|
|
170
|
+
needs: [],
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const result = await executeGitStep(step, context, logger);
|
|
174
|
+
expect(result.status).toBe('success');
|
|
175
|
+
expect(executeShellSpy).toHaveBeenCalledWith(
|
|
176
|
+
expect.objectContaining({
|
|
177
|
+
run: 'git push origin main',
|
|
178
|
+
}),
|
|
179
|
+
context,
|
|
180
|
+
logger,
|
|
181
|
+
undefined,
|
|
182
|
+
'git push origin main'
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should execute commit operation', async () => {
|
|
187
|
+
const step: GitStep = {
|
|
188
|
+
id: 'git-commit',
|
|
189
|
+
type: 'git',
|
|
190
|
+
op: 'commit',
|
|
191
|
+
message: 'test commit',
|
|
192
|
+
needs: [],
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const result = await executeGitStep(step, context, logger);
|
|
196
|
+
expect(result.status).toBe('success');
|
|
197
|
+
expect(executeShellSpy).toHaveBeenCalledWith(
|
|
198
|
+
expect.objectContaining({
|
|
199
|
+
run: 'git add . && git commit -m "test commit"',
|
|
200
|
+
}),
|
|
201
|
+
context,
|
|
202
|
+
logger,
|
|
203
|
+
undefined,
|
|
204
|
+
'git add . && git commit -m "test commit"'
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('should handle failed operations', async () => {
|
|
209
|
+
executeShellSpy.mockResolvedValue({
|
|
210
|
+
stdout: '',
|
|
211
|
+
stderr: 'git error',
|
|
212
|
+
exitCode: 1,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const step: GitStep = {
|
|
216
|
+
id: 'git-fail',
|
|
217
|
+
type: 'git',
|
|
218
|
+
op: 'pull',
|
|
219
|
+
needs: [],
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const result = await executeGitStep(step, context, logger);
|
|
223
|
+
expect(result.status).toBe('failed');
|
|
224
|
+
expect(result.error).toContain('Git pull failed with code 1: git error');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('should throw error for missing url in clone', async () => {
|
|
228
|
+
const step: GitStep = {
|
|
229
|
+
id: 'git-clone-no-url',
|
|
230
|
+
type: 'git',
|
|
231
|
+
op: 'clone',
|
|
232
|
+
needs: [],
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
await expect(executeGitStep(step, context, logger)).rejects.toThrow(
|
|
236
|
+
'Git clone requires a "url"'
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should throw error for missing path in worktree_add', async () => {
|
|
241
|
+
const step: GitStep = {
|
|
242
|
+
id: 'git-worktree-no-path',
|
|
243
|
+
type: 'git',
|
|
244
|
+
op: 'worktree_add',
|
|
245
|
+
needs: [],
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
await expect(executeGitStep(step, context, logger)).rejects.toThrow(
|
|
249
|
+
'Git worktree_add requires a "path"'
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should throw error for missing branch in checkout', async () => {
|
|
254
|
+
const step: GitStep = {
|
|
255
|
+
id: 'git-checkout-no-branch',
|
|
256
|
+
type: 'git',
|
|
257
|
+
op: 'checkout',
|
|
258
|
+
needs: [],
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
await expect(executeGitStep(step, context, logger)).rejects.toThrow(
|
|
262
|
+
'Git checkout requires a "branch"'
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should throw error for missing message in commit', async () => {
|
|
267
|
+
const step: GitStep = {
|
|
268
|
+
id: 'git-commit-no-msg',
|
|
269
|
+
type: 'git',
|
|
270
|
+
op: 'commit',
|
|
271
|
+
needs: [],
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
await expect(executeGitStep(step, context, logger)).rejects.toThrow(
|
|
275
|
+
'Git commit requires a "message"'
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { ExpressionEvaluator } from '../../expression/evaluator.ts';
|
|
2
|
+
import type { ExpressionContext } from '../../expression/evaluator.ts';
|
|
3
|
+
import type { GitStep } from '../../parser/schema.ts';
|
|
4
|
+
import { ConsoleLogger, type Logger } from '../../utils/logger.ts';
|
|
5
|
+
import { PathResolver } from '../../utils/paths.ts';
|
|
6
|
+
import { type ShellResult, executeShell } from './shell-executor.ts';
|
|
7
|
+
import type { StepResult } from './types.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Git command executor
|
|
11
|
+
*/
|
|
12
|
+
export async function executeGitStep(
|
|
13
|
+
step: GitStep,
|
|
14
|
+
context: ExpressionContext,
|
|
15
|
+
logger: Logger = new ConsoleLogger(),
|
|
16
|
+
abortSignal?: AbortSignal
|
|
17
|
+
): Promise<StepResult> {
|
|
18
|
+
const op = step.op;
|
|
19
|
+
const cwd = step.cwd ? ExpressionEvaluator.evaluateString(step.cwd, context) : undefined;
|
|
20
|
+
|
|
21
|
+
if (cwd) {
|
|
22
|
+
PathResolver.assertWithinCwd(cwd, step.allowOutsideCwd, 'CWD');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Pre-process common inputs
|
|
26
|
+
const path = step.path ? ExpressionEvaluator.evaluateString(step.path, context) : undefined;
|
|
27
|
+
const url = step.url ? ExpressionEvaluator.evaluateString(step.url, context) : undefined;
|
|
28
|
+
const branch = step.branch ? ExpressionEvaluator.evaluateString(step.branch, context) : undefined;
|
|
29
|
+
const message = step.message
|
|
30
|
+
? ExpressionEvaluator.evaluateString(step.message, context)
|
|
31
|
+
: undefined;
|
|
32
|
+
|
|
33
|
+
let command = '';
|
|
34
|
+
switch (op) {
|
|
35
|
+
case 'clone':
|
|
36
|
+
if (!url) throw new Error('Git clone requires a "url"');
|
|
37
|
+
command = `git clone ${branch ? `-b ${branch} ` : ''}${url} ${path || ''}`;
|
|
38
|
+
break;
|
|
39
|
+
case 'worktree_add':
|
|
40
|
+
if (!path) throw new Error('Git worktree_add requires a "path"');
|
|
41
|
+
command = `git worktree add ${path} ${branch || ''}`;
|
|
42
|
+
break;
|
|
43
|
+
case 'worktree_remove':
|
|
44
|
+
if (!path) throw new Error('Git worktree_remove requires a "path"');
|
|
45
|
+
command = `git worktree remove ${path}`;
|
|
46
|
+
break;
|
|
47
|
+
case 'checkout':
|
|
48
|
+
if (!branch) throw new Error('Git checkout requires a "branch"');
|
|
49
|
+
command = `git checkout ${branch}`;
|
|
50
|
+
break;
|
|
51
|
+
case 'pull':
|
|
52
|
+
command = `git pull origin ${branch || ''}`;
|
|
53
|
+
break;
|
|
54
|
+
case 'push':
|
|
55
|
+
command = `git push origin ${branch || ''}`;
|
|
56
|
+
break;
|
|
57
|
+
case 'commit':
|
|
58
|
+
if (!message) throw new Error('Git commit requires a "message"');
|
|
59
|
+
command = `git add . && git commit -m "${message.replace(/"/g, '\\"')}"`;
|
|
60
|
+
break;
|
|
61
|
+
default:
|
|
62
|
+
throw new Error(`Unsupported git operation: ${op}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Use executeShell to leverage its security checks and output handling
|
|
66
|
+
// We explicitly allow insecure if the user set it, but by default executeShell
|
|
67
|
+
// will check the command against the whitelist.
|
|
68
|
+
// Note: Git commands often contain spaces/slashes which are in the whitelist.
|
|
69
|
+
const result: ShellResult = await executeShell(
|
|
70
|
+
{
|
|
71
|
+
...step,
|
|
72
|
+
type: 'shell',
|
|
73
|
+
run: command,
|
|
74
|
+
dir: cwd,
|
|
75
|
+
},
|
|
76
|
+
context,
|
|
77
|
+
logger,
|
|
78
|
+
abortSignal,
|
|
79
|
+
command
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (result.exitCode !== 0) {
|
|
83
|
+
return {
|
|
84
|
+
output: result,
|
|
85
|
+
status: 'failed',
|
|
86
|
+
error: `Git ${op} failed with code ${result.exitCode}: ${result.stderr}`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Provide some structured output based on the operation
|
|
91
|
+
const output: any = { ...result };
|
|
92
|
+
if (op === 'worktree_add') {
|
|
93
|
+
output.worktreePath = path;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
output,
|
|
98
|
+
status: 'success',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { AUTO_LOAD_SECRET_PREFIXES } from '../../utils/env-constants';
|
|
3
|
+
import { SecretManager } from '../services/secret-manager';
|
|
4
|
+
import { detectShellInjectionRisk } from './shell-executor';
|
|
5
|
+
|
|
6
|
+
describe('Security Fixes', () => {
|
|
7
|
+
describe('ShellExecutor Command Injection', () => {
|
|
8
|
+
test('detectShellInjectionRisk should block newlines', () => {
|
|
9
|
+
// Regular space should be allowed
|
|
10
|
+
expect(detectShellInjectionRisk('echo hello')).toBe(false);
|
|
11
|
+
|
|
12
|
+
// Newline characters should be detected as risk
|
|
13
|
+
expect(detectShellInjectionRisk('echo hello\n')).toBe(true);
|
|
14
|
+
expect(detectShellInjectionRisk('echo hello\r')).toBe(true);
|
|
15
|
+
expect(detectShellInjectionRisk('echo hello\nrm -rf /')).toBe(true);
|
|
16
|
+
|
|
17
|
+
// Standard allowed characters should still pass
|
|
18
|
+
expect(detectShellInjectionRisk('curl -X POST https://example.com/api')).toBe(false);
|
|
19
|
+
expect(detectShellInjectionRisk('file_name-v1.0+beta.txt')).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('detectShellInjectionRisk should correctly handle quotes', () => {
|
|
23
|
+
// Content inside single quotes is considered safe (literal)
|
|
24
|
+
expect(detectShellInjectionRisk("echo 'safe string with ; and |'")).toBe(false);
|
|
25
|
+
|
|
26
|
+
// But unsafe chars outside quotes must be caught
|
|
27
|
+
expect(detectShellInjectionRisk("echo 'safe'; rm -rf /")).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('SecretManager Auto-loading', () => {
|
|
32
|
+
// Save original env to restore later
|
|
33
|
+
const originalEnv = { ...Bun.env };
|
|
34
|
+
|
|
35
|
+
afterAll(() => {
|
|
36
|
+
// Restore env
|
|
37
|
+
for (const key of Object.keys(Bun.env)) {
|
|
38
|
+
delete Bun.env[key];
|
|
39
|
+
}
|
|
40
|
+
for (const [key, value] of Object.entries(originalEnv)) {
|
|
41
|
+
if (value) Bun.env[key] = value;
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('should only load secrets with allowed prefixes', () => {
|
|
46
|
+
// Setup test env vars
|
|
47
|
+
Bun.env.KEYSTONE_TEST_SECRET = 'secret-value-1';
|
|
48
|
+
Bun.env.GITHUB_TOKEN = 'gh-token-123';
|
|
49
|
+
Bun.env.MY_RANDOM_TOKEN = 'unsafe-token-should-not-load';
|
|
50
|
+
Bun.env.SOME_API_KEY = 'unsafe-api-key';
|
|
51
|
+
|
|
52
|
+
const secretManager = new SecretManager();
|
|
53
|
+
const secrets = secretManager.loadSecrets();
|
|
54
|
+
|
|
55
|
+
expect(secrets.KEYSTONE_TEST_SECRET).toBe('secret-value-1');
|
|
56
|
+
expect(secrets.GITHUB_TOKEN).toBe('gh-token-123');
|
|
57
|
+
|
|
58
|
+
// Should NOT load these even though they contain "TOKEN" or "KEY"
|
|
59
|
+
expect(secrets.MY_RANDOM_TOKEN).toBeUndefined();
|
|
60
|
+
expect(secrets.SOME_API_KEY).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('env constants should match implementation', () => {
|
|
64
|
+
// Quick sanity check that our test logic matches the constants
|
|
65
|
+
expect(AUTO_LOAD_SECRET_PREFIXES).toContain('KEYSTONE_');
|
|
66
|
+
expect(AUTO_LOAD_SECRET_PREFIXES).toContain('GITHUB_');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -43,6 +43,9 @@ export async function executeShellStep(
|
|
|
43
43
|
abortSignal?: AbortSignal
|
|
44
44
|
): Promise<StepResult> {
|
|
45
45
|
if (step.args) {
|
|
46
|
+
// args are inherently safe from shell injection as they skip the shell
|
|
47
|
+
// and pass the array directly to the OS via Bun.spawn.
|
|
48
|
+
|
|
46
49
|
const command = step.args.map((a) => ExpressionEvaluator.evaluateString(a, context)).join(' ');
|
|
47
50
|
if (dryRun) {
|
|
48
51
|
logger.log(`[DRY RUN] Would execute: ${command}`);
|
|
@@ -200,13 +203,35 @@ async function readStreamWithLimit(
|
|
|
200
203
|
}
|
|
201
204
|
|
|
202
205
|
// Whitelist of allowed characters for secure shell command execution
|
|
203
|
-
// Allows: Alphanumeric,
|
|
204
|
-
// Blocks:
|
|
205
|
-
const SAFE_SHELL_CHARS = /^[a-zA-Z0-9
|
|
206
|
+
// Allows: Alphanumeric, space, and common safe punctuation (_ . / : @ , + - = ' " ! ~)
|
|
207
|
+
// Blocks: Newlines (\n, \r), Pipes, redirects, subshells, variables ($), etc.
|
|
208
|
+
const SAFE_SHELL_CHARS = /^[a-zA-Z0-9 _./:@,+=~'"!-]+$/;
|
|
206
209
|
|
|
207
210
|
export function detectShellInjectionRisk(rawCommand: string): boolean {
|
|
208
|
-
//
|
|
209
|
-
|
|
211
|
+
// We scan the command to handle single quotes correctly.
|
|
212
|
+
// Characters inside single quotes are considered escaped/literal and safe from shell injection.
|
|
213
|
+
let inSingleQuote = false;
|
|
214
|
+
|
|
215
|
+
for (let i = 0; i < rawCommand.length; i++) {
|
|
216
|
+
const char = rawCommand[i];
|
|
217
|
+
|
|
218
|
+
if (char === "'") {
|
|
219
|
+
inSingleQuote = !inSingleQuote;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Outside single quotes, we enforce the strict whitelist
|
|
224
|
+
if (!inSingleQuote) {
|
|
225
|
+
if (!SAFE_SHELL_CHARS.test(char)) {
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// Inside single quotes, everything is treated as a literal string by the shell,
|
|
230
|
+
// so we don't need to block special characters.
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// If we ended with an unclosed single quote, it's a syntax risk
|
|
234
|
+
return inSingleQuote;
|
|
210
235
|
}
|
|
211
236
|
|
|
212
237
|
/**
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import { existsSync, rmSync } from 'node:fs';
|
|
3
|
+
import { MemoryDb } from '../db/memory-db';
|
|
4
|
+
import { WorkflowDb } from '../db/workflow-db';
|
|
5
|
+
import type { Workflow } from '../parser/schema';
|
|
6
|
+
import { container } from '../utils/container';
|
|
7
|
+
import { ConsoleLogger } from '../utils/logger';
|
|
8
|
+
import { WorkflowRunner } from './workflow-runner';
|
|
9
|
+
|
|
10
|
+
describe('Workflow Memoization Leak (Args Check)', () => {
|
|
11
|
+
const dbPath = 'test-memoization-leak.db';
|
|
12
|
+
|
|
13
|
+
container.register('logger', new ConsoleLogger());
|
|
14
|
+
container.register('db', new WorkflowDb(dbPath));
|
|
15
|
+
container.register('memoryDb', new MemoryDb());
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
if (existsSync(dbPath)) {
|
|
19
|
+
rmSync(dbPath);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should NOT collide for shell steps with same command but different args', async () => {
|
|
24
|
+
const workflow: Workflow = {
|
|
25
|
+
name: 'memoize-args-wf',
|
|
26
|
+
inputs: {
|
|
27
|
+
arg: { type: 'string' },
|
|
28
|
+
},
|
|
29
|
+
steps: [
|
|
30
|
+
{
|
|
31
|
+
id: 's1',
|
|
32
|
+
type: 'shell',
|
|
33
|
+
args: ['echo', '${{ inputs.arg }}'],
|
|
34
|
+
allowInsecure: true,
|
|
35
|
+
memoize: true,
|
|
36
|
+
needs: [],
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
outputs: {
|
|
40
|
+
out: '${{ steps.s1.output.stdout.trim() }}',
|
|
41
|
+
},
|
|
42
|
+
} as unknown as Workflow;
|
|
43
|
+
|
|
44
|
+
let executeCount = 0;
|
|
45
|
+
const trackedExecuteStep = async (step: any, context: any, logger: any, options: any) => {
|
|
46
|
+
if (step.id === 's1') executeCount++;
|
|
47
|
+
const { executeStep } = await import('./step-executor');
|
|
48
|
+
return executeStep(step, context, logger, options);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Run 1: arg=A
|
|
52
|
+
const runner1 = new WorkflowRunner(workflow, {
|
|
53
|
+
dbPath,
|
|
54
|
+
inputs: { arg: 'A' },
|
|
55
|
+
executeStep: trackedExecuteStep,
|
|
56
|
+
});
|
|
57
|
+
const out1 = await runner1.run();
|
|
58
|
+
expect(out1.out).toBe('A');
|
|
59
|
+
expect(executeCount).toBe(1);
|
|
60
|
+
|
|
61
|
+
// Run 2: arg=A -> Cache Hit
|
|
62
|
+
const runner2 = new WorkflowRunner(workflow, {
|
|
63
|
+
dbPath,
|
|
64
|
+
inputs: { arg: 'A' },
|
|
65
|
+
executeStep: trackedExecuteStep,
|
|
66
|
+
});
|
|
67
|
+
executeCount = 0;
|
|
68
|
+
const out2 = await runner2.run();
|
|
69
|
+
expect(out2.out).toBe('A');
|
|
70
|
+
expect(executeCount).toBe(0); // Memoized
|
|
71
|
+
|
|
72
|
+
// Run 3: arg=B -> Execute (Must not collide with A)
|
|
73
|
+
const runner3 = new WorkflowRunner(workflow, {
|
|
74
|
+
dbPath,
|
|
75
|
+
inputs: { arg: 'B' },
|
|
76
|
+
executeStep: trackedExecuteStep,
|
|
77
|
+
});
|
|
78
|
+
executeCount = 0;
|
|
79
|
+
const out3 = await runner3.run();
|
|
80
|
+
expect(out3.out).toBe('B');
|
|
81
|
+
expect(executeCount).toBe(1); // Should execute because args are different!
|
|
82
|
+
});
|
|
83
|
+
});
|