takos-runtime-service 1.0.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/package.json +29 -0
- package/src/__tests__/middleware/rate-limit.test.ts +33 -0
- package/src/__tests__/middleware/workspace-scope-extended.test.ts +163 -0
- package/src/__tests__/routes/actions-start-limits.test.ts +139 -0
- package/src/__tests__/routes/actions-step-warnings.test.ts +194 -0
- package/src/__tests__/routes/cli-proxy.test.ts +72 -0
- package/src/__tests__/routes/git-http.test.ts +218 -0
- package/src/__tests__/routes/git-lfs-policy.test.ts +112 -0
- package/src/__tests__/routes/sessions/store.test.ts +72 -0
- package/src/__tests__/routes/workspace-scope.test.ts +45 -0
- package/src/__tests__/runtime/action-registry.test.ts +208 -0
- package/src/__tests__/runtime/action-result-helpers.test.ts +129 -0
- package/src/__tests__/runtime/actions/executor.test.ts +131 -0
- package/src/__tests__/runtime/composite-expression.test.ts +294 -0
- package/src/__tests__/runtime/file-parsers.test.ts +129 -0
- package/src/__tests__/runtime/logging.test.ts +65 -0
- package/src/__tests__/runtime/paths.test.ts +236 -0
- package/src/__tests__/runtime/secrets.test.ts +247 -0
- package/src/__tests__/runtime/validation.test.ts +516 -0
- package/src/__tests__/setup.ts +126 -0
- package/src/__tests__/shared/errors.test.ts +117 -0
- package/src/__tests__/storage/r2.test.ts +106 -0
- package/src/__tests__/utils/audit-log.test.ts +163 -0
- package/src/__tests__/utils/error-message.test.ts +38 -0
- package/src/__tests__/utils/sandbox-env.test.ts +74 -0
- package/src/app.ts +245 -0
- package/src/index.ts +1 -0
- package/src/middleware/rate-limit.ts +91 -0
- package/src/middleware/space-scope.ts +95 -0
- package/src/routes/actions/action-types.ts +20 -0
- package/src/routes/actions/execution.ts +229 -0
- package/src/routes/actions/index.ts +17 -0
- package/src/routes/actions/job-lifecycle.ts +242 -0
- package/src/routes/actions/job-queries.ts +52 -0
- package/src/routes/cli/proxy.ts +105 -0
- package/src/routes/git/http.ts +565 -0
- package/src/routes/git/init.ts +88 -0
- package/src/routes/repos/branches.ts +160 -0
- package/src/routes/repos/content.ts +209 -0
- package/src/routes/repos/read.ts +130 -0
- package/src/routes/repos/repo-validation.ts +136 -0
- package/src/routes/repos/write.ts +274 -0
- package/src/routes/runtime/exec.ts +147 -0
- package/src/routes/runtime/tools.ts +113 -0
- package/src/routes/sessions/execution.ts +263 -0
- package/src/routes/sessions/files.ts +326 -0
- package/src/routes/sessions/session-routes.ts +241 -0
- package/src/routes/sessions/session-utils.ts +88 -0
- package/src/routes/sessions/snapshot.ts +208 -0
- package/src/routes/sessions/storage.ts +329 -0
- package/src/runtime/actions/action-registry.ts +450 -0
- package/src/runtime/actions/action-result-converter.ts +31 -0
- package/src/runtime/actions/builtin/artifacts.ts +292 -0
- package/src/runtime/actions/builtin/cache-operations.ts +358 -0
- package/src/runtime/actions/builtin/checkout.ts +58 -0
- package/src/runtime/actions/builtin/index.ts +5 -0
- package/src/runtime/actions/builtin/setup-node.ts +86 -0
- package/src/runtime/actions/builtin/tar-parser.ts +175 -0
- package/src/runtime/actions/composite-executor.ts +192 -0
- package/src/runtime/actions/composite-expression.ts +190 -0
- package/src/runtime/actions/executor.ts +578 -0
- package/src/runtime/actions/file-parsers.ts +51 -0
- package/src/runtime/actions/job-manager.ts +213 -0
- package/src/runtime/actions/process-spawner.ts +275 -0
- package/src/runtime/actions/secrets.ts +162 -0
- package/src/runtime/command.ts +120 -0
- package/src/runtime/exec-runner.ts +309 -0
- package/src/runtime/git-http-backend.ts +145 -0
- package/src/runtime/git.ts +98 -0
- package/src/runtime/heartbeat.ts +57 -0
- package/src/runtime/logging.ts +26 -0
- package/src/runtime/paths.ts +264 -0
- package/src/runtime/secure-fs.ts +82 -0
- package/src/runtime/tools/network.ts +161 -0
- package/src/runtime/tools/worker.ts +335 -0
- package/src/runtime/validation.ts +292 -0
- package/src/shared/config.ts +149 -0
- package/src/shared/errors.ts +65 -0
- package/src/shared/temp-id.ts +10 -0
- package/src/storage/r2.ts +287 -0
- package/src/types/hono.d.ts +23 -0
- package/src/utils/audit-log.ts +92 -0
- package/src/utils/process-kill.ts +18 -0
- package/src/utils/sandbox-env.ts +136 -0
- package/src/utils/temp-dir.ts +74 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { parentPort } from 'worker_threads';
|
|
2
|
+
import vm from 'vm';
|
|
3
|
+
import { TOOL_NAME_PATTERN, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS } from '../../shared/config.js';
|
|
4
|
+
import { getErrorMessage } from 'takos-common/errors';
|
|
5
|
+
import { createLogger } from 'takos-common/logger';
|
|
6
|
+
import {
|
|
7
|
+
normalizeAllowedDomains,
|
|
8
|
+
parseFetchUrl,
|
|
9
|
+
assertOutboundUrlAllowed,
|
|
10
|
+
ALLOWED_PROTOCOLS,
|
|
11
|
+
} from './network.js';
|
|
12
|
+
|
|
13
|
+
const logger = createLogger({ service: 'takos-runtime' });
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Tool Worker — executes user-defined tool code in a Node.js vm context.
|
|
17
|
+
*
|
|
18
|
+
* TRUST MODEL: This worker uses Node.js `vm` module which is NOT a security
|
|
19
|
+
* sandbox. It prevents accidental global pollution but does not isolate
|
|
20
|
+
* against malicious code. Only code from trusted workspaces should be
|
|
21
|
+
* executed here. For untrusted execution, a separate container boundary
|
|
22
|
+
* is required (see takos-runtime container isolation).
|
|
23
|
+
*
|
|
24
|
+
* Mitigations in place:
|
|
25
|
+
* - Outbound network restricted to allowed domains (SSRF protection)
|
|
26
|
+
* - DNS rebinding prevention (resolved IPs checked for private ranges)
|
|
27
|
+
* - Code generation disabled (no eval/new Function via codeGeneration option)
|
|
28
|
+
* - Output size capped (MAX_OUTPUT_BYTES)
|
|
29
|
+
* - Execution timeout enforced
|
|
30
|
+
* - Timer count limited (MAX_ACTIVE_TIMERS)
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
type ToolRequest = {
|
|
34
|
+
code: string;
|
|
35
|
+
toolName: string;
|
|
36
|
+
parameters: Record<string, unknown>;
|
|
37
|
+
secrets: Record<string, string>;
|
|
38
|
+
config: Record<string, unknown>;
|
|
39
|
+
allowedDomains: string[];
|
|
40
|
+
timeout: number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type ToolResult = {
|
|
44
|
+
success: boolean;
|
|
45
|
+
output: string;
|
|
46
|
+
error?: string;
|
|
47
|
+
executionTime: number;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const MAX_TOOL_CODE_BYTES = 256 * 1024;
|
|
51
|
+
const MAX_OUTPUT_BYTES = 1024 * 1024; // 1MB
|
|
52
|
+
const MAX_ACTIVE_TIMERS = 32;
|
|
53
|
+
const MAX_FETCH_REDIRECTS = 5;
|
|
54
|
+
const MAX_CONCURRENT_FETCHES = 10; // Per-execution fetch concurrency limit
|
|
55
|
+
const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]);
|
|
56
|
+
type SandboxTimer = ReturnType<typeof setTimeout>;
|
|
57
|
+
type SandboxSetTimeout = (
|
|
58
|
+
handler: unknown,
|
|
59
|
+
delay?: number,
|
|
60
|
+
...args: unknown[]
|
|
61
|
+
) => SandboxTimer;
|
|
62
|
+
type SandboxClearTimeout = (timerId: SandboxTimer) => void;
|
|
63
|
+
|
|
64
|
+
const TRUST_MODEL_NOTE =
|
|
65
|
+
'tool-worker vm is not a strong security boundary; only trusted workspace code is supported';
|
|
66
|
+
|
|
67
|
+
if (!parentPort) {
|
|
68
|
+
throw new Error('Tool worker started without parent port');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function clampTimeout(timeout: unknown): number {
|
|
72
|
+
if (typeof timeout !== 'number' || !Number.isFinite(timeout)) {
|
|
73
|
+
return DEFAULT_TIMEOUT_MS;
|
|
74
|
+
}
|
|
75
|
+
return Math.min(Math.max(Math.floor(timeout), 1), MAX_TIMEOUT_MS);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function toPlainRecord(value: unknown, label: string): Record<string, unknown> {
|
|
79
|
+
if (value === null || value === undefined) return {};
|
|
80
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
81
|
+
throw new Error(`${label} must be an object`);
|
|
82
|
+
}
|
|
83
|
+
return { ...(value as Record<string, unknown>) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function deepFreeze<T>(value: T, visited: WeakSet<object> = new WeakSet<object>()): T {
|
|
87
|
+
if (!value || typeof value !== 'object') {
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const objectValue = value as object;
|
|
92
|
+
if (visited.has(objectValue)) {
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
visited.add(objectValue);
|
|
96
|
+
|
|
97
|
+
for (const nestedValue of Object.values(value as Record<string, unknown>)) {
|
|
98
|
+
deepFreeze(nestedValue, visited);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
Object.freeze(objectValue);
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function serializeToolOutput(result: unknown): string {
|
|
106
|
+
if (typeof result === 'string') return result;
|
|
107
|
+
return JSON.stringify(result, null, 2) ?? 'null';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function postResult(startTime: number, result: Partial<ToolResult>): void {
|
|
111
|
+
const response: ToolResult = {
|
|
112
|
+
success: result.success ?? false,
|
|
113
|
+
output: result.output ?? '',
|
|
114
|
+
error: result.error,
|
|
115
|
+
executionTime: Date.now() - startTime,
|
|
116
|
+
};
|
|
117
|
+
parentPort?.postMessage(response);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function createSandboxTimers(timeoutMs: number): {
|
|
121
|
+
setTimeout: SandboxSetTimeout;
|
|
122
|
+
clearTimeout: SandboxClearTimeout;
|
|
123
|
+
clearAll: () => void;
|
|
124
|
+
} {
|
|
125
|
+
const activeTimers = new Set<SandboxTimer>();
|
|
126
|
+
|
|
127
|
+
const sandboxSetTimeout: SandboxSetTimeout = (handler, delay, ...args) => {
|
|
128
|
+
if (typeof handler !== 'function') {
|
|
129
|
+
throw new Error('setTimeout handler must be a function');
|
|
130
|
+
}
|
|
131
|
+
if (activeTimers.size >= MAX_ACTIVE_TIMERS) {
|
|
132
|
+
throw new Error(`Timer limit exceeded (max ${MAX_ACTIVE_TIMERS})`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const numericDelay = typeof delay === 'number' && Number.isFinite(delay) ? delay : 0;
|
|
136
|
+
const clampedDelay = Math.max(0, Math.min(Math.floor(numericDelay), timeoutMs));
|
|
137
|
+
|
|
138
|
+
const timer = setTimeout(() => {
|
|
139
|
+
activeTimers.delete(timer);
|
|
140
|
+
try {
|
|
141
|
+
handler(...args);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
logger.error('[Tool Timer Error]', { error: getErrorMessage(err) });
|
|
144
|
+
}
|
|
145
|
+
}, clampedDelay);
|
|
146
|
+
activeTimers.add(timer);
|
|
147
|
+
return timer;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const sandboxClearTimeout: SandboxClearTimeout = (timerId) => {
|
|
151
|
+
if (activeTimers.delete(timerId)) {
|
|
152
|
+
clearTimeout(timerId);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const clearAll = (): void => {
|
|
157
|
+
for (const timer of activeTimers) {
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
}
|
|
160
|
+
activeTimers.clear();
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
setTimeout: sandboxSetTimeout,
|
|
165
|
+
clearTimeout: sandboxClearTimeout,
|
|
166
|
+
clearAll,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
parentPort.on('message', async (message: ToolRequest) => {
|
|
171
|
+
const startTime = Date.now();
|
|
172
|
+
const timeout = clampTimeout(message.timeout);
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
if (!TOOL_NAME_PATTERN.test(message.toolName)) {
|
|
176
|
+
throw new Error('Invalid toolName format');
|
|
177
|
+
}
|
|
178
|
+
if (typeof message.code !== 'string' || message.code.trim().length === 0) {
|
|
179
|
+
throw new Error('Tool code is required');
|
|
180
|
+
}
|
|
181
|
+
if (Buffer.byteLength(message.code, 'utf-8') > MAX_TOOL_CODE_BYTES) {
|
|
182
|
+
throw new Error(`Tool code exceeds size limit (${MAX_TOOL_CODE_BYTES} bytes)`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const allowedDomains = normalizeAllowedDomains(message.allowedDomains);
|
|
186
|
+
const parameters = deepFreeze(toPlainRecord(message.parameters, 'parameters'));
|
|
187
|
+
const secrets = deepFreeze(toPlainRecord(message.secrets, 'secrets'));
|
|
188
|
+
const config = deepFreeze(toPlainRecord(message.config, 'config'));
|
|
189
|
+
const timers = createSandboxTimers(timeout);
|
|
190
|
+
|
|
191
|
+
let activeFetchCount = 0;
|
|
192
|
+
|
|
193
|
+
const restrictedFetch = async (url: unknown, init?: RequestInit): Promise<Response> => {
|
|
194
|
+
if (activeFetchCount >= MAX_CONCURRENT_FETCHES) {
|
|
195
|
+
throw new Error(`Fetch concurrency limit exceeded (max ${MAX_CONCURRENT_FETCHES})`);
|
|
196
|
+
}
|
|
197
|
+
activeFetchCount++;
|
|
198
|
+
try {
|
|
199
|
+
let targetUrl = parseFetchUrl(url);
|
|
200
|
+
let requestInit: RequestInit = {
|
|
201
|
+
...init,
|
|
202
|
+
redirect: 'manual',
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
for (let redirectCount = 0; redirectCount <= MAX_FETCH_REDIRECTS; redirectCount++) {
|
|
206
|
+
await assertOutboundUrlAllowed(targetUrl, allowedDomains);
|
|
207
|
+
const response = await fetch(targetUrl, requestInit);
|
|
208
|
+
|
|
209
|
+
if (!REDIRECT_STATUS_CODES.has(response.status)) {
|
|
210
|
+
return response;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const locationHeader = response.headers.get('location');
|
|
214
|
+
if (!locationHeader) {
|
|
215
|
+
throw new Error(`Network access denied: redirect response from ${targetUrl.hostname} missing location`);
|
|
216
|
+
}
|
|
217
|
+
if (redirectCount >= MAX_FETCH_REDIRECTS) {
|
|
218
|
+
throw new Error(`Network access denied: too many redirects (max ${MAX_FETCH_REDIRECTS})`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const nextUrl = new URL(locationHeader, targetUrl);
|
|
222
|
+
const method = (requestInit.method ?? 'GET').toUpperCase();
|
|
223
|
+
if ((response.status === 307 || response.status === 308) && requestInit.body !== undefined) {
|
|
224
|
+
throw new Error('Network access denied: redirected requests with body are not supported');
|
|
225
|
+
}
|
|
226
|
+
if (response.status === 303 || ((response.status === 301 || response.status === 302) && method === 'POST')) {
|
|
227
|
+
requestInit = {
|
|
228
|
+
...requestInit,
|
|
229
|
+
method: 'GET',
|
|
230
|
+
body: undefined,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
targetUrl = nextUrl;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
throw new Error(`Network access denied: too many redirects (max ${MAX_FETCH_REDIRECTS})`);
|
|
238
|
+
} finally {
|
|
239
|
+
activeFetchCount--;
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
logger.warn('[ToolWorker] ' + TRUST_MODEL_NOTE, { toolName: message.toolName });
|
|
244
|
+
|
|
245
|
+
const sandboxContext = {
|
|
246
|
+
fetch: restrictedFetch,
|
|
247
|
+
// eslint-disable-next-line no-console -- intentional console proxy for sandboxed tool output
|
|
248
|
+
console: Object.freeze({
|
|
249
|
+
log: (...args: unknown[]) => console.log('[Tool]', ...args),
|
|
250
|
+
error: (...args: unknown[]) => console.error('[Tool]', ...args),
|
|
251
|
+
warn: (...args: unknown[]) => console.warn('[Tool]', ...args),
|
|
252
|
+
}),
|
|
253
|
+
parameters,
|
|
254
|
+
secrets,
|
|
255
|
+
config,
|
|
256
|
+
URL,
|
|
257
|
+
URLSearchParams,
|
|
258
|
+
TextEncoder,
|
|
259
|
+
TextDecoder,
|
|
260
|
+
AbortController,
|
|
261
|
+
setTimeout: timers.setTimeout,
|
|
262
|
+
clearTimeout: timers.clearTimeout,
|
|
263
|
+
// Explicitly block known escape vectors
|
|
264
|
+
process: undefined,
|
|
265
|
+
require: undefined,
|
|
266
|
+
global: undefined,
|
|
267
|
+
Buffer: undefined,
|
|
268
|
+
__dirname: undefined,
|
|
269
|
+
__filename: undefined,
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
(sandboxContext as Record<string, unknown>).globalThis = sandboxContext;
|
|
273
|
+
|
|
274
|
+
const context = vm.createContext(sandboxContext, {
|
|
275
|
+
codeGeneration: { strings: false, wasm: false },
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const wrappedCode = `
|
|
279
|
+
(async function(sandbox) {
|
|
280
|
+
"use strict";
|
|
281
|
+
const exports = {};
|
|
282
|
+
const module = { exports };
|
|
283
|
+
const requestedToolName = ${JSON.stringify(message.toolName)};
|
|
284
|
+
const { fetch, console, parameters, secrets, config, setTimeout, clearTimeout } = sandbox;
|
|
285
|
+
|
|
286
|
+
${message.code}
|
|
287
|
+
|
|
288
|
+
const exportedTools =
|
|
289
|
+
module.exports && typeof module.exports === 'object'
|
|
290
|
+
? module.exports
|
|
291
|
+
: exports;
|
|
292
|
+
const hasRequestedTool = Object.prototype.hasOwnProperty.call(exportedTools, requestedToolName);
|
|
293
|
+
const toolFn = hasRequestedTool ? exportedTools[requestedToolName] : null;
|
|
294
|
+
|
|
295
|
+
if (typeof toolFn !== 'function') {
|
|
296
|
+
throw new Error('Tool function not found: ' + requestedToolName);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return await toolFn(parameters, { secrets, config, fetch });
|
|
300
|
+
})
|
|
301
|
+
`;
|
|
302
|
+
|
|
303
|
+
const script = new vm.Script(wrappedCode, { filename: 'tool-worker.js' });
|
|
304
|
+
let executionTimer: ReturnType<typeof setTimeout> | undefined;
|
|
305
|
+
let result: unknown;
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
const runner = script.runInContext(context);
|
|
309
|
+
if (typeof runner !== 'function') {
|
|
310
|
+
throw new Error('Tool runner initialization failed');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
result = await Promise.race([
|
|
314
|
+
Promise.resolve(runner(sandboxContext)),
|
|
315
|
+
new Promise<never>((_, reject) => {
|
|
316
|
+
executionTimer = setTimeout(() => {
|
|
317
|
+
reject(new Error(`Execution timed out after ${timeout}ms`));
|
|
318
|
+
}, timeout);
|
|
319
|
+
}),
|
|
320
|
+
]);
|
|
321
|
+
} finally {
|
|
322
|
+
if (executionTimer) clearTimeout(executionTimer);
|
|
323
|
+
timers.clearAll();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const output = serializeToolOutput(result);
|
|
327
|
+
if (Buffer.byteLength(output, 'utf-8') > MAX_OUTPUT_BYTES) {
|
|
328
|
+
throw new Error(`Tool output exceeds size limit (${MAX_OUTPUT_BYTES} bytes)`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
postResult(startTime, { success: true, output });
|
|
332
|
+
} catch (err) {
|
|
333
|
+
postResult(startTime, { error: getErrorMessage(err) });
|
|
334
|
+
}
|
|
335
|
+
});
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { ALLOWED_COMMANDS_SET, COMMAND_BLOCKLIST_PATTERNS } from '../shared/config.js';
|
|
2
|
+
import { createLogger } from 'takos-common/logger';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Matches all C0 control characters (0x00-0x1F) plus DEL (0x7F).
|
|
6
|
+
* Used for security-sensitive inputs (git paths, author names, emails)
|
|
7
|
+
* where any control character is potentially dangerous.
|
|
8
|
+
*
|
|
9
|
+
* This is intentionally broader than LINE_UNSAFE_CHARS_PATTERN in
|
|
10
|
+
* actions/builtin/constants.ts, which only rejects null/CR/LF for
|
|
11
|
+
* line-oriented key/name formats.
|
|
12
|
+
*/
|
|
13
|
+
// eslint-disable-next-line no-control-regex
|
|
14
|
+
const ALL_CONTROL_CHARS_PATTERN = /[\x00-\x1f\x7f]/;
|
|
15
|
+
const STRICT_SESSION_ID_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9]|[-_](?![-_])){14,126}[A-Za-z0-9]$/;
|
|
16
|
+
const VALID_GIT_REF_PATTERN = /^[A-Za-z0-9_./@^~:-]+$/;
|
|
17
|
+
const VALID_GIT_PATH_PATTERN = /^[A-Za-z0-9_.@/-]+$/;
|
|
18
|
+
const VALID_EMAIL_PATTERN = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/;
|
|
19
|
+
|
|
20
|
+
function requireNonEmptyString(value: unknown, label: string): asserts value is string {
|
|
21
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
22
|
+
throw new Error(`${label} is required`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function requireMaxLength(value: string, maxLength: number, label: string): void {
|
|
27
|
+
if (value.length > maxLength) {
|
|
28
|
+
throw new Error(`${label} too long`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function rejectControlChars(value: string, label: string): void {
|
|
33
|
+
if (ALL_CONTROL_CHARS_PATTERN.test(value)) {
|
|
34
|
+
throw new Error(`${label} contains invalid characters`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function validateCommandLine(commandLine: string): void {
|
|
39
|
+
const trimmed = commandLine.trim();
|
|
40
|
+
if (trimmed.length === 0 || /\0/.test(trimmed)) {
|
|
41
|
+
throw new Error('Invalid command');
|
|
42
|
+
}
|
|
43
|
+
for (const pattern of COMMAND_BLOCKLIST_PATTERNS) {
|
|
44
|
+
if (pattern.test(trimmed)) {
|
|
45
|
+
throw new Error('Dangerous command arguments detected');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isProbablyBinary(buffer: Buffer): boolean {
|
|
51
|
+
if (buffer.length === 0) return false;
|
|
52
|
+
const sampleSize = Math.min(buffer.length, 8000);
|
|
53
|
+
let suspicious = 0;
|
|
54
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
55
|
+
const byte = buffer[i];
|
|
56
|
+
if (byte === 0) return true;
|
|
57
|
+
if (byte < 7 || (byte > 14 && byte < 32)) {
|
|
58
|
+
suspicious++;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return suspicious / sampleSize > 0.3;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isValidSessionId(sessionId: string): boolean {
|
|
65
|
+
return typeof sessionId === 'string' && STRICT_SESSION_ID_PATTERN.test(sessionId);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function getWorkerResourceLimits(maxMemory?: number): { maxOldGenerationSizeMb: number } | undefined {
|
|
69
|
+
if (!maxMemory) return undefined;
|
|
70
|
+
return { maxOldGenerationSizeMb: Math.max(16, Math.min(Math.floor(maxMemory), 512)) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Validate git ref (branch name, tag, commit hash).
|
|
75
|
+
* Git commands are executed via spawn() with array args, so shell injection is not possible.
|
|
76
|
+
* We only validate for Git's own ref format requirements (matching `git check-ref-format` rules).
|
|
77
|
+
*/
|
|
78
|
+
export function validateGitRef(ref: string): void {
|
|
79
|
+
requireNonEmptyString(ref, 'Git ref');
|
|
80
|
+
requireMaxLength(ref, 256, 'Git ref');
|
|
81
|
+
// eslint-disable-next-line no-control-regex
|
|
82
|
+
if (/\x00/.test(ref)) {
|
|
83
|
+
throw new Error('Git ref contains invalid characters');
|
|
84
|
+
}
|
|
85
|
+
if (ref.startsWith('.') || ref.endsWith('.') || ref.includes('..') || ref.includes('@{')) {
|
|
86
|
+
throw new Error('Git ref format is invalid');
|
|
87
|
+
}
|
|
88
|
+
if (ref.startsWith('-')) {
|
|
89
|
+
throw new Error('Git ref must not start with a dash');
|
|
90
|
+
}
|
|
91
|
+
if (ref.includes('\\')) {
|
|
92
|
+
throw new Error('Git ref contains invalid characters');
|
|
93
|
+
}
|
|
94
|
+
// Git forbids .lock suffix, leading colon, trailing slash, and space/tilde/caret/colon sequences
|
|
95
|
+
if (ref.endsWith('.lock')) {
|
|
96
|
+
throw new Error('Git ref must not end with .lock');
|
|
97
|
+
}
|
|
98
|
+
if (ref.startsWith(':')) {
|
|
99
|
+
throw new Error('Git ref must not start with a colon');
|
|
100
|
+
}
|
|
101
|
+
if (ref.endsWith('/')) {
|
|
102
|
+
throw new Error('Git ref must not end with a slash');
|
|
103
|
+
}
|
|
104
|
+
if (/\s/.test(ref)) {
|
|
105
|
+
throw new Error('Git ref must not contain whitespace');
|
|
106
|
+
}
|
|
107
|
+
// eslint-disable-next-line no-control-regex
|
|
108
|
+
if (/[\x00-\x1f\x7f]/.test(ref)) {
|
|
109
|
+
throw new Error('Git ref contains control characters');
|
|
110
|
+
}
|
|
111
|
+
if (!VALID_GIT_REF_PATTERN.test(ref)) {
|
|
112
|
+
throw new Error('Git ref contains invalid characters');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Validate git path to prevent path traversal and command injection.
|
|
118
|
+
* Allows: alphanumeric, dash, underscore, dot, slash (for subdirectories)
|
|
119
|
+
* Disallows: path traversal (..), shell metacharacters, null bytes
|
|
120
|
+
*/
|
|
121
|
+
export function validateGitPath(filePath: string): void {
|
|
122
|
+
if (typeof filePath !== 'string') {
|
|
123
|
+
throw new Error('Git path must be a string');
|
|
124
|
+
}
|
|
125
|
+
requireMaxLength(filePath, 4096, 'Git path');
|
|
126
|
+
rejectControlChars(filePath, 'Git path');
|
|
127
|
+
if (filePath.includes('..') || filePath.startsWith('/') || /^[A-Za-z]:/.test(filePath)) {
|
|
128
|
+
throw new Error('Path traversal not allowed');
|
|
129
|
+
}
|
|
130
|
+
if (filePath.length > 0 && !VALID_GIT_PATH_PATTERN.test(filePath)) {
|
|
131
|
+
throw new Error('Git path contains invalid characters');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Validate git author name to prevent injection attacks.
|
|
137
|
+
* Must be a reasonable name without shell metacharacters.
|
|
138
|
+
*/
|
|
139
|
+
export function validateGitAuthorName(name: string): void {
|
|
140
|
+
requireNonEmptyString(name, 'Author name');
|
|
141
|
+
requireMaxLength(name, 256, 'Author name');
|
|
142
|
+
rejectControlChars(name, 'Author name');
|
|
143
|
+
if (/[<>;&|`$(){}[\]\\"]/.test(name)) {
|
|
144
|
+
throw new Error('Author name contains disallowed characters');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Validate git author email to prevent injection attacks.
|
|
150
|
+
* Must be a valid email format.
|
|
151
|
+
*/
|
|
152
|
+
export function validateGitAuthorEmail(email: string): void {
|
|
153
|
+
requireNonEmptyString(email, 'Author email');
|
|
154
|
+
requireMaxLength(email, 256, 'Author email');
|
|
155
|
+
rejectControlChars(email, 'Author email');
|
|
156
|
+
if (!VALID_EMAIL_PATTERN.test(email)) {
|
|
157
|
+
throw new Error('Author email format is invalid');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Git name / space-id validation
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Strict pattern: alphanumeric start/end, alphanumeric/underscore/hyphen middle.
|
|
167
|
+
* No consecutive underscores or hyphens. Length enforced separately.
|
|
168
|
+
*/
|
|
169
|
+
const SAFE_NAME_PATTERN = /^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$/;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Validates and sanitizes a space or repo name for use in git/HTTP paths.
|
|
173
|
+
*
|
|
174
|
+
* Valid names: 1-128 chars, alphanumeric start/end, middle allows underscore/hyphen,
|
|
175
|
+
* no consecutive underscores or hyphens, no path traversal, no control characters.
|
|
176
|
+
*
|
|
177
|
+
* Returns the sanitized name or null if invalid.
|
|
178
|
+
*/
|
|
179
|
+
export function validateGitName(name: string): string | null {
|
|
180
|
+
if (typeof name !== 'string' || name.length === 0 || name.length > 128) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Reject null bytes, control characters, path traversal, and URL-encoded traversal
|
|
185
|
+
// eslint-disable-next-line no-control-regex
|
|
186
|
+
if (/[\x00-\x1f\x7f]/.test(name)) return null;
|
|
187
|
+
if (name.includes('..') || name.includes('/') || name.includes('\\')) return null;
|
|
188
|
+
if (/%2e|%2f|%5c/i.test(name)) return null;
|
|
189
|
+
if (/__|--/.test(name)) return null;
|
|
190
|
+
|
|
191
|
+
if (!SAFE_NAME_PATTERN.test(name)) return null;
|
|
192
|
+
|
|
193
|
+
return name;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Pattern for space IDs: alphanumeric start, alphanumeric + underscore/hyphen middle, 1-128 chars total.
|
|
198
|
+
* This is intentionally less strict than validateGitName (no consecutive-separator check, single-char allowed).
|
|
199
|
+
*/
|
|
200
|
+
const SPACE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Validates a space ID.
|
|
204
|
+
* Throws on invalid input, returns the validated ID string on success.
|
|
205
|
+
*/
|
|
206
|
+
export function validateSpaceId(spaceId: string): string {
|
|
207
|
+
if (typeof spaceId !== 'string' || spaceId.length === 0) {
|
|
208
|
+
throw new Error('space_id is required');
|
|
209
|
+
}
|
|
210
|
+
if (!SPACE_ID_PATTERN.test(spaceId)) {
|
|
211
|
+
throw new Error('Invalid space_id format');
|
|
212
|
+
}
|
|
213
|
+
return spaceId;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Validates a name parameter (space_id, repo_name, etc.) using SAFE_NAME_PATTERN.
|
|
219
|
+
* Returns an error message string on failure, or null on success.
|
|
220
|
+
*/
|
|
221
|
+
export function validateNameParam(value: string | undefined, label: string): string | null {
|
|
222
|
+
if (!value) return `${label} is required`;
|
|
223
|
+
if (!SAFE_NAME_PATTERN.test(value)) return `Invalid ${label} format`;
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// --- Command validation ---
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
const commandValidationLogger = createLogger({ service: 'takos-runtime' });
|
|
232
|
+
|
|
233
|
+
const SHELL_METACHAR_PATTERN = /[|&;`$(){}]/;
|
|
234
|
+
|
|
235
|
+
function hasDisallowedShellMetacharacters(value: string): boolean {
|
|
236
|
+
if (!SHELL_METACHAR_PATTERN.test(value)) {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
// Strip GitHub Actions expression syntax ${{ ... }} before checking.
|
|
240
|
+
// This allows actions-style expressions while still blocking raw shell metacharacters.
|
|
241
|
+
const stripped = value.replace(/\$\{\{[^}]*\}\}/g, '');
|
|
242
|
+
if (!SHELL_METACHAR_PATTERN.test(stripped)) {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
// Any remaining shell metacharacter (including $VAR, ${VAR}, pipes, etc.) is disallowed.
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function validateCommand(command: string): string | null {
|
|
250
|
+
if (typeof command !== 'string' || command.trim().length === 0) {
|
|
251
|
+
return 'Command is empty or invalid';
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (command.length > 100000) {
|
|
255
|
+
return 'Command is too long';
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// eslint-disable-next-line no-control-regex
|
|
259
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(command)) {
|
|
260
|
+
return 'Command contains invalid control characters';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const lines = command.split('\n').map(line => line.trim()).filter(line => line.length > 0 && !line.startsWith('#'));
|
|
264
|
+
const combinedCommand = lines.join('\n');
|
|
265
|
+
if (combinedCommand.length > 0 && hasDisallowedShellMetacharacters(combinedCommand)) {
|
|
266
|
+
return 'Command contains shell metacharacters (|, &, ;, `, $, etc.) which are not allowed';
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
for (const line of lines) {
|
|
270
|
+
if (hasDisallowedShellMetacharacters(line)) {
|
|
271
|
+
return 'Command contains shell metacharacters (|, &, ;, `, $, etc.) which are not allowed';
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
for (const pattern of COMMAND_BLOCKLIST_PATTERNS) {
|
|
275
|
+
if (pattern.test(line)) {
|
|
276
|
+
return 'Command contains potentially dangerous patterns';
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const firstCommand = line.split(/\s+/)[0];
|
|
281
|
+
const isAllowed = ALLOWED_COMMANDS_SET.has(firstCommand) ||
|
|
282
|
+
firstCommand.startsWith('./') ||
|
|
283
|
+
firstCommand.startsWith('.\\');
|
|
284
|
+
|
|
285
|
+
if (!isAllowed) {
|
|
286
|
+
commandValidationLogger.warn('Rejected unrecognized command', { command: firstCommand });
|
|
287
|
+
return `Command not allowed: ${firstCommand}`;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return null;
|
|
292
|
+
}
|