keystone-cli 2.1.5 → 2.1.7
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/package.json +14 -3
- package/src/cli.ts +0 -1
- package/src/runner/executors/verification_fixes.test.ts +9 -7
- package/src/runner/mcp-client-audit.test.ts +26 -14
- package/src/runner/mcp-client.test.ts +23 -9
- package/src/runner/shell-executor.test.ts +13 -9
- package/src/runner/standard-tools-integration.test.ts +15 -19
- package/src/runner/standard-tools.ts +1 -2
- package/src/runner/step-executor.test.ts +8 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keystone-cli",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.7",
|
|
4
4
|
"description": "A local-first, declarative, agentic workflow orchestrator built on Bun",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,13 @@
|
|
|
16
16
|
"format": "biome format --write .",
|
|
17
17
|
"schema:generate": "bun run src/scripts/generate-schemas.ts"
|
|
18
18
|
},
|
|
19
|
-
"keywords": [
|
|
19
|
+
"keywords": [
|
|
20
|
+
"workflow",
|
|
21
|
+
"orchestrator",
|
|
22
|
+
"agentic",
|
|
23
|
+
"automation",
|
|
24
|
+
"bun"
|
|
25
|
+
],
|
|
20
26
|
"author": "Mark Hingston",
|
|
21
27
|
"license": "MIT",
|
|
22
28
|
"repository": {
|
|
@@ -24,7 +30,12 @@
|
|
|
24
30
|
"url": "https://github.com/mhingston/keystone-cli.git"
|
|
25
31
|
},
|
|
26
32
|
"homepage": "https://github.com/mhingston/keystone-cli#readme",
|
|
27
|
-
"files": [
|
|
33
|
+
"files": [
|
|
34
|
+
"src",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE",
|
|
37
|
+
"logo.png"
|
|
38
|
+
],
|
|
28
39
|
"dependencies": {
|
|
29
40
|
"@ast-grep/cli": "^0.40.3",
|
|
30
41
|
"@ast-grep/napi": "^0.40.3",
|
package/src/cli.ts
CHANGED
|
@@ -20,25 +20,27 @@ describe('Verification Fixes', () => {
|
|
|
20
20
|
describe('Shell Path Traversal (shell-executor)', () => {
|
|
21
21
|
const mockContext = { env: {}, steps: {}, inputs: {}, envOverrides: {}, secrets: {} };
|
|
22
22
|
|
|
23
|
-
test('should
|
|
23
|
+
test('should allow command with ".." and "/"', async () => {
|
|
24
24
|
const step = {
|
|
25
25
|
id: 'test',
|
|
26
26
|
type: 'shell' as const,
|
|
27
27
|
run: 'cat ../secret.txt',
|
|
28
28
|
};
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
//
|
|
32
|
-
|
|
29
|
+
// Commands with .. are allowed (only dir step property is validated)
|
|
30
|
+
const result = await executeShell(step, mockContext);
|
|
31
|
+
// Will fail because file doesn't exist, but not blocked
|
|
32
|
+
expect(result.exitCode).toBe(1);
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
-
test('should
|
|
35
|
+
test('should allow absolute path with ".."', async () => {
|
|
36
36
|
const step = {
|
|
37
37
|
id: 'test',
|
|
38
38
|
type: 'shell' as const,
|
|
39
39
|
run: '/bin/ls ../',
|
|
40
40
|
};
|
|
41
|
-
|
|
41
|
+
// Absolute paths are allowed in run command
|
|
42
|
+
const result = await executeShell(step, mockContext);
|
|
43
|
+
expect(result.exitCode).toBe(0); // ls should succeed
|
|
42
44
|
});
|
|
43
45
|
});
|
|
44
46
|
});
|
|
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:te
|
|
|
2
2
|
import * as child_process from 'node:child_process';
|
|
3
3
|
import { MCPClient } from './mcp-client';
|
|
4
4
|
|
|
5
|
+
import * as dns from 'node:dns/promises';
|
|
5
6
|
import { Readable, Writable } from 'node:stream';
|
|
6
7
|
|
|
7
8
|
describe('MCPClient Audit Fixes', () => {
|
|
@@ -78,12 +79,29 @@ describe('MCPClient Audit Fixes', () => {
|
|
|
78
79
|
});
|
|
79
80
|
|
|
80
81
|
describe('MCPClient SSRF Protection', () => {
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
let lookupSpy: ReturnType<typeof spyOn>;
|
|
83
|
+
let fetchSpy: ReturnType<typeof spyOn>;
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
// Mock DNS lookup to return a public IP for api.example.com
|
|
87
|
+
lookupSpy = spyOn(dns, 'lookup').mockResolvedValue([
|
|
88
|
+
{ address: '93.184.216.34', family: 4 }, // example.com's actual IP
|
|
89
|
+
] as any);
|
|
90
|
+
|
|
91
|
+
// Mock fetch to prevent actual network calls
|
|
92
|
+
fetchSpy = spyOn(global, 'fetch').mockRejectedValue(new Error('Connection timeout'));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterEach(() => {
|
|
96
|
+
lookupSpy.mockRestore();
|
|
97
|
+
fetchSpy.mockRestore();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should reject localhost URLs', async () => {
|
|
101
|
+
// Localhost is rejected regardless of protocol
|
|
83
102
|
await expect(MCPClient.createRemote('http://localhost:8080/sse')).rejects.toThrow(
|
|
84
|
-
/SSRF Protection.*
|
|
103
|
+
/SSRF Protection.*localhost/
|
|
85
104
|
);
|
|
86
|
-
// HTTPS localhost is rejected for being localhost
|
|
87
105
|
await expect(MCPClient.createRemote('https://localhost:8080/sse')).rejects.toThrow(
|
|
88
106
|
/SSRF Protection.*localhost/
|
|
89
107
|
);
|
|
@@ -127,19 +145,13 @@ describe('MCPClient SSRF Protection', () => {
|
|
|
127
145
|
);
|
|
128
146
|
});
|
|
129
147
|
|
|
130
|
-
it('should
|
|
131
|
-
|
|
132
|
-
/SSRF Protection.*HTTPS/
|
|
133
|
-
);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it('should allow HTTP with allowInsecure option', async () => {
|
|
137
|
-
// This will fail due to network issues, not SSRF
|
|
148
|
+
it('should allow valid external domains', async () => {
|
|
149
|
+
// Valid external domains should pass SSRF validation (but may fail on actual connection)
|
|
138
150
|
const promise = MCPClient.createRemote(
|
|
139
|
-
'
|
|
151
|
+
'https://api.example.com/sse',
|
|
140
152
|
{},
|
|
141
153
|
100, // short timeout
|
|
142
|
-
{
|
|
154
|
+
{}
|
|
143
155
|
);
|
|
144
156
|
// Should NOT throw SSRF error, but will throw timeout/connection error
|
|
145
157
|
await expect(promise).rejects.not.toThrow(/SSRF Protection/);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
|
2
2
|
import * as child_process from 'node:child_process';
|
|
3
|
+
import * as dns from 'node:dns/promises';
|
|
3
4
|
import { EventEmitter } from 'node:events';
|
|
4
5
|
import { Readable, Writable } from 'node:stream';
|
|
5
6
|
import { MCPClient } from './mcp-client';
|
|
@@ -123,6 +124,19 @@ describe('MCPClient', () => {
|
|
|
123
124
|
});
|
|
124
125
|
|
|
125
126
|
describe('SSE Transport', () => {
|
|
127
|
+
let lookupSpy: ReturnType<typeof spyOn>;
|
|
128
|
+
|
|
129
|
+
beforeEach(() => {
|
|
130
|
+
// Mock DNS lookup to return a public IP for api.example.com
|
|
131
|
+
lookupSpy = spyOn(dns, 'lookup').mockResolvedValue([
|
|
132
|
+
{ address: '93.184.216.34', family: 4 }, // example.com's actual IP
|
|
133
|
+
] as any);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
afterEach(() => {
|
|
137
|
+
lookupSpy.mockRestore();
|
|
138
|
+
});
|
|
139
|
+
|
|
126
140
|
it('should connect and receive endpoint', async () => {
|
|
127
141
|
let controller: ReadableStreamDefaultController;
|
|
128
142
|
const stream = new ReadableStream({
|
|
@@ -133,14 +147,13 @@ describe('MCPClient', () => {
|
|
|
133
147
|
});
|
|
134
148
|
|
|
135
149
|
const fetchMock = spyOn(global, 'fetch').mockImplementation((url) => {
|
|
136
|
-
if (url === '
|
|
150
|
+
if (url === 'https://api.example.com/sse') {
|
|
137
151
|
return Promise.resolve(new Response(stream));
|
|
138
152
|
}
|
|
139
153
|
return Promise.resolve(new Response(JSON.stringify({ ok: true })));
|
|
140
154
|
});
|
|
141
155
|
|
|
142
|
-
const clientPromise = MCPClient.createRemote('
|
|
143
|
-
});
|
|
156
|
+
const clientPromise = MCPClient.createRemote('https://api.example.com/sse', {}, 60000, {});
|
|
144
157
|
|
|
145
158
|
const client = await clientPromise;
|
|
146
159
|
expect(client).toBeDefined();
|
|
@@ -162,7 +175,10 @@ describe('MCPClient', () => {
|
|
|
162
175
|
|
|
163
176
|
const response = await initPromise;
|
|
164
177
|
expect(response.result?.protocolVersion).toBe('2024-11-05');
|
|
165
|
-
expect(fetchMock).toHaveBeenCalledWith(
|
|
178
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
179
|
+
'https://api.example.com/endpoint',
|
|
180
|
+
expect.any(Object)
|
|
181
|
+
);
|
|
166
182
|
|
|
167
183
|
client.stop();
|
|
168
184
|
fetchMock.mockRestore();
|
|
@@ -180,14 +196,13 @@ describe('MCPClient', () => {
|
|
|
180
196
|
});
|
|
181
197
|
|
|
182
198
|
const fetchMock = spyOn(global, 'fetch').mockImplementation((url) => {
|
|
183
|
-
if (url === '
|
|
199
|
+
if (url === 'https://api.example.com/sse') {
|
|
184
200
|
return Promise.resolve(new Response(stream));
|
|
185
201
|
}
|
|
186
202
|
return Promise.resolve(new Response(JSON.stringify({ ok: true })));
|
|
187
203
|
});
|
|
188
204
|
|
|
189
|
-
const client = await MCPClient.createRemote('
|
|
190
|
-
});
|
|
205
|
+
const client = await MCPClient.createRemote('https://api.example.com/sse', {}, 60000, {});
|
|
191
206
|
|
|
192
207
|
// We can't easily hook into onMessage without reaching into internals
|
|
193
208
|
// Instead, we'll test that initialize resolves correctly when the response arrives
|
|
@@ -230,8 +245,7 @@ describe('MCPClient', () => {
|
|
|
230
245
|
)
|
|
231
246
|
);
|
|
232
247
|
|
|
233
|
-
const clientPromise = MCPClient.createRemote('
|
|
234
|
-
});
|
|
248
|
+
const clientPromise = MCPClient.createRemote('https://api.example.com/sse', {}, 60000, {});
|
|
235
249
|
|
|
236
250
|
await expect(clientPromise).rejects.toThrow(/SSE connection failed: 500/);
|
|
237
251
|
|
|
@@ -111,18 +111,20 @@ describe('shell-executor', () => {
|
|
|
111
111
|
expect(result.exitCode).toBe(1);
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
-
it('should
|
|
114
|
+
it('should allow shell commands with semicolons', async () => {
|
|
115
115
|
const step: ShellStep = {
|
|
116
116
|
id: 'test',
|
|
117
117
|
type: 'shell',
|
|
118
118
|
needs: [],
|
|
119
|
-
run: 'echo "hello" ;
|
|
119
|
+
run: 'echo "hello" ; echo "world"',
|
|
120
120
|
};
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
// Semicolons are allowed (denylist check is for dangerous commands, not syntax)
|
|
123
|
+
const result = await executeShell(step, context);
|
|
124
|
+
expect(result.exitCode).toBe(0);
|
|
123
125
|
});
|
|
124
126
|
|
|
125
|
-
it('should allow shell variable expansion like ${HOME}
|
|
127
|
+
it('should allow shell variable expansion like ${HOME}', async () => {
|
|
126
128
|
const step: ShellStep = {
|
|
127
129
|
id: 'test',
|
|
128
130
|
type: 'shell',
|
|
@@ -136,7 +138,7 @@ describe('shell-executor', () => {
|
|
|
136
138
|
expect(result.stdout.trim()).toBe(Bun.env.HOME || '');
|
|
137
139
|
});
|
|
138
140
|
|
|
139
|
-
it('should
|
|
141
|
+
it('should allow parameter expansion like ${IFS}', async () => {
|
|
140
142
|
const step: ShellStep = {
|
|
141
143
|
id: 'test',
|
|
142
144
|
type: 'shell',
|
|
@@ -144,11 +146,13 @@ describe('shell-executor', () => {
|
|
|
144
146
|
run: 'echo ${IFS}',
|
|
145
147
|
};
|
|
146
148
|
|
|
147
|
-
|
|
149
|
+
// ${IFS} is allowed (no active injection detection beyond denylist)
|
|
150
|
+
const result = await executeShell(step, context);
|
|
151
|
+
expect(result.exitCode).toBe(0);
|
|
148
152
|
});
|
|
149
153
|
|
|
150
|
-
it('should allow braces and quotes for JSON usage
|
|
151
|
-
// {} and quotes
|
|
154
|
+
it('should allow braces and quotes for JSON usage', async () => {
|
|
155
|
+
// {} and quotes for JSON handling
|
|
152
156
|
const step: ShellStep = {
|
|
153
157
|
id: 'test',
|
|
154
158
|
type: 'shell',
|
|
@@ -161,7 +165,7 @@ describe('shell-executor', () => {
|
|
|
161
165
|
expect(result.stdout.trim()).toBe('{"values": [1, 2, 3]}');
|
|
162
166
|
});
|
|
163
167
|
|
|
164
|
-
it('should allow flow control with semicolons
|
|
168
|
+
it('should allow flow control with semicolons', async () => {
|
|
165
169
|
const step: ShellStep = {
|
|
166
170
|
id: 'test',
|
|
167
171
|
type: 'shell',
|
|
@@ -168,13 +168,13 @@ describe('Standard Tools Integration', () => {
|
|
|
168
168
|
expect(toolStep.type).toBe('file');
|
|
169
169
|
});
|
|
170
170
|
|
|
171
|
-
it('should
|
|
171
|
+
it('should allow standard tools within security boundaries', async () => {
|
|
172
172
|
const step: LlmStep = {
|
|
173
173
|
id: 'l1',
|
|
174
174
|
type: 'llm',
|
|
175
175
|
agent: 'test-agent',
|
|
176
176
|
needs: [],
|
|
177
|
-
prompt: 'run
|
|
177
|
+
prompt: 'run command',
|
|
178
178
|
useStandardTools: true,
|
|
179
179
|
maxIterations: 2,
|
|
180
180
|
};
|
|
@@ -182,7 +182,7 @@ describe('Standard Tools Integration', () => {
|
|
|
182
182
|
const context: ExpressionContext = { inputs: {}, steps: {} };
|
|
183
183
|
const executeStepFn = mock(async () => ({ status: 'success', output: '' }));
|
|
184
184
|
|
|
185
|
-
// Mock makes a tool call to run_command
|
|
185
|
+
// Mock makes a tool call to run_command
|
|
186
186
|
currentChatFn = async () => {
|
|
187
187
|
return {
|
|
188
188
|
message: {
|
|
@@ -199,22 +199,18 @@ describe('Standard Tools Integration', () => {
|
|
|
199
199
|
};
|
|
200
200
|
setCurrentChatFn(currentChatFn as any);
|
|
201
201
|
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
);
|
|
213
|
-
} catch (e) {
|
|
214
|
-
// Expected to hit max iterations
|
|
215
|
-
}
|
|
202
|
+
// Should complete successfully
|
|
203
|
+
await executeLlmStep(
|
|
204
|
+
step,
|
|
205
|
+
context,
|
|
206
|
+
executeStepFn as unknown as (step: Step, context: ExpressionContext) => Promise<StepResult>,
|
|
207
|
+
undefined,
|
|
208
|
+
undefined,
|
|
209
|
+
undefined,
|
|
210
|
+
undefined
|
|
211
|
+
);
|
|
216
212
|
|
|
217
|
-
//
|
|
218
|
-
expect(executeStepFn).
|
|
213
|
+
// executeStepFn should have been called for the command
|
|
214
|
+
expect(executeStepFn).toHaveBeenCalled();
|
|
219
215
|
});
|
|
220
216
|
});
|
|
@@ -421,7 +421,7 @@ describe('step-executor', () => {
|
|
|
421
421
|
}),
|
|
422
422
|
};
|
|
423
423
|
|
|
424
|
-
it('should
|
|
424
|
+
it('should execute script', async () => {
|
|
425
425
|
// @ts-ignore
|
|
426
426
|
const step = {
|
|
427
427
|
id: 's1',
|
|
@@ -431,11 +431,11 @@ describe('step-executor', () => {
|
|
|
431
431
|
const result = await executeStep(step, context, undefined, {
|
|
432
432
|
sandbox: mockSandbox as unknown as typeof SafeSandbox,
|
|
433
433
|
});
|
|
434
|
-
expect(result.status).toBe('
|
|
435
|
-
expect(result.
|
|
434
|
+
expect(result.status).toBe('success');
|
|
435
|
+
expect(result.output).toBe('script-result');
|
|
436
436
|
});
|
|
437
437
|
|
|
438
|
-
it('should execute script
|
|
438
|
+
it('should execute script when provided', async () => {
|
|
439
439
|
// @ts-ignore
|
|
440
440
|
const step = {
|
|
441
441
|
id: 's1',
|
|
@@ -883,12 +883,12 @@ describe('step-executor', () => {
|
|
|
883
883
|
expect(secondCall.headers.Authorization).toBeUndefined();
|
|
884
884
|
});
|
|
885
885
|
|
|
886
|
-
it('should
|
|
886
|
+
it('should block localhost requests', async () => {
|
|
887
887
|
// @ts-ignore
|
|
888
888
|
global.fetch.mockResolvedValue(new Response('ok'));
|
|
889
889
|
|
|
890
890
|
const step: RequestStep = {
|
|
891
|
-
id: 'req-
|
|
891
|
+
id: 'req-localhost',
|
|
892
892
|
type: 'request',
|
|
893
893
|
needs: [],
|
|
894
894
|
url: 'http://localhost/test',
|
|
@@ -896,7 +896,8 @@ describe('step-executor', () => {
|
|
|
896
896
|
};
|
|
897
897
|
|
|
898
898
|
const result = await executeStep(step, context);
|
|
899
|
-
expect(result.status).toBe('
|
|
899
|
+
expect(result.status).toBe('failed');
|
|
900
|
+
expect(result.error).toContain('SSRF Protection');
|
|
900
901
|
});
|
|
901
902
|
});
|
|
902
903
|
|