keystone-cli 0.4.3 → 0.5.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 +29 -4
- package/package.json +4 -16
- 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/config-schema.ts +2 -0
- 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 +24 -6
- package/src/runner/llm-executor.ts +76 -44
- package/src/runner/mcp-client.audit.test.ts +79 -0
- package/src/runner/mcp-client.ts +103 -20
- package/src/runner/mcp-manager.ts +8 -2
- 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/src/utils/redactor.ts
CHANGED
|
@@ -6,40 +6,81 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
export class Redactor {
|
|
9
|
-
private
|
|
9
|
+
private patterns: RegExp[] = [];
|
|
10
10
|
|
|
11
11
|
constructor(secrets: Record<string, string>) {
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
// Keys that indicate high sensitivity - always redact their values regardless of length
|
|
13
|
+
const sensitiveKeys = new Set([
|
|
14
|
+
'api_key',
|
|
15
|
+
'apikey',
|
|
16
|
+
'token',
|
|
17
|
+
'secret',
|
|
18
|
+
'password',
|
|
19
|
+
'pswd',
|
|
20
|
+
'passwd',
|
|
21
|
+
'pwd',
|
|
22
|
+
'auth',
|
|
23
|
+
'credential',
|
|
24
|
+
'access_key',
|
|
25
|
+
'private_key',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
// Extract all secret values
|
|
29
|
+
// We filter based on:
|
|
30
|
+
// 1. Value must be a string and not empty
|
|
31
|
+
// 2. Either the key indicates high sensitivity OR length >= 3
|
|
32
|
+
const secretsToRedact = new Set<string>();
|
|
33
|
+
|
|
34
|
+
for (const [key, value] of Object.entries(secrets)) {
|
|
35
|
+
if (!value) continue;
|
|
36
|
+
|
|
37
|
+
const lowerKey = key.toLowerCase();
|
|
38
|
+
// Check if key contains any sensitive term
|
|
39
|
+
const isSensitiveKey = Array.from(sensitiveKeys).some((k) => lowerKey.includes(k));
|
|
40
|
+
|
|
41
|
+
if (isSensitiveKey || value.length >= 3) {
|
|
42
|
+
secretsToRedact.add(value);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const uniqueSecrets = Array.from(secretsToRedact).sort((a, b) => b.length - a.length);
|
|
47
|
+
|
|
48
|
+
for (const secret of uniqueSecrets) {
|
|
49
|
+
// Escape special regex characters in the secret
|
|
50
|
+
const escaped = secret.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
51
|
+
|
|
52
|
+
// Use word boundaries if the secret starts/ends with an alphanumeric character
|
|
53
|
+
// to avoid partial matches (e.g. redacting 'mark' in 'marketplace')
|
|
54
|
+
// BUT only if length is small (< 5), otherwise matching inside strings is desirable
|
|
55
|
+
let pattern: RegExp;
|
|
56
|
+
if (secret.length < 5) {
|
|
57
|
+
const startBoundary = /^\w/.test(secret) ? '\\b' : '';
|
|
58
|
+
const endBoundary = /\w$/.test(secret) ? '\\b' : '';
|
|
59
|
+
pattern = new RegExp(`${startBoundary}${escaped}${endBoundary}`, 'g');
|
|
60
|
+
} else {
|
|
61
|
+
pattern = new RegExp(escaped, 'g');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this.patterns.push(pattern);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Capture the maximum length for buffering purposes
|
|
68
|
+
this.maxSecretLength = uniqueSecrets.reduce((max, s) => Math.max(max, s.length), 0);
|
|
18
69
|
}
|
|
19
70
|
|
|
71
|
+
public readonly maxSecretLength: number;
|
|
72
|
+
|
|
20
73
|
/**
|
|
21
74
|
* Redact all secrets from a string
|
|
22
75
|
*/
|
|
23
76
|
redact(text: string): string {
|
|
24
|
-
if (!text || typeof text !== 'string') {
|
|
77
|
+
if (!text || typeof text !== 'string' || text.length < 3) {
|
|
25
78
|
return text;
|
|
26
79
|
}
|
|
27
80
|
|
|
28
81
|
let redacted = text;
|
|
29
|
-
for (const
|
|
30
|
-
|
|
31
|
-
// Escape special regex characters in the secret
|
|
32
|
-
const escaped = secret.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
33
|
-
|
|
34
|
-
// Use word boundaries if the secret starts/ends with an alphanumeric character
|
|
35
|
-
// to avoid partial matches (e.g. redacting 'mark' in 'marketplace')
|
|
36
|
-
const startBoundary = /^\w/.test(secret) ? '\\b' : '';
|
|
37
|
-
const endBoundary = /\w$/.test(secret) ? '\\b' : '';
|
|
38
|
-
|
|
39
|
-
redacted = redacted.replace(
|
|
40
|
-
new RegExp(`${startBoundary}${escaped}${endBoundary}`, 'g'),
|
|
41
|
-
'***REDACTED***'
|
|
42
|
-
);
|
|
82
|
+
for (const pattern of this.patterns) {
|
|
83
|
+
redacted = redacted.replace(pattern, '***REDACTED***');
|
|
43
84
|
}
|
|
44
85
|
return redacted;
|
|
45
86
|
}
|
|
@@ -67,3 +108,55 @@ export class Redactor {
|
|
|
67
108
|
return value;
|
|
68
109
|
}
|
|
69
110
|
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Buffer for streaming redaction
|
|
114
|
+
* Ensures secrets split across chunks are properly masked
|
|
115
|
+
*/
|
|
116
|
+
export class RedactionBuffer {
|
|
117
|
+
private buffer = '';
|
|
118
|
+
private redactor: Redactor;
|
|
119
|
+
|
|
120
|
+
constructor(redactor: Redactor) {
|
|
121
|
+
this.redactor = redactor;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Process a chunk of text and return the safe-to-print portion
|
|
126
|
+
*/
|
|
127
|
+
process(chunk: string): string {
|
|
128
|
+
// Append new chunk to buffer
|
|
129
|
+
this.buffer += chunk;
|
|
130
|
+
|
|
131
|
+
// Redact the entire buffer
|
|
132
|
+
// This allows us to catch secrets that were completed by the new chunk
|
|
133
|
+
const redactedBuffer = this.redactor.redact(this.buffer);
|
|
134
|
+
|
|
135
|
+
// If buffer is smaller than max secret length, we can't be sure it's safe to output yet
|
|
136
|
+
// (it might be the start of a secret)
|
|
137
|
+
if (redactedBuffer.length < this.redactor.maxSecretLength) {
|
|
138
|
+
this.buffer = redactedBuffer;
|
|
139
|
+
return '';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Keep the tail of the buffer (max secret length) to handle potential split secrets
|
|
143
|
+
// Output everything before the tail
|
|
144
|
+
const safeLength = redactedBuffer.length - this.redactor.maxSecretLength;
|
|
145
|
+
const output = redactedBuffer.substring(0, safeLength);
|
|
146
|
+
|
|
147
|
+
// Update buffer to just the tail
|
|
148
|
+
this.buffer = redactedBuffer.substring(safeLength);
|
|
149
|
+
|
|
150
|
+
return output;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Flush any remaining content in the buffer
|
|
155
|
+
* Call this when the stream ends
|
|
156
|
+
*/
|
|
157
|
+
flush(): string {
|
|
158
|
+
const final = this.redactor.redact(this.buffer);
|
|
159
|
+
this.buffer = '';
|
|
160
|
+
return final;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -1,32 +1,28 @@
|
|
|
1
|
-
import { describe, expect, it } from 'bun:test';
|
|
1
|
+
import { afterEach, describe, expect, it } from 'bun:test';
|
|
2
2
|
import { SafeSandbox } from './sandbox';
|
|
3
3
|
|
|
4
4
|
describe('SafeSandbox', () => {
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
SafeSandbox.resetWarning();
|
|
7
|
+
});
|
|
8
|
+
|
|
5
9
|
it('should execute basic arithmetic', async () => {
|
|
6
|
-
const result = await SafeSandbox.execute('1 + 2', {}
|
|
10
|
+
const result = await SafeSandbox.execute('1 + 2', {});
|
|
7
11
|
expect(result).toBe(3);
|
|
8
12
|
});
|
|
9
13
|
|
|
10
14
|
it('should have access to context variables', async () => {
|
|
11
|
-
const result = await SafeSandbox.execute(
|
|
12
|
-
'a + b',
|
|
13
|
-
{ a: 10, b: 20 },
|
|
14
|
-
{ allowInsecureFallback: true }
|
|
15
|
-
);
|
|
15
|
+
const result = await SafeSandbox.execute('a + b', { a: 10, b: 20 });
|
|
16
16
|
expect(result).toBe(30);
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
it('should not have access to Node.js globals', async () => {
|
|
20
|
-
const result = await SafeSandbox.execute('typeof process', {}
|
|
20
|
+
const result = await SafeSandbox.execute('typeof process', {});
|
|
21
21
|
expect(result).toBe('undefined');
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
it('should handle object results', async () => {
|
|
25
|
-
const result = await SafeSandbox.execute(
|
|
26
|
-
'({ x: 1, y: 2 })',
|
|
27
|
-
{},
|
|
28
|
-
{ allowInsecureFallback: true }
|
|
29
|
-
);
|
|
25
|
+
const result = await SafeSandbox.execute('({ x: 1, y: 2 })', {});
|
|
30
26
|
expect(result).toEqual({ x: 1, y: 2 });
|
|
31
27
|
});
|
|
32
28
|
|
package/src/utils/sandbox.ts
CHANGED
|
@@ -1,71 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox for executing untrusted script code.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ IMPORTANT: Bun Runtime Compatibility
|
|
5
|
+
*
|
|
6
|
+
* This project runs on Bun, which uses JavaScriptCore (JSC), NOT V8.
|
|
7
|
+
* The `isolated-vm` package binds to V8's C++ API and CANNOT work with Bun.
|
|
8
|
+
*
|
|
9
|
+
* As a result, we use Node.js's built-in `vm` module (which Bun implements
|
|
10
|
+
* via a compatibility layer). This provides basic sandboxing but is NOT
|
|
11
|
+
* secure against determined attackers.
|
|
12
|
+
*
|
|
13
|
+
* SECURITY IMPLICATIONS:
|
|
14
|
+
* - The `vm` module does NOT provide true isolation
|
|
15
|
+
* - Malicious code could potentially escape the sandbox
|
|
16
|
+
* - Only run workflows/scripts from TRUSTED sources
|
|
17
|
+
*
|
|
18
|
+
* For production use with untrusted code, consider:
|
|
19
|
+
* 1. Running in a separate subprocess with OS-level isolation
|
|
20
|
+
* 2. Using containers or VMs for full isolation
|
|
21
|
+
* 3. Running on Node.js with isolated-vm instead of Bun
|
|
22
|
+
*/
|
|
23
|
+
|
|
1
24
|
import * as vm from 'node:vm';
|
|
2
25
|
|
|
3
26
|
export interface SandboxOptions {
|
|
4
27
|
timeout?: number;
|
|
5
|
-
memoryLimit?: number;
|
|
6
|
-
allowInsecureFallback?: boolean;
|
|
28
|
+
memoryLimit?: number; // Note: memoryLimit is not enforced by node:vm
|
|
7
29
|
}
|
|
8
30
|
|
|
9
31
|
export class SafeSandbox {
|
|
32
|
+
private static warned = false;
|
|
33
|
+
|
|
10
34
|
/**
|
|
11
|
-
* Execute a script in a
|
|
35
|
+
* Execute a script in a sandbox.
|
|
36
|
+
*
|
|
37
|
+
* Note: On Bun, this uses node:vm which provides basic isolation but
|
|
38
|
+
* is not secure against malicious code. Only run trusted scripts.
|
|
12
39
|
*/
|
|
13
40
|
static async execute(
|
|
14
41
|
code: string,
|
|
15
42
|
context: Record<string, unknown> = {},
|
|
16
43
|
options: SandboxOptions = {}
|
|
17
44
|
): Promise<unknown> {
|
|
18
|
-
|
|
19
|
-
// Try to use isolated-vm if available (dynamic import)
|
|
20
|
-
// Note: This will likely fail on Bun as it expects V8 host symbols
|
|
21
|
-
const ivm = await import('isolated-vm').then((m) => m.default || m).catch(() => null);
|
|
22
|
-
|
|
23
|
-
if (ivm && typeof ivm.Isolate === 'function') {
|
|
24
|
-
const isolate = new ivm.Isolate({ memoryLimit: options.memoryLimit || 128 });
|
|
25
|
-
try {
|
|
26
|
-
const contextInstance = await isolate.createContext();
|
|
27
|
-
const jail = contextInstance.global;
|
|
28
|
-
|
|
29
|
-
// Set up global context
|
|
30
|
-
await jail.set('global', jail.derefInto());
|
|
31
|
-
|
|
32
|
-
// Inject context variables
|
|
33
|
-
for (const [key, value] of Object.entries(context)) {
|
|
34
|
-
// Only copy non-undefined values
|
|
35
|
-
if (value !== undefined) {
|
|
36
|
-
await jail.set(key, new ivm.ExternalCopy(value).copyInto());
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const script = await isolate.compileScript(code);
|
|
41
|
-
const result = await script.run(contextInstance, { timeout: options.timeout || 5000 });
|
|
42
|
-
|
|
43
|
-
if (result && typeof result === 'object' && result instanceof ivm.Reference) {
|
|
44
|
-
return await result.copy();
|
|
45
|
-
}
|
|
46
|
-
return result;
|
|
47
|
-
} finally {
|
|
48
|
-
isolate.dispose();
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
} catch (e) {
|
|
52
|
-
// If isolated-vm failed during execution (not just loading), log it if we're debugging
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Fallback implementation using node:vm (built-in)
|
|
56
|
-
// ONLY allowed if explicitly opted in via options
|
|
57
|
-
if (!options.allowInsecureFallback) {
|
|
58
|
-
throw new Error(
|
|
59
|
-
'Execution in secure sandbox failed (isolated-vm not available) and insecure fallback is disabled.\n' +
|
|
60
|
-
'To allow insecure execution, set allowInsecureFallback: true in sandbox options.\n' +
|
|
61
|
-
'Note: Insecure execution is NOT recommended for untrusted code.'
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
|
|
45
|
+
// Show warning once per process
|
|
65
46
|
if (!SafeSandbox.warned) {
|
|
66
|
-
const isBun = typeof Bun !== 'undefined';
|
|
67
47
|
console.warn(
|
|
68
|
-
|
|
48
|
+
'\n⚠️ SECURITY WARNING: Using Bun/Node.js built-in VM for script execution.\n' +
|
|
49
|
+
' This sandbox is NOT secure against malicious code.\n' +
|
|
50
|
+
' Only run workflows from trusted sources.\n'
|
|
69
51
|
);
|
|
70
52
|
SafeSandbox.warned = true;
|
|
71
53
|
}
|
|
@@ -77,5 +59,10 @@ export class SafeSandbox {
|
|
|
77
59
|
});
|
|
78
60
|
}
|
|
79
61
|
|
|
80
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Reset the warning state (useful for testing)
|
|
64
|
+
*/
|
|
65
|
+
static resetWarning(): void {
|
|
66
|
+
SafeSandbox.warned = false;
|
|
67
|
+
}
|
|
81
68
|
}
|
|
@@ -15,13 +15,17 @@ describe('WorkflowRegistry', () => {
|
|
|
15
15
|
beforeAll(() => {
|
|
16
16
|
try {
|
|
17
17
|
mkdirSync(tempWorkflowsDir, { recursive: true });
|
|
18
|
-
} catch (e) {
|
|
18
|
+
} catch (e) {
|
|
19
|
+
// Ignore cleanup error
|
|
20
|
+
}
|
|
19
21
|
});
|
|
20
22
|
|
|
21
23
|
afterAll(() => {
|
|
22
24
|
try {
|
|
23
25
|
rmSync(tempWorkflowsDir, { recursive: true, force: true });
|
|
24
|
-
} catch (e) {
|
|
26
|
+
} catch (e) {
|
|
27
|
+
// Ignore cleanup error
|
|
28
|
+
}
|
|
25
29
|
});
|
|
26
30
|
|
|
27
31
|
it('should list workflows in the workflows directory', () => {
|