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.
- package/README.md +43 -4
- package/package.json +4 -1
- package/src/cli.ts +1 -0
- package/src/commands/event.ts +9 -0
- package/src/commands/run.ts +17 -0
- package/src/db/dynamic-state-manager.ts +12 -9
- package/src/db/memory-db.test.ts +19 -1
- package/src/db/memory-db.ts +101 -22
- package/src/db/workflow-db.ts +181 -9
- package/src/expression/evaluator.ts +4 -1
- package/src/parser/config-schema.ts +6 -0
- package/src/parser/schema.ts +1 -0
- package/src/runner/__test__/llm-test-setup.ts +43 -11
- package/src/runner/durable-timers.test.ts +1 -1
- package/src/runner/executors/dynamic-executor.ts +125 -88
- package/src/runner/executors/engine-executor.ts +10 -39
- package/src/runner/executors/file-executor.ts +67 -0
- package/src/runner/executors/foreach-executor.ts +170 -17
- package/src/runner/executors/human-executor.ts +18 -0
- package/src/runner/executors/llm/stream-handler.ts +103 -0
- package/src/runner/executors/llm/tool-manager.ts +360 -0
- package/src/runner/executors/llm-executor.ts +288 -555
- package/src/runner/executors/memory-executor.ts +41 -34
- package/src/runner/executors/shell-executor.ts +96 -52
- package/src/runner/executors/subworkflow-executor.ts +16 -0
- package/src/runner/executors/types.ts +3 -1
- package/src/runner/executors/verification_fixes.test.ts +46 -0
- package/src/runner/join-scheduling.test.ts +2 -1
- package/src/runner/llm-adapter.integration.test.ts +10 -5
- package/src/runner/llm-adapter.ts +57 -18
- package/src/runner/llm-clarification.test.ts +4 -1
- package/src/runner/llm-executor.test.ts +21 -7
- package/src/runner/mcp-client.ts +36 -2
- package/src/runner/mcp-server.ts +65 -36
- package/src/runner/recovery-security.test.ts +5 -2
- package/src/runner/reflexion.test.ts +6 -3
- package/src/runner/services/context-builder.ts +13 -4
- package/src/runner/services/workflow-validator.ts +2 -1
- package/src/runner/standard-tools-ast.test.ts +4 -2
- package/src/runner/standard-tools-execution.test.ts +14 -1
- package/src/runner/standard-tools-integration.test.ts +6 -0
- package/src/runner/standard-tools.ts +13 -10
- package/src/runner/step-executor.ts +2 -2
- package/src/runner/tool-integration.test.ts +4 -1
- package/src/runner/workflow-runner.test.ts +23 -12
- package/src/runner/workflow-runner.ts +172 -79
- package/src/runner/workflow-state.ts +181 -111
- package/src/ui/dashboard.tsx +17 -3
- package/src/utils/config-loader.ts +4 -0
- package/src/utils/constants.ts +4 -0
- package/src/utils/context-injector.test.ts +27 -27
- package/src/utils/context-injector.ts +68 -26
- package/src/utils/process-sandbox.ts +138 -148
- package/src/utils/redactor.ts +39 -9
- package/src/utils/resource-loader.ts +24 -19
- package/src/utils/sandbox.ts +6 -0
- package/src/utils/stream-utils.ts +58 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import
|
|
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 {
|
|
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 (
|
|
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(
|
|
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 (
|
|
74
|
+
if (await ContextInjector.exists(readmePath)) {
|
|
60
75
|
try {
|
|
61
|
-
result.readme = fs.
|
|
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 (
|
|
86
|
+
if (await ContextInjector.exists(agentsMdPath)) {
|
|
72
87
|
try {
|
|
73
|
-
result.agentsMd = fs.
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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 (!
|
|
118
|
+
if (!(await ContextInjector.exists(rulesPath))) continue;
|
|
103
119
|
|
|
104
120
|
try {
|
|
105
|
-
const files = fs.
|
|
121
|
+
const files = await fs.readdir(rulesPath);
|
|
106
122
|
for (const file of files) {
|
|
107
123
|
const rulePath = path.join(rulesPath, file);
|
|
108
|
-
|
|
124
|
+
const stats = await fs.stat(rulePath);
|
|
125
|
+
if (!stats.isFile()) continue;
|
|
109
126
|
|
|
110
|
-
const content = fs.
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
//
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
if (
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
//
|
|
253
|
-
globalThis
|
|
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 (
|
|
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;
|
package/src/utils/redactor.ts
CHANGED
|
@@ -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,
|
|
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 (
|
|
187
|
-
return value
|
|
191
|
+
if (value === null || typeof value !== 'object') {
|
|
192
|
+
return value;
|
|
188
193
|
}
|
|
189
194
|
|
|
190
|
-
|
|
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
|
|