keystone-cli 2.0.0 → 2.1.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 (57) hide show
  1. package/README.md +43 -4
  2. package/package.json +4 -1
  3. package/src/cli.ts +1 -0
  4. package/src/commands/event.ts +9 -0
  5. package/src/commands/run.ts +17 -0
  6. package/src/db/dynamic-state-manager.ts +12 -9
  7. package/src/db/memory-db.test.ts +19 -1
  8. package/src/db/memory-db.ts +101 -22
  9. package/src/db/workflow-db.ts +181 -9
  10. package/src/expression/evaluator.ts +4 -1
  11. package/src/parser/config-schema.ts +6 -0
  12. package/src/parser/schema.ts +1 -0
  13. package/src/runner/__test__/llm-test-setup.ts +43 -11
  14. package/src/runner/durable-timers.test.ts +1 -1
  15. package/src/runner/executors/dynamic-executor.ts +125 -88
  16. package/src/runner/executors/engine-executor.ts +10 -39
  17. package/src/runner/executors/file-executor.ts +67 -0
  18. package/src/runner/executors/foreach-executor.ts +170 -17
  19. package/src/runner/executors/human-executor.ts +18 -0
  20. package/src/runner/executors/llm/stream-handler.ts +103 -0
  21. package/src/runner/executors/llm/tool-manager.ts +360 -0
  22. package/src/runner/executors/llm-executor.ts +288 -555
  23. package/src/runner/executors/memory-executor.ts +41 -34
  24. package/src/runner/executors/shell-executor.ts +96 -52
  25. package/src/runner/executors/subworkflow-executor.ts +16 -0
  26. package/src/runner/executors/types.ts +3 -1
  27. package/src/runner/executors/verification_fixes.test.ts +46 -0
  28. package/src/runner/join-scheduling.test.ts +2 -1
  29. package/src/runner/llm-adapter.integration.test.ts +10 -5
  30. package/src/runner/llm-adapter.ts +57 -18
  31. package/src/runner/llm-clarification.test.ts +4 -1
  32. package/src/runner/llm-executor.test.ts +21 -7
  33. package/src/runner/mcp-client.ts +36 -2
  34. package/src/runner/mcp-server.ts +65 -36
  35. package/src/runner/recovery-security.test.ts +5 -2
  36. package/src/runner/reflexion.test.ts +6 -3
  37. package/src/runner/services/context-builder.ts +13 -4
  38. package/src/runner/services/workflow-validator.ts +2 -1
  39. package/src/runner/standard-tools-ast.test.ts +4 -2
  40. package/src/runner/standard-tools-execution.test.ts +14 -1
  41. package/src/runner/standard-tools-integration.test.ts +6 -0
  42. package/src/runner/standard-tools.ts +13 -10
  43. package/src/runner/step-executor.ts +2 -2
  44. package/src/runner/tool-integration.test.ts +4 -1
  45. package/src/runner/workflow-runner.test.ts +23 -12
  46. package/src/runner/workflow-runner.ts +172 -79
  47. package/src/runner/workflow-state.ts +181 -111
  48. package/src/ui/dashboard.tsx +17 -3
  49. package/src/utils/config-loader.ts +4 -0
  50. package/src/utils/constants.ts +4 -0
  51. package/src/utils/context-injector.test.ts +27 -27
  52. package/src/utils/context-injector.ts +68 -26
  53. package/src/utils/process-sandbox.ts +138 -148
  54. package/src/utils/redactor.ts +39 -9
  55. package/src/utils/resource-loader.ts +24 -19
  56. package/src/utils/sandbox.ts +6 -0
  57. package/src/utils/stream-utils.ts +58 -0
@@ -1,6 +1,8 @@
1
- import * as fs from 'node:fs';
1
+ import { existsSync } from 'node:fs'; // Keep for synchronous fallbacks if absolutely needed, but prefer async
2
+ import * as fs from 'node:fs/promises';
2
3
  import * as path from 'node:path';
3
- import { globSync } from 'glob';
4
+ import { glob } from 'glob';
5
+ import { minimatch } from 'minimatch';
4
6
  import { ConfigLoader } from './config-loader';
5
7
 
6
8
  export interface ContextData {
@@ -23,17 +25,27 @@ export class ContextInjector {
23
25
  private static contextCache = new Map<string, { context: ContextData; timestamp: number }>();
24
26
  private static CACHE_TTL_MS = 60000; // 1 minute cache
25
27
 
28
+ // Helper to check file existence asynchronously
29
+ private static async exists(filePath: string): Promise<boolean> {
30
+ try {
31
+ await fs.access(filePath);
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
26
38
  /**
27
39
  * Find the project root by looking for common project markers
28
40
  */
29
- static findProjectRoot(startPath: string): string {
41
+ static async findProjectRoot(startPath: string): Promise<string> {
30
42
  const markers = ['.git', 'package.json', 'Cargo.toml', 'go.mod', 'pyproject.toml', '.keystone'];
31
43
  let current = path.resolve(startPath);
32
44
  const root = path.parse(current).root;
33
45
 
34
46
  while (current !== root) {
35
47
  for (const marker of markers) {
36
- if (fs.existsSync(path.join(current, marker))) {
48
+ if (await ContextInjector.exists(path.join(current, marker))) {
37
49
  return current;
38
50
  }
39
51
  }
@@ -46,9 +58,12 @@ export class ContextInjector {
46
58
  /**
47
59
  * Scan directories for README.md and AGENTS.md files
48
60
  */
49
- static scanDirectoryContext(dir: string, depth = 3): Omit<ContextData, 'cursorRules'> {
61
+ static async scanDirectoryContext(
62
+ dir: string,
63
+ depth = 3
64
+ ): Promise<Omit<ContextData, 'cursorRules'>> {
50
65
  const result: Omit<ContextData, 'cursorRules'> = {};
51
- const projectRoot = ContextInjector.findProjectRoot(dir);
66
+ const projectRoot = await ContextInjector.findProjectRoot(dir);
52
67
  let current = path.resolve(dir);
53
68
 
54
69
  // Walk from current dir up to project root, limited by depth
@@ -56,9 +71,9 @@ export class ContextInjector {
56
71
  // Check for README.md (only use first one found, closest to working dir)
57
72
  if (!result.readme) {
58
73
  const readmePath = path.join(current, 'README.md');
59
- if (fs.existsSync(readmePath)) {
74
+ if (await ContextInjector.exists(readmePath)) {
60
75
  try {
61
- result.readme = fs.readFileSync(readmePath, 'utf-8');
76
+ result.readme = await fs.readFile(readmePath, 'utf-8');
62
77
  } catch {
63
78
  // Ignore read errors
64
79
  }
@@ -68,9 +83,9 @@ export class ContextInjector {
68
83
  // Check for AGENTS.md (only use first one found, closest to working dir)
69
84
  if (!result.agentsMd) {
70
85
  const agentsMdPath = path.join(current, 'AGENTS.md');
71
- if (fs.existsSync(agentsMdPath)) {
86
+ if (await ContextInjector.exists(agentsMdPath)) {
72
87
  try {
73
- result.agentsMd = fs.readFileSync(agentsMdPath, 'utf-8');
88
+ result.agentsMd = await fs.readFile(agentsMdPath, 'utf-8');
74
89
  } catch {
75
90
  // Ignore read errors
76
91
  }
@@ -89,25 +104,27 @@ export class ContextInjector {
89
104
  /**
90
105
  * Scan for .cursor/rules or .claude/rules files that apply to accessed files
91
106
  */
92
- static scanRules(filesAccessed: string[]): string[] {
107
+ static async scanRules(filesAccessed: string[]): Promise<string[]> {
93
108
  const rules: string[] = [];
94
109
  const rulesDirs = ['.cursor/rules', '.claude/rules'];
95
- const projectRoot =
96
- filesAccessed.length > 0
97
- ? ContextInjector.findProjectRoot(path.dirname(filesAccessed[0]))
98
- : process.cwd();
110
+
111
+ let projectRoot = process.cwd();
112
+ if (filesAccessed.length > 0) {
113
+ projectRoot = await ContextInjector.findProjectRoot(path.dirname(filesAccessed[0]));
114
+ }
99
115
 
100
116
  for (const rulesDir of rulesDirs) {
101
117
  const rulesPath = path.join(projectRoot, rulesDir);
102
- if (!fs.existsSync(rulesPath)) continue;
118
+ if (!(await ContextInjector.exists(rulesPath))) continue;
103
119
 
104
120
  try {
105
- const files = fs.readdirSync(rulesPath);
121
+ const files = await fs.readdir(rulesPath);
106
122
  for (const file of files) {
107
123
  const rulePath = path.join(rulesPath, file);
108
- if (!fs.statSync(rulePath).isFile()) continue;
124
+ const stats = await fs.stat(rulePath);
125
+ if (!stats.isFile()) continue;
109
126
 
110
- const content = fs.readFileSync(rulePath, 'utf-8');
127
+ const content = await fs.readFile(rulePath, 'utf-8');
111
128
 
112
129
  // Check if rule applies to any of the accessed files
113
130
  // Rules can have a glob pattern on the first line prefixed with "applies:"
@@ -117,13 +134,35 @@ export class ContextInjector {
117
134
  const matchesAny = filesAccessed.some((f) => {
118
135
  const relativePath = path.relative(projectRoot, f);
119
136
  try {
120
- const matches = globSync(pattern, { cwd: projectRoot });
121
- return matches.includes(relativePath);
137
+ // Using synchronous glob logic for pattern matching against specific files is tricky with 'glob' package
138
+ // 'glob' package usually searches the filesystem.
139
+ // We want minimatch-style matching.
140
+ // Typically 'glob' exports 'minimatch' or we use 'minimatch' package.
141
+ // Assuming we can fallback to checking if the file matches the glob by expanding the glob?
142
+ // Or simplified: use glob to find files matching pattern and see if ours is in it.
143
+ // NOTE: For performance, ideally we'd use 'minimatch'. But we don't know if it's installed.
144
+ // We'll stick to 'glob' to list matches and check inclusion.
145
+ // This might be slow if the glob matches EVERYTHING.
146
+ // Optimization: If pattern is simple, maybe regex.
147
+ // Given constraints, we will attempt to limit the scope or assume 'glob' is efficient enough.
148
+ // Actually, 'glob' function is async.
149
+ return false; // Placeholder, real impl below
122
150
  } catch {
123
151
  return false;
124
152
  }
125
153
  });
126
- if (!matchesAny) continue;
154
+
155
+ // Use minimatch to check if the file matches the pattern
156
+ // This avoids scanning the entire filesystem with glob
157
+ const relativePath = path.relative(projectRoot, filesAccessed[0]); // Check the first file for now, or loop all
158
+
159
+ // Note: In real usage, we should probably check against ALL accessed files.
160
+ // The current logic only checked filesAccessed vs the glob list.
161
+ const isMatch = filesAccessed.some((f) =>
162
+ minimatch(path.relative(projectRoot, f), pattern)
163
+ );
164
+
165
+ if (!isMatch) continue;
127
166
  }
128
167
 
129
168
  rules.push(content);
@@ -174,11 +213,11 @@ export class ContextInjector {
174
213
  /**
175
214
  * Get context for a directory, using cache if available
176
215
  */
177
- static getContext(
216
+ static async getContext(
178
217
  dir: string,
179
218
  filesAccessed: string[],
180
219
  config?: ContextInjectorConfig
181
- ): ContextData {
220
+ ): Promise<ContextData> {
182
221
  // Default config from ConfigLoader
183
222
  let effectiveConfig = config;
184
223
  if (!effectiveConfig) {
@@ -216,7 +255,10 @@ export class ContextInjector {
216
255
  effectiveConfig.sources.includes('readme') ||
217
256
  effectiveConfig.sources.includes('agents_md')
218
257
  ) {
219
- const dirContext = ContextInjector.scanDirectoryContext(dir, effectiveConfig.search_depth);
258
+ const dirContext = await ContextInjector.scanDirectoryContext(
259
+ dir,
260
+ effectiveConfig.search_depth
261
+ );
220
262
  if (effectiveConfig.sources.includes('readme')) {
221
263
  context.readme = dirContext.readme;
222
264
  }
@@ -226,7 +268,7 @@ export class ContextInjector {
226
268
  }
227
269
 
228
270
  if (effectiveConfig.sources.includes('cursor_rules')) {
229
- context.cursorRules = ContextInjector.scanRules(filesAccessed);
271
+ context.cursorRules = await ContextInjector.scanRules(filesAccessed);
230
272
  }
231
273
 
232
274
  // Cache the result
@@ -66,19 +66,24 @@ export class ProcessSandbox {
66
66
  const timeout = options.timeout ?? TIMEOUTS.DEFAULT_SCRIPT_TIMEOUT_MS;
67
67
  const tempDir = join(tmpdir(), `keystone-sandbox-${randomUUID()}`);
68
68
 
69
+ // Security Check: Prevent dynamic import usage
70
+ if (/\bimport\s*\(/.test(code)) {
71
+ throw new Error('Security Error: Dynamic imports are not allowed in sandboxed scripts.');
72
+ }
73
+
69
74
  try {
70
75
  // Create temp directory with restrictive permissions (0o700 = owner only)
71
76
  await mkdir(tempDir, { recursive: true, mode: FILE_MODES.SECURE_DIR });
72
77
 
73
78
  // Write the runner script
74
- const runnerScript = ProcessSandbox.createRunnerScript(code, context, !!options.useWorker);
79
+ const runnerScript = ProcessSandbox.createRunnerScript(code, !!options.useWorker);
75
80
  const scriptPath = join(tempDir, 'script.js');
76
81
  await writeFile(scriptPath, runnerScript, 'utf-8');
77
82
 
78
83
  // Execute in subprocess or worker
79
84
  const result = options.useWorker
80
- ? await ProcessSandbox.runInWorker(scriptPath, timeout, options)
81
- : await ProcessSandbox.runInSubprocess(scriptPath, timeout, options);
85
+ ? await ProcessSandbox.runInWorker(scriptPath, timeout, options, context)
86
+ : await ProcessSandbox.runInSubprocess(scriptPath, timeout, options, context);
82
87
 
83
88
  if (options.signal?.aborted) {
84
89
  throw new Error('Script execution aborted');
@@ -106,153 +111,126 @@ export class ProcessSandbox {
106
111
  /**
107
112
  * Create the runner script that will be executed in the subprocess or worker.
108
113
  */
109
- private static createRunnerScript(
110
- code: string,
111
- context: Record<string, unknown>,
112
- isWorker: boolean
113
- ): string {
114
- // Check for prototype pollution attempts before serialization
115
- const dangerousKeys = ['__proto__', 'constructor', 'prototype'];
116
- const checkForDangerousKeys = (
117
- obj: unknown,
118
- path = '',
119
- visited = new WeakSet<object>()
120
- ): void => {
121
- if (obj === null || typeof obj !== 'object') return;
122
-
123
- // Prevent infinite loops with circular references
124
- if (visited.has(obj)) return;
125
- visited.add(obj);
126
-
127
- // Handle arrays - check each element
128
- if (Array.isArray(obj)) {
129
- obj.forEach((item, idx) => checkForDangerousKeys(item, `${path}[${idx}]`, visited));
130
- return;
131
- }
132
-
133
- // Use getOwnPropertyNames to catch non-enumerable properties too
134
- for (const key of Object.getOwnPropertyNames(obj)) {
135
- if (dangerousKeys.includes(key)) {
136
- throw new Error(
137
- `Security Error: Context contains forbidden key "${key}"${path ? ` at path "${path}"` : ''}. This may indicate a prototype pollution attack.`
138
- );
139
- }
140
- checkForDangerousKeys(
141
- (obj as Record<string, unknown>)[key],
142
- path ? `${path}.${key}` : key,
143
- visited
144
- );
145
- }
146
- };
147
- checkForDangerousKeys(context);
148
-
149
- // Sanitize context and handle circular references
150
- const safeStringify = (obj: unknown) => {
151
- const seen = new WeakSet();
152
- return JSON.stringify(obj, (key, value) => {
153
- if (typeof value === 'object' && value !== null) {
154
- if (seen.has(value)) return '[Circular]';
155
- seen.add(value);
156
- }
157
- return value;
158
- });
159
- };
160
-
161
- // We still want to parse/stringify to strip non-serializable values and ensure clean state
162
- // but now we handle cycles safely
163
- const contextJson = safeStringify(context);
164
-
114
+ private static createRunnerScript(code: string, isWorker: boolean): string {
165
115
  return `
166
- // Minimal sandbox environment
167
- // Context is sanitized through JSON parse/stringify to prevent prototype pollution
168
- const context = ${contextJson};
169
-
170
- // Use explicit flag passed from host
171
- const isWorker = ${isWorker};
172
-
173
- // Capture essential functions before deleting dangerous globals
174
- const __write = !isWorker ? process.stdout.write.bind(process.stdout) : null;
175
- const __post = isWorker ? self.postMessage.bind(self) : null;
176
-
177
- // Remove dangerous globals to prevent sandbox escape
178
- const dangerousGlobals = [
179
- 'process',
180
- 'require',
181
- 'module',
182
- 'exports',
183
- '__dirname',
184
- '__filename',
185
- 'Bun',
186
- 'fetch',
187
- 'crypto',
188
- 'Worker',
189
- 'navigator',
190
- 'performance',
191
- 'alert',
192
- 'confirm',
193
- 'prompt',
194
- 'addEventListener',
195
- 'dispatchEvent',
196
- 'removeEventListener',
197
- 'onmessage',
198
- 'onerror',
199
- 'ErrorEvent',
200
- ];
201
-
202
- for (const g of dangerousGlobals) {
203
- try {
204
- delete globalThis[g];
205
- } catch (e) {
206
- // Ignore errors for non-deletable properties
116
+ (async () => {
117
+ const isWorker = ${isWorker};
118
+ let context = {};
119
+
120
+ if (isWorker) {
121
+ await new Promise((resolve) => {
122
+ const handler = (e) => {
123
+ if (e.data && e.data.type === 'context') {
124
+ context = e.data.context;
125
+ self.removeEventListener('message', handler);
126
+ resolve();
127
+ }
128
+ };
129
+ self.addEventListener('message', handler);
130
+ });
131
+ } else {
132
+ const fs = require('node:fs');
133
+ try {
134
+ const contextData = fs.readFileSync(0, 'utf-8');
135
+ context = JSON.parse(contextData);
136
+ } catch (e) {
137
+ throw new Error('Failed to load sandbox context from stdin: ' + e.message);
138
+ }
207
139
  }
208
- }
209
-
210
- // Make context variables available
211
- Object.assign(globalThis, context);
212
140
 
213
- // Custom console that prefixes logs for the runner to intercept
214
- const __keystone_console = {
215
- log: (...args) => {
216
- if (isWorker) {
217
- __post({ type: 'log', message: args.join(' ') });
218
- } else {
219
- __write('__SANDBOX_LOG__:' + args.join(' ') + '\\n');
141
+ // Security: Prevent prototype pollution check on loaded context
142
+ const dangerousKeys = ['__proto__', 'constructor', 'prototype'];
143
+ const checkKeys = (obj) => {
144
+ if (!obj || typeof obj !== 'object') return;
145
+ for (const key of Object.getOwnPropertyNames(obj)) {
146
+ if (dangerousKeys.includes(key)) throw new Error('Security Error: Forbidden key in context: ' + key);
147
+ checkKeys(obj[key]);
220
148
  }
221
- },
222
- error: (...args) => {
223
- if (isWorker) {
224
- __post({ type: 'log', message: 'ERROR: ' + args.join(' ') });
225
- } else {
226
- __write('__SANDBOX_LOG__:ERROR: ' + args.join(' ') + '\\n');
227
- }
228
- },
229
- warn: (...args) => {
230
- if (isWorker) {
231
- __post({ type: 'log', message: 'WARN: ' + args.join(' ') });
232
- } else {
233
- __write('__SANDBOX_LOG__:WARN: ' + args.join(' ') + '\\n');
234
- }
235
- },
236
- info: (...args) => {
237
- if (isWorker) {
238
- __post({ type: 'log', message: 'INFO: ' + args.join(' ') });
239
- } else {
240
- __write('__SANDBOX_LOG__:INFO: ' + args.join(' ') + '\\n');
149
+ };
150
+ checkKeys(context);
151
+
152
+ // Capture essential functions before deleting dangerous globals
153
+ const __write = !isWorker ? process.stdout.write.bind(process.stdout) : null;
154
+ const __post = isWorker ? self.postMessage.bind(self) : null;
155
+
156
+ // Custom console that prefixes logs for the runner to intercept
157
+ const __keystone_console = {
158
+ log: (...args) => {
159
+ if (isWorker) {
160
+ __post({ type: 'log', message: args.join(' ') });
161
+ } else {
162
+ __write('__SANDBOX_LOG__:' + args.join(' ') + '\\n');
163
+ }
164
+ },
165
+ error: (...args) => {
166
+ if (isWorker) {
167
+ __post({ type: 'log', message: 'ERROR: ' + args.join(' ') });
168
+ } else {
169
+ __write('__SANDBOX_LOG__:ERROR: ' + args.join(' ') + '\\n');
170
+ }
171
+ },
172
+ warn: (...args) => {
173
+ if (isWorker) {
174
+ __post({ type: 'log', message: 'WARN: ' + args.join(' ') });
175
+ } else {
176
+ __write('__SANDBOX_LOG__:WARN: ' + args.join(' ') + '\\n');
177
+ }
178
+ },
179
+ info: (...args) => {
180
+ if (isWorker) {
181
+ __post({ type: 'log', message: 'INFO: ' + args.join(' ') });
182
+ } else {
183
+ __write('__SANDBOX_LOG__:INFO: ' + args.join(' ') + '\\n');
184
+ }
185
+ },
186
+ debug: (...args) => {
187
+ if (isWorker) {
188
+ __post({ type: 'log', message: 'DEBUG: ' + args.join(' ') });
189
+ } else {
190
+ __write('__SANDBOX_LOG__:DEBUG: ' + args.join(' ') + '\\n');
191
+ }
241
192
  }
242
- },
243
- debug: (...args) => {
244
- if (isWorker) {
245
- __post({ type: 'log', message: 'DEBUG: ' + args.join(' ') });
246
- } else {
247
- __write('__SANDBOX_LOG__:DEBUG: ' + args.join(' ') + '\\n');
193
+ };
194
+
195
+ // Remove dangerous globals to prevent sandbox escape
196
+ const dangerousGlobals = [
197
+ 'process',
198
+ 'require',
199
+ 'module',
200
+ 'exports',
201
+ '__dirname',
202
+ '__filename',
203
+ 'Bun',
204
+ 'fetch',
205
+ 'crypto',
206
+ 'Worker',
207
+ 'navigator',
208
+ 'performance',
209
+ 'alert',
210
+ 'confirm',
211
+ 'prompt',
212
+ 'addEventListener',
213
+ 'dispatchEvent',
214
+ 'removeEventListener',
215
+ 'onmessage',
216
+ 'onerror',
217
+ 'ErrorEvent',
218
+ ];
219
+
220
+ for (const g of dangerousGlobals) {
221
+ try {
222
+ delete globalThis[g];
223
+ } catch (e) {
224
+ // Ignore errors for non-deletable properties
248
225
  }
249
226
  }
250
- };
251
227
 
252
- // Replace global console
253
- globalThis.console = __keystone_console;
228
+ // Make context variables available
229
+ Object.assign(globalThis, context);
230
+
231
+ // Replace global console
232
+ globalThis.console = __keystone_console;
254
233
 
255
- (async () => {
256
234
  try {
257
235
  // Execute the user code (wrap in async to support await)
258
236
  const __result = await (async () => {
@@ -282,12 +260,15 @@ globalThis.console = __keystone_console;
282
260
  private static runInWorker(
283
261
  scriptPath: string,
284
262
  timeout: number,
285
- options: ProcessSandboxOptions
263
+ options: ProcessSandboxOptions,
264
+ context: Record<string, unknown>
286
265
  ): Promise<ProcessSandboxResult> {
287
266
  return new Promise((resolve) => {
288
267
  let timedOut = false;
289
268
  const worker = new Worker(scriptPath);
290
269
 
270
+ worker.postMessage({ type: 'context', context });
271
+
291
272
  const timeoutHandle = setTimeout(() => {
292
273
  timedOut = true;
293
274
  worker.terminate();
@@ -339,7 +320,8 @@ globalThis.console = __keystone_console;
339
320
  private static runInSubprocess(
340
321
  scriptPath: string,
341
322
  timeout: number,
342
- options: ProcessSandboxOptions
323
+ options: ProcessSandboxOptions,
324
+ context: Record<string, unknown>
343
325
  ): Promise<ProcessSandboxResult> {
344
326
  return new Promise((resolve) => {
345
327
  let stdout = '';
@@ -352,15 +334,17 @@ globalThis.console = __keystone_console;
352
334
  let args = ['run', scriptPath];
353
335
 
354
336
  if (useMemoryLimit) {
355
- if (isWindows) {
356
- options.logger?.warn?.(
357
- 'ProcessSandbox: memoryLimit is not supported on Windows; running without a limit.'
358
- );
359
- } else {
337
+ if (process.platform === 'linux') {
360
338
  const limitKb = Math.max(1, Math.floor((options.memoryLimit as number) / 1024));
361
339
  const escapedPath = scriptPath.replace(/'/g, "'\\''");
362
340
  command = 'sh';
363
341
  args = ['-c', `ulimit -v ${limitKb}; exec bun run '${escapedPath}'`];
342
+ } else {
343
+ // On macOS/Windows, ulimit -v is often ignored or causes crashes with V8/JSC
344
+ // due to high virtual memory reservation. We log a warning but proceed without hard limit.
345
+ options.logger?.warn?.(
346
+ `ProcessSandbox: memoryLimit is not effectively enforced on ${process.platform}. Security Warning: Scripts may consume excessive memory.`
347
+ );
364
348
  }
365
349
  }
366
350
 
@@ -376,6 +360,12 @@ globalThis.console = __keystone_console;
376
360
  stdio: ['pipe', 'pipe', 'pipe'],
377
361
  });
378
362
 
363
+ // Write context to stdin
364
+ if (child.stdin) {
365
+ child.stdin.write(JSON.stringify(context));
366
+ child.stdin.end();
367
+ }
368
+
379
369
  // Set up timeout
380
370
  const timeoutHandle = setTimeout(() => {
381
371
  timedOut = true;
@@ -16,6 +16,7 @@ export interface RedactorOptions {
16
16
  }
17
17
 
18
18
  export class Redactor {
19
+ public static readonly REDACTED_PLACEHOLDER = '***REDACTED***';
19
20
  private patterns: RegExp[] = [];
20
21
  private combinedPattern: RegExp | null = null;
21
22
  public readonly maxSecretLength: number;
@@ -115,6 +116,8 @@ export class Redactor {
115
116
 
116
117
  // Build regex patterns
117
118
  // Optimization: Group secrets into a single combined regex where possible
119
+ // ReDoS Safety: We escape all special characters, so the regex consists purely of literal strings (alternations).
120
+ // This avoids backtracking issues common in ReDoS attacks.
118
121
  const parts: string[] = [];
119
122
 
120
123
  for (const secret of uniqueSecrets) {
@@ -134,6 +137,8 @@ export class Redactor {
134
137
  }
135
138
 
136
139
  if (parts.length > 0) {
140
+ // Note: If thousands of secrets are present, this regex compilation might be slow,
141
+ // but it remains safe from catastrophic backtracking due to being a simple alternation of literals.
137
142
  this.combinedPattern = new RegExp(parts.join('|'), 'g');
138
143
  }
139
144
 
@@ -163,7 +168,7 @@ export class Redactor {
163
168
  return strText;
164
169
  }
165
170
 
166
- return strText.replace(this.combinedPattern, '***REDACTED***');
171
+ return strText.replace(this.combinedPattern, Redactor.REDACTED_PLACEHOLDER);
167
172
  }
168
173
 
169
174
  /**
@@ -178,25 +183,50 @@ export class Redactor {
178
183
  * This is the recommended method for redacting complex data structures like
179
184
  * step outputs before storage, as it preserves JSON serializability.
180
185
  */
181
- redactValue(value: unknown): unknown {
186
+ redactValue(value: unknown, visited = new WeakSet<object>()): unknown {
182
187
  if (typeof value === 'string') {
183
188
  return this.redact(value);
184
189
  }
185
190
 
186
- if (Array.isArray(value)) {
187
- return value.map((item) => this.redactValue(item));
191
+ if (value === null || typeof value !== 'object') {
192
+ return value;
188
193
  }
189
194
 
190
- if (value !== null && typeof value === 'object') {
195
+ // Handle circular references
196
+ if (visited.has(value)) {
197
+ return '[Circular]';
198
+ }
199
+ visited.add(value);
200
+
201
+ try {
202
+ if (Array.isArray(value)) {
203
+ return value.map((item) => this.redactValue(item, visited));
204
+ }
205
+
191
206
  const redacted: Record<string, unknown> = {};
192
207
  for (const [key, val] of Object.entries(value)) {
193
- redacted[key] = this.redactValue(val);
208
+ redacted[key] = this.redactValue(val, visited);
194
209
  }
195
210
  return redacted;
211
+ } finally {
212
+ // Optional: remove from visited if we want to allow the same object
213
+ // in different branches of the tree (DAG), but strictly strictly strictly
214
+ // to prevent infinite recursion, keeping it in visited is safer and faster.
215
+ // However, JSON.stringify behavior is to only block ancestors.
216
+ // For deep recursion protection, consistent with JSON.stringify's cycle detection,
217
+ // we should arguably NOT remove it if we want to blocking *any* recurrence,
218
+ // but to match JSON.stringify "circular" error, strictly it's ancestors.
219
+ // However, for a Redactor, reusing the same object instance in multiple places is fine.
220
+ // The issue is strictly CYCLES.
221
+ // So we should remove it from visited on exit to support DAGs?
222
+ // Actually, JSON.stringify throws on cycles. We want to return "[Circular]".
223
+ // Let's stick to safe cycle detection which requires tracking the current stack.
224
+ // BUT, `visited` is passed down. If we don't remove it, we block non-circular DAGs (diamond problem).
225
+ // e.g. A -> B, A -> C, B -> D, C -> D. D is visited twice.
226
+ // So we SHOULD remove it.
227
+ // wait, WeakSet doesn't have .delete? Yes it does.
228
+ visited.delete(value);
196
229
  }
197
-
198
- // Primitives (numbers, booleans, null, undefined) pass through unchanged
199
- return value;
200
230
  }
201
231
  }
202
232