btca-server 1.0.20

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.
@@ -0,0 +1,101 @@
1
+ import { z } from 'zod';
2
+
3
+ export const BtcaModelSchema = z.object({
4
+ provider: z.string(),
5
+ model: z.string()
6
+ });
7
+
8
+ export const BtcaCollectionInfoSchema = z.object({
9
+ key: z.string(),
10
+ path: z.string()
11
+ });
12
+
13
+ export const BtcaStreamMetaEventSchema = z.object({
14
+ type: z.literal('meta'),
15
+ model: BtcaModelSchema,
16
+ resources: z.array(z.string()),
17
+ collection: BtcaCollectionInfoSchema
18
+ });
19
+
20
+ export const BtcaStreamTextDeltaEventSchema = z.object({
21
+ type: z.literal('text.delta'),
22
+ delta: z.string()
23
+ });
24
+
25
+ export const BtcaStreamReasoningDeltaEventSchema = z.object({
26
+ type: z.literal('reasoning.delta'),
27
+ delta: z.string()
28
+ });
29
+
30
+ export const BtcaToolStateSchema = z.discriminatedUnion('status', [
31
+ z.object({
32
+ status: z.literal('pending'),
33
+ input: z.record(z.unknown()),
34
+ raw: z.string()
35
+ }),
36
+ z.object({
37
+ status: z.literal('running'),
38
+ input: z.record(z.unknown()),
39
+ title: z.string().optional(),
40
+ metadata: z.record(z.unknown()).optional(),
41
+ time: z.object({ start: z.number() })
42
+ }),
43
+ z.object({
44
+ status: z.literal('completed'),
45
+ input: z.record(z.unknown()),
46
+ output: z.string(),
47
+ title: z.string(),
48
+ metadata: z.record(z.unknown()),
49
+ time: z.object({ start: z.number(), end: z.number(), compacted: z.number().optional() })
50
+ }),
51
+ z.object({
52
+ status: z.literal('error'),
53
+ input: z.record(z.unknown()),
54
+ error: z.string(),
55
+ metadata: z.record(z.unknown()).optional(),
56
+ time: z.object({ start: z.number(), end: z.number() })
57
+ })
58
+ ]);
59
+
60
+ export const BtcaStreamToolUpdatedEventSchema = z.object({
61
+ type: z.literal('tool.updated'),
62
+ callID: z.string(),
63
+ tool: z.string(),
64
+ state: BtcaToolStateSchema
65
+ });
66
+
67
+ export const BtcaStreamDoneEventSchema = z.object({
68
+ type: z.literal('done'),
69
+ text: z.string(),
70
+ reasoning: z.string(),
71
+ tools: z.array(
72
+ z.object({
73
+ callID: z.string(),
74
+ tool: z.string(),
75
+ state: BtcaToolStateSchema
76
+ })
77
+ )
78
+ });
79
+
80
+ export const BtcaStreamErrorEventSchema = z.object({
81
+ type: z.literal('error'),
82
+ tag: z.string(),
83
+ message: z.string()
84
+ });
85
+
86
+ export const BtcaStreamEventSchema = z.union([
87
+ BtcaStreamMetaEventSchema,
88
+ BtcaStreamTextDeltaEventSchema,
89
+ BtcaStreamReasoningDeltaEventSchema,
90
+ BtcaStreamToolUpdatedEventSchema,
91
+ BtcaStreamDoneEventSchema,
92
+ BtcaStreamErrorEventSchema
93
+ ]);
94
+
95
+ export type BtcaStreamMetaEvent = z.infer<typeof BtcaStreamMetaEventSchema>;
96
+ export type BtcaStreamTextDeltaEvent = z.infer<typeof BtcaStreamTextDeltaEventSchema>;
97
+ export type BtcaStreamReasoningDeltaEvent = z.infer<typeof BtcaStreamReasoningDeltaEventSchema>;
98
+ export type BtcaStreamToolUpdatedEvent = z.infer<typeof BtcaStreamToolUpdatedEventSchema>;
99
+ export type BtcaStreamDoneEvent = z.infer<typeof BtcaStreamDoneEventSchema>;
100
+ export type BtcaStreamErrorEvent = z.infer<typeof BtcaStreamErrorEventSchema>;
101
+ export type BtcaStreamEvent = z.infer<typeof BtcaStreamEventSchema>;
@@ -0,0 +1,440 @@
1
+ /**
2
+ * Input validation utilities for the btca server.
3
+ *
4
+ * These validators prevent security issues including:
5
+ * - Path traversal attacks via resource names
6
+ * - Git injection via malicious URLs
7
+ * - Command injection via branch names
8
+ * - DoS via unbounded input sizes
9
+ */
10
+
11
+ // ─────────────────────────────────────────────────────────────────────────────
12
+ // Regex Patterns
13
+ // ─────────────────────────────────────────────────────────────────────────────
14
+
15
+ /**
16
+ * Resource name: must start with a letter, followed by alphanumeric and hyphens only.
17
+ * This prevents path traversal (../), git option injection (-), and shell metacharacters.
18
+ */
19
+ const RESOURCE_NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9-]*$/;
20
+
21
+ /**
22
+ * Branch name: alphanumeric, forward slashes, dots, underscores, and hyphens.
23
+ * Must not start with hyphen to prevent git option injection.
24
+ */
25
+ const BRANCH_NAME_REGEX = /^[a-zA-Z0-9/_.-]+$/;
26
+
27
+ /**
28
+ * Provider/Model names: letters, numbers, dots, underscores, plus, hyphens, forward slashes, colons.
29
+ * Blocks shell metacharacters and path traversal.
30
+ */
31
+ const SAFE_NAME_REGEX = /^[a-zA-Z0-9._+\-/:]+$/;
32
+
33
+ // ─────────────────────────────────────────────────────────────────────────────
34
+ // Limits
35
+ // ─────────────────────────────────────────────────────────────────────────────
36
+
37
+ export const LIMITS = {
38
+ /** Maximum length for resource names */
39
+ RESOURCE_NAME_MAX: 64,
40
+ /** Maximum length for branch names */
41
+ BRANCH_NAME_MAX: 128,
42
+ /** Maximum length for provider names */
43
+ PROVIDER_NAME_MAX: 100,
44
+ /** Maximum length for model names */
45
+ MODEL_NAME_MAX: 100,
46
+ /** Maximum length for special notes */
47
+ NOTES_MAX: 500,
48
+ /** Maximum length for search paths */
49
+ SEARCH_PATH_MAX: 256,
50
+ /** Maximum length for questions */
51
+ QUESTION_MAX: 10_000,
52
+ /** Maximum number of resources per request */
53
+ MAX_RESOURCES_PER_REQUEST: 20
54
+ } as const;
55
+
56
+ // ─────────────────────────────────────────────────────────────────────────────
57
+ // Validation Result Type
58
+ // ─────────────────────────────────────────────────────────────────────────────
59
+
60
+ export type ValidationResult =
61
+ | { valid: true }
62
+ | { valid: false; error: string };
63
+
64
+ const ok = (): ValidationResult => ({ valid: true });
65
+ const fail = (error: string): ValidationResult => ({ valid: false, error });
66
+
67
+ // ─────────────────────────────────────────────────────────────────────────────
68
+ // Validators
69
+ // ─────────────────────────────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * Validate a resource name to prevent path traversal and git injection attacks.
73
+ *
74
+ * Requirements:
75
+ * - Non-empty
76
+ * - Starts with a letter (prevents git option injection with -)
77
+ * - Only contains letters, numbers, and hyphens
78
+ * - Max length enforced
79
+ */
80
+ export const validateResourceName = (name: string): ValidationResult => {
81
+ if (!name || name.trim().length === 0) {
82
+ return fail('Resource name cannot be empty');
83
+ }
84
+
85
+ if (name.length > LIMITS.RESOURCE_NAME_MAX) {
86
+ return fail(
87
+ `Resource name too long: ${name.length} chars (max ${LIMITS.RESOURCE_NAME_MAX})`
88
+ );
89
+ }
90
+
91
+ if (!RESOURCE_NAME_REGEX.test(name)) {
92
+ return fail(
93
+ `Invalid resource name: "${name}". Must start with a letter and contain only alphanumeric characters and hyphens`
94
+ );
95
+ }
96
+
97
+ return ok();
98
+ };
99
+
100
+ /**
101
+ * Validate a git branch name to prevent git injection attacks.
102
+ *
103
+ * Requirements:
104
+ * - Non-empty
105
+ * - Does not start with hyphen (prevents git option injection)
106
+ * - Only safe characters
107
+ * - Max length enforced
108
+ */
109
+ export const validateBranchName = (branch: string): ValidationResult => {
110
+ if (!branch || branch.trim().length === 0) {
111
+ return fail('Branch name cannot be empty');
112
+ }
113
+
114
+ if (branch.length > LIMITS.BRANCH_NAME_MAX) {
115
+ return fail(
116
+ `Branch name too long: ${branch.length} chars (max ${LIMITS.BRANCH_NAME_MAX})`
117
+ );
118
+ }
119
+
120
+ if (branch.startsWith('-')) {
121
+ return fail(
122
+ `Invalid branch name: "${branch}". Must not start with '-' to prevent git option injection`
123
+ );
124
+ }
125
+
126
+ if (!BRANCH_NAME_REGEX.test(branch)) {
127
+ return fail(
128
+ `Invalid branch name: "${branch}". Must contain only alphanumeric characters, forward slashes, dots, underscores, and hyphens`
129
+ );
130
+ }
131
+
132
+ return ok();
133
+ };
134
+
135
+ /**
136
+ * Validate a git URL to prevent unsafe git operations.
137
+ *
138
+ * Requirements:
139
+ * - Valid URL format
140
+ * - HTTPS protocol only (rejects file://, git://, ssh://, ext::, etc.)
141
+ * - No embedded credentials
142
+ * - No localhost or private IP addresses
143
+ */
144
+ export const validateGitUrl = (url: string): ValidationResult => {
145
+ if (!url || url.trim().length === 0) {
146
+ return fail('Git URL cannot be empty');
147
+ }
148
+
149
+ let parsed: URL;
150
+ try {
151
+ parsed = new URL(url);
152
+ } catch {
153
+ return fail(`Invalid URL format: "${url}"`);
154
+ }
155
+
156
+ // Only allow HTTPS protocol
157
+ if (parsed.protocol !== 'https:') {
158
+ return fail(
159
+ `Invalid URL protocol: ${parsed.protocol}. Only HTTPS URLs are allowed for security reasons`
160
+ );
161
+ }
162
+
163
+ // Reject embedded credentials
164
+ if (parsed.username || parsed.password) {
165
+ return fail('URL must not contain embedded credentials');
166
+ }
167
+
168
+ // Reject localhost and private IP addresses
169
+ const hostname = parsed.hostname.toLowerCase();
170
+ if (
171
+ hostname === 'localhost' ||
172
+ hostname.startsWith('127.') ||
173
+ hostname.startsWith('192.168.') ||
174
+ hostname.startsWith('10.') ||
175
+ hostname.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./) || // 172.16.0.0 - 172.31.255.255
176
+ hostname === '::1' ||
177
+ hostname === '0.0.0.0'
178
+ ) {
179
+ return fail(`URL must not point to localhost or private IP addresses: ${hostname}`);
180
+ }
181
+
182
+ return ok();
183
+ };
184
+
185
+ /**
186
+ * Validate a git sparse-checkout search path to prevent injection attacks.
187
+ *
188
+ * Requirements:
189
+ * - No newlines (prevents multi-line pattern injection)
190
+ * - No path traversal sequences (..)
191
+ * - No absolute paths
192
+ * - Max length enforced
193
+ */
194
+ export const validateSearchPath = (searchPath: string | undefined): ValidationResult => {
195
+ // Empty/undefined search path is valid (means no sparse checkout)
196
+ if (!searchPath || searchPath.trim().length === 0) {
197
+ return ok();
198
+ }
199
+
200
+ if (searchPath.length > LIMITS.SEARCH_PATH_MAX) {
201
+ return fail(
202
+ `Search path too long: ${searchPath.length} chars (max ${LIMITS.SEARCH_PATH_MAX})`
203
+ );
204
+ }
205
+
206
+ // Reject newlines (pattern injection)
207
+ if (searchPath.includes('\n') || searchPath.includes('\r')) {
208
+ return fail('Search path must not contain newline characters');
209
+ }
210
+
211
+ // Reject path traversal sequences
212
+ if (searchPath.includes('..')) {
213
+ return fail('Search path must not contain path traversal sequences (..)')
214
+ }
215
+
216
+ // Reject absolute paths
217
+ if (searchPath.startsWith('/') || searchPath.match(/^[a-zA-Z]:\\/)) {
218
+ return fail('Search path must not be an absolute path');
219
+ }
220
+
221
+ return ok();
222
+ };
223
+
224
+ /**
225
+ * Validate a local file path.
226
+ *
227
+ * Requirements:
228
+ * - Non-empty
229
+ * - No null bytes
230
+ * - Must be absolute path
231
+ */
232
+ export const validateLocalPath = (path: string): ValidationResult => {
233
+ if (!path || path.trim().length === 0) {
234
+ return fail('Local path cannot be empty');
235
+ }
236
+
237
+ // Reject null bytes
238
+ if (path.includes('\0')) {
239
+ return fail('Path must not contain null bytes');
240
+ }
241
+
242
+ // Must be absolute path (starts with / on Unix or drive letter on Windows)
243
+ if (!path.startsWith('/') && !path.match(/^[a-zA-Z]:\\/)) {
244
+ return fail('Local path must be an absolute path');
245
+ }
246
+
247
+ return ok();
248
+ };
249
+
250
+ /**
251
+ * Validate resource notes to prevent excessive content.
252
+ *
253
+ * Requirements:
254
+ * - Max length enforced
255
+ * - No control characters (except newlines and tabs)
256
+ */
257
+ export const validateNotes = (notes: string | undefined): ValidationResult => {
258
+ if (!notes || notes.trim().length === 0) {
259
+ return ok();
260
+ }
261
+
262
+ if (notes.length > LIMITS.NOTES_MAX) {
263
+ return fail(`Notes too long: ${notes.length} chars (max ${LIMITS.NOTES_MAX})`);
264
+ }
265
+
266
+ // Reject control characters except newlines and tabs
267
+ // eslint-disable-next-line no-control-regex
268
+ const hasInvalidControlChars = /[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/.test(notes);
269
+ if (hasInvalidControlChars) {
270
+ return fail('Notes contain invalid control characters');
271
+ }
272
+
273
+ return ok();
274
+ };
275
+
276
+ /**
277
+ * Validate provider name.
278
+ */
279
+ export const validateProviderName = (name: string): ValidationResult => {
280
+ if (!name || name.trim().length === 0) {
281
+ return fail('Provider name cannot be empty');
282
+ }
283
+
284
+ if (name.length > LIMITS.PROVIDER_NAME_MAX) {
285
+ return fail(`Provider name too long: ${name.length} chars (max ${LIMITS.PROVIDER_NAME_MAX})`);
286
+ }
287
+
288
+ if (!SAFE_NAME_REGEX.test(name)) {
289
+ return fail(
290
+ `Invalid provider name: "${name}". Must contain only letters, numbers, and: . _ + - / :`
291
+ );
292
+ }
293
+
294
+ return ok();
295
+ };
296
+
297
+ /**
298
+ * Validate model name.
299
+ */
300
+ export const validateModelName = (name: string): ValidationResult => {
301
+ if (!name || name.trim().length === 0) {
302
+ return fail('Model name cannot be empty');
303
+ }
304
+
305
+ if (name.length > LIMITS.MODEL_NAME_MAX) {
306
+ return fail(`Model name too long: ${name.length} chars (max ${LIMITS.MODEL_NAME_MAX})`);
307
+ }
308
+
309
+ if (!SAFE_NAME_REGEX.test(name)) {
310
+ return fail(
311
+ `Invalid model name: "${name}". Must contain only letters, numbers, and: . _ + - / :`
312
+ );
313
+ }
314
+
315
+ return ok();
316
+ };
317
+
318
+ /**
319
+ * Validate question text for the /question endpoint.
320
+ */
321
+ export const validateQuestion = (question: string): ValidationResult => {
322
+ if (!question || question.trim().length === 0) {
323
+ return fail('Question cannot be empty');
324
+ }
325
+
326
+ if (question.length > LIMITS.QUESTION_MAX) {
327
+ return fail(`Question too long: ${question.length} chars (max ${LIMITS.QUESTION_MAX})`);
328
+ }
329
+
330
+ return ok();
331
+ };
332
+
333
+ /**
334
+ * Validate resources array size.
335
+ */
336
+ export const validateResourcesArray = (resources: string[] | undefined): ValidationResult => {
337
+ if (!resources) {
338
+ return ok();
339
+ }
340
+
341
+ if (resources.length > LIMITS.MAX_RESOURCES_PER_REQUEST) {
342
+ return fail(
343
+ `Too many resources: ${resources.length} (max ${LIMITS.MAX_RESOURCES_PER_REQUEST})`
344
+ );
345
+ }
346
+
347
+ // Validate each resource name
348
+ for (const name of resources) {
349
+ const result = validateResourceName(name);
350
+ if (!result.valid) {
351
+ return result;
352
+ }
353
+ }
354
+
355
+ return ok();
356
+ };
357
+
358
+ // ─────────────────────────────────────────────────────────────────────────────
359
+ // Composite Validators
360
+ // ─────────────────────────────────────────────────────────────────────────────
361
+
362
+ /**
363
+ * Validate a complete git resource definition.
364
+ */
365
+ export const validateGitResource = (resource: {
366
+ name: string;
367
+ url: string;
368
+ branch: string;
369
+ searchPath?: string;
370
+ specialNotes?: string;
371
+ }): ValidationResult => {
372
+ const nameResult = validateResourceName(resource.name);
373
+ if (!nameResult.valid) return nameResult;
374
+
375
+ const urlResult = validateGitUrl(resource.url);
376
+ if (!urlResult.valid) return urlResult;
377
+
378
+ const branchResult = validateBranchName(resource.branch);
379
+ if (!branchResult.valid) return branchResult;
380
+
381
+ const searchPathResult = validateSearchPath(resource.searchPath);
382
+ if (!searchPathResult.valid) return searchPathResult;
383
+
384
+ const notesResult = validateNotes(resource.specialNotes);
385
+ if (!notesResult.valid) return notesResult;
386
+
387
+ return ok();
388
+ };
389
+
390
+ /**
391
+ * Validate a complete local resource definition.
392
+ */
393
+ export const validateLocalResource = (resource: {
394
+ name: string;
395
+ path: string;
396
+ specialNotes?: string;
397
+ }): ValidationResult => {
398
+ const nameResult = validateResourceName(resource.name);
399
+ if (!nameResult.valid) return nameResult;
400
+
401
+ const pathResult = validateLocalPath(resource.path);
402
+ if (!pathResult.valid) return pathResult;
403
+
404
+ const notesResult = validateNotes(resource.specialNotes);
405
+ if (!notesResult.valid) return notesResult;
406
+
407
+ return ok();
408
+ };
409
+
410
+ /**
411
+ * Validate a question request.
412
+ */
413
+ export const validateQuestionRequest = (request: {
414
+ question: string;
415
+ resources?: string[];
416
+ }): ValidationResult => {
417
+ const questionResult = validateQuestion(request.question);
418
+ if (!questionResult.valid) return questionResult;
419
+
420
+ const resourcesResult = validateResourcesArray(request.resources);
421
+ if (!resourcesResult.valid) return resourcesResult;
422
+
423
+ return ok();
424
+ };
425
+
426
+ /**
427
+ * Validate model update request.
428
+ */
429
+ export const validateModelUpdate = (request: {
430
+ provider: string;
431
+ model: string;
432
+ }): ValidationResult => {
433
+ const providerResult = validateProviderName(request.provider);
434
+ if (!providerResult.valid) return providerResult;
435
+
436
+ const modelResult = validateModelName(request.model);
437
+ if (!modelResult.valid) return modelResult;
438
+
439
+ return ok();
440
+ };