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.
Files changed (36) hide show
  1. package/README.md +29 -4
  2. package/package.json +4 -16
  3. package/src/cli.ts +64 -4
  4. package/src/db/workflow-db.ts +16 -7
  5. package/src/expression/evaluator.audit.test.ts +67 -0
  6. package/src/expression/evaluator.test.ts +15 -2
  7. package/src/expression/evaluator.ts +102 -29
  8. package/src/parser/agent-parser.test.ts +6 -2
  9. package/src/parser/config-schema.ts +2 -0
  10. package/src/parser/schema.ts +2 -0
  11. package/src/parser/workflow-parser.test.ts +6 -2
  12. package/src/parser/workflow-parser.ts +22 -11
  13. package/src/runner/audit-verification.test.ts +12 -8
  14. package/src/runner/llm-adapter.ts +49 -12
  15. package/src/runner/llm-executor.test.ts +24 -6
  16. package/src/runner/llm-executor.ts +76 -44
  17. package/src/runner/mcp-client.audit.test.ts +79 -0
  18. package/src/runner/mcp-client.ts +103 -20
  19. package/src/runner/mcp-manager.ts +8 -2
  20. package/src/runner/shell-executor.test.ts +33 -15
  21. package/src/runner/shell-executor.ts +110 -39
  22. package/src/runner/step-executor.test.ts +30 -2
  23. package/src/runner/timeout.ts +2 -2
  24. package/src/runner/tool-integration.test.ts +8 -2
  25. package/src/runner/workflow-runner.ts +95 -29
  26. package/src/templates/agents/keystone-architect.md +5 -3
  27. package/src/types/status.ts +25 -0
  28. package/src/ui/dashboard.tsx +3 -1
  29. package/src/utils/auth-manager.test.ts +3 -1
  30. package/src/utils/auth-manager.ts +12 -2
  31. package/src/utils/config-loader.test.ts +2 -17
  32. package/src/utils/mermaid.ts +0 -8
  33. package/src/utils/redactor.ts +115 -22
  34. package/src/utils/sandbox.test.ts +9 -13
  35. package/src/utils/sandbox.ts +40 -53
  36. package/src/utils/workflow-registry.test.ts +6 -2
@@ -6,40 +6,81 @@
6
6
  */
7
7
 
8
8
  export class Redactor {
9
- private secrets: string[];
9
+ private patterns: RegExp[] = [];
10
10
 
11
11
  constructor(secrets: Record<string, string>) {
12
- // Extract all secret values (not keys) and sort by length descending
13
- // to ensure longer secrets are matched first.
14
- // Filter out very short secrets (length < 3) to avoid redacting common words/numbers.
15
- this.secrets = Object.values(secrets)
16
- .filter((value) => value && value.length >= 3)
17
- .sort((a, b) => b.length - a.length);
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 secret of this.secrets) {
30
- // Use a global replace to handle multiple occurrences
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', {}, { allowInsecureFallback: true });
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', {}, { allowInsecureFallback: true });
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
 
@@ -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 secure sandbox
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
- try {
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
- `\n⚠️ SECURITY WARNING: Using ${isBun ? 'Bun' : 'Node.js'} built-in VM for script execution.\n This sandbox is NOT secure against malicious code.\n Only run workflows from trusted sources.\n`
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
- private static warned = false;
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', () => {