voracode 0.0.1
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/LICENSE +21 -0
- package/README.md +146 -0
- package/dist/main.js +2842 -0
- package/package.json +57 -0
- package/src/cli/audit.ts +34 -0
- package/src/cli/config.ts +54 -0
- package/src/cli/doctor.ts +61 -0
- package/src/cli/init.ts +62 -0
- package/src/cli/key.ts +67 -0
- package/src/cli/lite.ts +65 -0
- package/src/cli/mcp.ts +221 -0
- package/src/cli/model.ts +103 -0
- package/src/cli/plugin.ts +49 -0
- package/src/cli/pro.ts +97 -0
- package/src/cli/run.ts +101 -0
- package/src/cli/session.ts +138 -0
- package/src/cli/skill.ts +156 -0
- package/src/cli/stats.ts +16 -0
- package/src/cli/update.ts +28 -0
- package/src/engine/agent.ts +256 -0
- package/src/engine/sub-agent.ts +122 -0
- package/src/main.ts +77 -0
- package/src/models/adapters/all-providers.ts +302 -0
- package/src/models/router.ts +426 -0
- package/src/security/owasp.ts +254 -0
- package/src/session/manager.ts +118 -0
- package/src/skills/self-improve/engine.ts +336 -0
- package/src/storage/config.ts +213 -0
- package/src/storage/database.ts +253 -0
- package/src/storage/mcp-config.ts +170 -0
- package/src/tools/executor.ts +362 -0
- package/src/ui/theme.ts +163 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VORACODE Model Router — Universal AI provider adapter
|
|
3
|
+
*
|
|
4
|
+
* Supports any AI provider through a unified interface.
|
|
5
|
+
*
|
|
6
|
+
* Provider protocols supported:
|
|
7
|
+
* - OpenAI-compatible (DeepSeek, Groq, Together, Ollama, OpenRouter, etc.)
|
|
8
|
+
* - Anthropic Messages API (Claude)
|
|
9
|
+
* - Google AI API (Gemini)
|
|
10
|
+
* - Custom (user-defined)
|
|
11
|
+
*
|
|
12
|
+
* BYOK ONLY — user must provide their own API key.
|
|
13
|
+
* VORACODE does not provide free managed models.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface ChatMessage {
|
|
17
|
+
role: "user" | "assistant" | "system" | "tool";
|
|
18
|
+
content: string;
|
|
19
|
+
name?: string;
|
|
20
|
+
tool_calls?: Array<{
|
|
21
|
+
id: string;
|
|
22
|
+
type: "function";
|
|
23
|
+
function: { name: string; arguments: string };
|
|
24
|
+
}>;
|
|
25
|
+
tool_call_id?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ToolDefinition {
|
|
29
|
+
type: "function";
|
|
30
|
+
function: {
|
|
31
|
+
name: string;
|
|
32
|
+
description: string;
|
|
33
|
+
parameters: Record<string, unknown>;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ChatOptions {
|
|
38
|
+
model: string;
|
|
39
|
+
maxTokens?: number;
|
|
40
|
+
temperature?: number;
|
|
41
|
+
systemPrompt?: string;
|
|
42
|
+
stream?: boolean;
|
|
43
|
+
tools?: ToolDefinition[];
|
|
44
|
+
toolChoice?: "auto" | "required" | "none";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ChatResponse {
|
|
48
|
+
content: string;
|
|
49
|
+
model: string;
|
|
50
|
+
provider: string;
|
|
51
|
+
usage: {
|
|
52
|
+
inputTokens: number;
|
|
53
|
+
outputTokens: number;
|
|
54
|
+
totalTokens: number;
|
|
55
|
+
};
|
|
56
|
+
toolCalls?: Array<{
|
|
57
|
+
id: string;
|
|
58
|
+
type: "function";
|
|
59
|
+
function: { name: string; arguments: string };
|
|
60
|
+
}>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface ProviderConfig {
|
|
64
|
+
apiKey?: string;
|
|
65
|
+
baseUrl: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const PROVIDER_BASE_URLS: Record<string, string> = {
|
|
69
|
+
openai: "https://api.openai.com/v1",
|
|
70
|
+
deepseek: "https://api.deepseek.com/v1",
|
|
71
|
+
groq: "https://api.groq.com/openai/v1",
|
|
72
|
+
together: "https://api.together.xyz/v1",
|
|
73
|
+
openrouter: "https://openrouter.ai/api/v1",
|
|
74
|
+
ollama: "http://localhost:11434/v1",
|
|
75
|
+
huggingface: "https://router.huggingface.co/v1",
|
|
76
|
+
fireworks: "https://api.fireworks.ai/inference/v1",
|
|
77
|
+
cerebras: "https://api.cerebras.ai/v1",
|
|
78
|
+
anthropic: "https://api.anthropic.com/v1",
|
|
79
|
+
google: "https://generativelanguage.googleapis.com/v1beta",
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const PROVIDER_KEY_PATTERNS: Record<string, RegExp> = {
|
|
83
|
+
openai: /^(sk-|sx-)/,
|
|
84
|
+
anthropic: /^sk-ant-/,
|
|
85
|
+
deepseek: /^sk-/,
|
|
86
|
+
groq: /^gsk_/,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// ── Rate Limiter ──
|
|
90
|
+
|
|
91
|
+
class RateLimiter {
|
|
92
|
+
private calls: Map<string, number[]> = new Map();
|
|
93
|
+
private readonly windowMs = 60_000; // 1 minute
|
|
94
|
+
private readonly maxCalls = 10; // 10 calls per minute
|
|
95
|
+
|
|
96
|
+
canCall(provider: string): boolean {
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
const timestamps = this.calls.get(provider) || [];
|
|
99
|
+
|
|
100
|
+
// Remove entries older than the window
|
|
101
|
+
const recent = timestamps.filter((t) => now - t < this.windowMs);
|
|
102
|
+
this.calls.set(provider, recent);
|
|
103
|
+
|
|
104
|
+
if (recent.length >= this.maxCalls) {
|
|
105
|
+
return false; // Rate limited
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
recent.push(now);
|
|
109
|
+
this.calls.set(provider, recent);
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
retryAfter(provider: string): number {
|
|
114
|
+
const timestamps = this.calls.get(provider) || [];
|
|
115
|
+
if (timestamps.length === 0) return 0;
|
|
116
|
+
const oldest = timestamps[0];
|
|
117
|
+
return Math.max(0, Math.ceil((this.windowMs - (Date.now() - oldest)) / 1000));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export class ModelRouter {
|
|
122
|
+
private keychain: Map<string, string> = new Map();
|
|
123
|
+
private rateLimiter: RateLimiter = new RateLimiter();
|
|
124
|
+
|
|
125
|
+
constructor() {
|
|
126
|
+
// Keys will be loaded from OS Keychain in Phase 1.3
|
|
127
|
+
// For now, use environment variables
|
|
128
|
+
this.loadFromEnv();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private loadFromEnv(): void {
|
|
132
|
+
const envMap: Record<string, string | undefined> = {
|
|
133
|
+
openai: process.env.OPENAI_API_KEY || process.env.OPENAI_KEY,
|
|
134
|
+
anthropic: process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_KEY,
|
|
135
|
+
deepseek: process.env.DEEPSEEK_API_KEY || process.env.DEEPSEEK_KEY,
|
|
136
|
+
groq: process.env.GROQ_API_KEY || process.env.GROQ_KEY,
|
|
137
|
+
google: process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY,
|
|
138
|
+
openrouter: process.env.OPENROUTER_API_KEY,
|
|
139
|
+
huggingface: process.env.HF_API_KEY || process.env.HUGGINGFACE_KEY,
|
|
140
|
+
together: process.env.TOGETHER_API_KEY,
|
|
141
|
+
ollama: "", // No key needed for local
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
for (const [provider, key] of Object.entries(envMap)) {
|
|
145
|
+
if (key) {
|
|
146
|
+
this.keychain.set(provider, key);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get the active API key for a provider
|
|
153
|
+
*/
|
|
154
|
+
getKey(provider: string): string | undefined {
|
|
155
|
+
return this.keychain.get(provider);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Set an API key for a provider
|
|
160
|
+
*/
|
|
161
|
+
setKey(provider: string, key: string): void {
|
|
162
|
+
this.keychain.set(provider, key);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Check if a provider has a valid-looking API key configured
|
|
167
|
+
*/
|
|
168
|
+
hasValidKey(provider: string): boolean {
|
|
169
|
+
const key = this.keychain.get(provider);
|
|
170
|
+
if (!key) return false;
|
|
171
|
+
|
|
172
|
+
const pattern = PROVIDER_KEY_PATTERNS[provider];
|
|
173
|
+
if (pattern) return pattern.test(key);
|
|
174
|
+
return key.length > 8;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Detect provider from model name
|
|
179
|
+
*/
|
|
180
|
+
detectProvider(modelName: string): string {
|
|
181
|
+
const normalized = modelName.toLowerCase();
|
|
182
|
+
|
|
183
|
+
if (normalized.startsWith("gpt-") || normalized.startsWith("o3") || normalized.startsWith("o1")) return "openai";
|
|
184
|
+
if (normalized.startsWith("claude-") || normalized.startsWith("opus") || normalized.startsWith("sonnet") || normalized.startsWith("haiku")) return "anthropic";
|
|
185
|
+
if (normalized.startsWith("gemini-")) return "google";
|
|
186
|
+
if (normalized.startsWith("deepseek-")) return "deepseek";
|
|
187
|
+
if (normalized.startsWith("llama-") || normalized.startsWith("mixtral") || normalized.startsWith("qwen-")) return "groq";
|
|
188
|
+
if (normalized.startsWith("command-")) return "cohere";
|
|
189
|
+
|
|
190
|
+
return "openai"; // default fallback
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Get available models for a provider
|
|
195
|
+
*/
|
|
196
|
+
getModels(provider: string): string[] {
|
|
197
|
+
const modelMap: Record<string, string[]> = {
|
|
198
|
+
openai: ["gpt-4o", "gpt-4o-mini", "o3-mini", "o1"],
|
|
199
|
+
anthropic: ["claude-sonnet-4", "claude-opus-4", "claude-haiku-4"],
|
|
200
|
+
deepseek: ["deepseek-chat", "deepseek-v4-flash", "deepseek-v4-pro"],
|
|
201
|
+
groq: ["llama-3.1-8b", "llama-3.1-70b", "qwen-32b", "mixtral-8x7b"],
|
|
202
|
+
google: ["gemini-2.5-pro", "gemini-2.5-flash"],
|
|
203
|
+
ollama: ["llama3.2", "mistral", "deepseek-coder-v2", "qwen2.5-coder"],
|
|
204
|
+
openrouter: ["openrouter/auto"],
|
|
205
|
+
huggingface: ["HuggingFaceH4/zephyr-7b-beta"],
|
|
206
|
+
};
|
|
207
|
+
return modelMap[provider] || [];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Get provider base URL
|
|
212
|
+
*/
|
|
213
|
+
getBaseUrl(provider: string): string {
|
|
214
|
+
return PROVIDER_BASE_URLS[provider] || "";
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Chat completion using OpenAI-compatible API
|
|
219
|
+
* Covers: DeepSeek, Groq, Together, Ollama, OpenRouter, HuggingFace
|
|
220
|
+
*/
|
|
221
|
+
async chatOpenAI(provider: string, messages: ChatMessage[], options: ChatOptions): Promise<ChatResponse> {
|
|
222
|
+
const baseUrl = this.getBaseUrl(provider);
|
|
223
|
+
const apiKey = this.keychain.get(provider);
|
|
224
|
+
|
|
225
|
+
if (!apiKey && provider !== "ollama") {
|
|
226
|
+
throw new Error(`No API key configured for ${provider}. Run: voracode key set ${provider}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const response = await fetch(`${baseUrl}/chat/completions`, {
|
|
230
|
+
method: "POST",
|
|
231
|
+
headers: {
|
|
232
|
+
"Content-Type": "application/json",
|
|
233
|
+
Authorization: apiKey ? `Bearer ${apiKey}` : "",
|
|
234
|
+
},
|
|
235
|
+
body: JSON.stringify({
|
|
236
|
+
model: options.model,
|
|
237
|
+
messages: messages.map((m) => ({
|
|
238
|
+
role: m.role,
|
|
239
|
+
content: m.content,
|
|
240
|
+
...(m.name ? { name: m.name } : {}),
|
|
241
|
+
...(m.tool_calls ? { tool_calls: m.tool_calls } : {}),
|
|
242
|
+
...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
|
|
243
|
+
})),
|
|
244
|
+
max_tokens: options.maxTokens || 4096,
|
|
245
|
+
temperature: options.temperature ?? 0.7,
|
|
246
|
+
stream: false,
|
|
247
|
+
...(options.tools && options.tools.length > 0 ? { tools: options.tools, tool_choice: options.toolChoice || "auto" } : {}),
|
|
248
|
+
}),
|
|
249
|
+
signal: AbortSignal.timeout(60_000), // 60s timeout
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
if (!response.ok) {
|
|
253
|
+
const errorBody = await response.text().catch(() => "");
|
|
254
|
+
throw new Error(`${provider} API error (${response.status}): ${errorBody}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const data = await response.json() as {
|
|
258
|
+
choices: Array<{ message: { content: string; tool_calls?: Array<{ id: string; type: "function"; function: { name: string; arguments: string } }> } }>;
|
|
259
|
+
usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
|
260
|
+
model: string;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
content: data.choices[0]?.message?.content || "",
|
|
265
|
+
model: data.model,
|
|
266
|
+
provider,
|
|
267
|
+
usage: {
|
|
268
|
+
inputTokens: data.usage?.prompt_tokens || 0,
|
|
269
|
+
outputTokens: data.usage?.completion_tokens || 0,
|
|
270
|
+
totalTokens: data.usage?.total_tokens || 0,
|
|
271
|
+
},
|
|
272
|
+
toolCalls: data.choices[0]?.message?.tool_calls,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Chat completion using Anthropic Messages API
|
|
278
|
+
*/
|
|
279
|
+
async chatAnthropic(messages: ChatMessage[], options: ChatOptions): Promise<ChatResponse> {
|
|
280
|
+
const apiKey = this.keychain.get("anthropic");
|
|
281
|
+
if (!apiKey) throw new Error("No API key configured for Anthropic. Run: voracode key set anthropic");
|
|
282
|
+
|
|
283
|
+
// Convert messages to Anthropic format
|
|
284
|
+
const systemMsg = messages.find((m) => m.role === "system");
|
|
285
|
+
const nonSystem = messages.filter((m) => m.role !== "system");
|
|
286
|
+
|
|
287
|
+
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
288
|
+
method: "POST",
|
|
289
|
+
headers: {
|
|
290
|
+
"Content-Type": "application/json",
|
|
291
|
+
"x-api-key": apiKey,
|
|
292
|
+
"anthropic-version": "2023-06-01",
|
|
293
|
+
},
|
|
294
|
+
body: JSON.stringify({
|
|
295
|
+
model: options.model,
|
|
296
|
+
messages: nonSystem.map((m) => ({
|
|
297
|
+
role: m.role === "assistant" ? "assistant" : "user",
|
|
298
|
+
content: m.content,
|
|
299
|
+
})),
|
|
300
|
+
system: systemMsg?.content,
|
|
301
|
+
max_tokens: options.maxTokens || 4096,
|
|
302
|
+
temperature: options.temperature ?? 0.7,
|
|
303
|
+
}),
|
|
304
|
+
signal: AbortSignal.timeout(60_000), // 60s timeout
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
if (!response.ok) {
|
|
308
|
+
const errorBody = await response.text().catch(() => "");
|
|
309
|
+
throw new Error(`Anthropic API error (${response.status}): ${errorBody}`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const data = await response.json() as {
|
|
313
|
+
content: Array<{ text: string }>;
|
|
314
|
+
model: string;
|
|
315
|
+
usage: { input_tokens: number; output_tokens: number };
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
content: data.content?.[0]?.text || "",
|
|
320
|
+
model: data.model,
|
|
321
|
+
provider: "anthropic",
|
|
322
|
+
usage: {
|
|
323
|
+
inputTokens: data.usage?.input_tokens || 0,
|
|
324
|
+
outputTokens: data.usage?.output_tokens || 0,
|
|
325
|
+
totalTokens: (data.usage?.input_tokens || 0) + (data.usage?.output_tokens || 0),
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Main chat method — auto-selects correct adapter based on model name
|
|
332
|
+
*/
|
|
333
|
+
async chat(modelRef: string, messages: ChatMessage[], options: ChatOptions = {}): Promise<ChatResponse> {
|
|
334
|
+
const provider = modelRef.includes("/") ? modelRef.split("/")[0] : this.detectProvider(modelRef);
|
|
335
|
+
const modelName = modelRef.includes("/") ? modelRef.slice(modelRef.indexOf("/") + 1) : modelRef;
|
|
336
|
+
|
|
337
|
+
// Rate limit check
|
|
338
|
+
if (!this.rateLimiter.canCall(provider)) {
|
|
339
|
+
const retryAfter = this.rateLimiter.retryAfter(provider);
|
|
340
|
+
throw new Error(`Rate limited on ${provider}. Retry in ${retryAfter}s. Use voracode pro for higher limits.`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const fullOptions = { ...options, model: modelName };
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
switch (provider) {
|
|
347
|
+
case "anthropic":
|
|
348
|
+
return await this.chatAnthropic(messages, fullOptions);
|
|
349
|
+
case "google":
|
|
350
|
+
return await this.chatGoogle(messages, fullOptions);
|
|
351
|
+
default:
|
|
352
|
+
return await this.chatOpenAI(provider, messages, fullOptions);
|
|
353
|
+
}
|
|
354
|
+
} catch (error) {
|
|
355
|
+
// Auto-fallback: try next provider
|
|
356
|
+
const fallbacks = ["deepseek", "groq", "openrouter"];
|
|
357
|
+
for (const fb of fallbacks) {
|
|
358
|
+
if (fb === provider) continue;
|
|
359
|
+
const fbKey = this.keychain.get(fb);
|
|
360
|
+
if (fbKey) {
|
|
361
|
+
try {
|
|
362
|
+
return await this.chatOpenAI(fb, messages, { ...fullOptions, model: this.getModels(fb)[0] || fb });
|
|
363
|
+
} catch {
|
|
364
|
+
continue; // Try next fallback
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
throw error; // All fallbacks exhausted
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Chat completion using Google AI API
|
|
374
|
+
*/
|
|
375
|
+
async chatGoogle(messages: ChatMessage[], _options: ChatOptions): Promise<ChatResponse> {
|
|
376
|
+
const apiKey = this.keychain.get("google");
|
|
377
|
+
if (!apiKey) throw new Error("No API key configured for Google. Run: voracode key set google");
|
|
378
|
+
|
|
379
|
+
const response = await fetch(
|
|
380
|
+
`https://generativelanguage.googleapis.com/v1beta/models/${_options.model}:generateContent`,
|
|
381
|
+
{
|
|
382
|
+
method: "POST",
|
|
383
|
+
headers: {
|
|
384
|
+
"Content-Type": "application/json",
|
|
385
|
+
"x-goog-api-key": apiKey, // Use header instead of URL query param
|
|
386
|
+
},
|
|
387
|
+
body: JSON.stringify({
|
|
388
|
+
contents: messages
|
|
389
|
+
.filter((m) => m.role !== "system")
|
|
390
|
+
.map((m) => ({
|
|
391
|
+
role: m.role === "assistant" ? "model" : "user",
|
|
392
|
+
parts: [{ text: m.content }],
|
|
393
|
+
})),
|
|
394
|
+
systemInstruction: messages.find((m) => m.role === "system")?.content
|
|
395
|
+
? { parts: [{ text: messages.find((m) => m.role === "system")!.content }] }
|
|
396
|
+
: undefined,
|
|
397
|
+
generationConfig: {
|
|
398
|
+
maxOutputTokens: _options.maxTokens || 4096,
|
|
399
|
+
temperature: _options.temperature ?? 0.7,
|
|
400
|
+
},
|
|
401
|
+
}),
|
|
402
|
+
},
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
if (!response.ok) {
|
|
406
|
+
const errorBody = await response.text().catch(() => "");
|
|
407
|
+
throw new Error(`Google API error (${response.status}): ${errorBody}`);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const data = await response.json() as {
|
|
411
|
+
candidates: Array<{ content: { parts: Array<{ text: string }> } }>;
|
|
412
|
+
usageMetadata?: { promptTokenCount: number; candidatesTokenCount: number; totalTokenCount: number };
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
content: data.candidates?.[0]?.content?.parts?.[0]?.text || "",
|
|
417
|
+
model: _options.model,
|
|
418
|
+
provider: "google",
|
|
419
|
+
usage: {
|
|
420
|
+
inputTokens: data.usageMetadata?.promptTokenCount || 0,
|
|
421
|
+
outputTokens: data.usageMetadata?.candidatesTokenCount || 0,
|
|
422
|
+
totalTokens: data.usageMetadata?.totalTokenCount || 0,
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VORACODE OWASP Security Layer
|
|
3
|
+
*
|
|
4
|
+
* Protects against OWASP Top 10 + 40 years of common vulnerabilities:
|
|
5
|
+
* - Prompt injection (new OWASP LLM Top 10)
|
|
6
|
+
* - Command injection
|
|
7
|
+
* - Path traversal
|
|
8
|
+
* - SSRF
|
|
9
|
+
* - SQL injection
|
|
10
|
+
* - XXE
|
|
11
|
+
* - Supply chain
|
|
12
|
+
* - Credential leakage
|
|
13
|
+
* - Data exfiltration
|
|
14
|
+
* - Rate limiting abuse
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export class OwaspShield {
|
|
18
|
+
private auditLog: string[] = [];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Scan user input for injection patterns
|
|
22
|
+
*/
|
|
23
|
+
sanitizeInput(input: string): { clean: string; blocked: boolean; reason?: string } {
|
|
24
|
+
// Block control characters (except newlines and tabs)
|
|
25
|
+
const controlChars = input.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
|
|
26
|
+
if (controlChars.length !== input.length) {
|
|
27
|
+
this.log("blocked", "Control characters in input");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Detect prompt injection attempts
|
|
31
|
+
const injectionPatterns = [
|
|
32
|
+
/ignore\s+(all\s+)?(previous|above|below)\s+(instructions|commands)/i,
|
|
33
|
+
/forget\s+(all\s+)?(previous|above|below)\s+(instructions|commands)/i,
|
|
34
|
+
/you\s+(are\s+)?(now|will\s+now)\s+(act\s+as|become|be)/i,
|
|
35
|
+
/system\s+prompt/i,
|
|
36
|
+
/new\s+(instructions|commands|task)/i,
|
|
37
|
+
/disregard\s+(previous|above)/i,
|
|
38
|
+
/\[SYSTEM\]|\[INST\]|<\|im_start\|>|<\|\s*im_start\s*\|>/i,
|
|
39
|
+
/role\s*:\s*system/i,
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
for (const pattern of injectionPatterns) {
|
|
43
|
+
if (pattern.test(input)) {
|
|
44
|
+
this.log("blocked", `Prompt injection detected: ${pattern.source}`);
|
|
45
|
+
return { clean: input, blocked: true, reason: "Potential prompt injection detected" };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { clean: input, blocked: false };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Validate LLM output for dangerous content
|
|
54
|
+
*/
|
|
55
|
+
validateOutput(output: string): { safe: boolean; sanitized: string; reason?: string } {
|
|
56
|
+
// Check for credential leakage
|
|
57
|
+
const credentialPatterns = [
|
|
58
|
+
/sk-[a-zA-Z0-9]{20,}/, // OpenAI keys
|
|
59
|
+
/sk-ant-[a-zA-Z0-9]{20,}/, // Anthropic keys
|
|
60
|
+
/gsk_[a-zA-Z0-9]{20,}/, // Groq keys
|
|
61
|
+
/AIza[a-zA-Z0-9_-]{35}/, // Google keys
|
|
62
|
+
/ghp_[a-zA-Z0-9]{36}/, // GitHub tokens
|
|
63
|
+
/-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----/i, // Private keys
|
|
64
|
+
/password\s*[:=]\s*\S+/i, // Password leakage
|
|
65
|
+
/api[_-]?key\s*[:=]\s*\S+/i, // API key leakage
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
let sanitized = output;
|
|
69
|
+
for (const pattern of credentialPatterns) {
|
|
70
|
+
if (pattern.test(sanitized)) {
|
|
71
|
+
sanitized = sanitized.replace(pattern, "[REDACTED - CREDENTIAL]");
|
|
72
|
+
this.log("redacted", "Credential pattern detected in output");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Check for dangerous code patterns in LLM output
|
|
77
|
+
const dangerousCode = [
|
|
78
|
+
/rm\s+-rf\s+\//g,
|
|
79
|
+
/>\s*\/dev\/(sda|sdb|sdc)/g,
|
|
80
|
+
/:\(\s*\)\s*\{/g,
|
|
81
|
+
/eval\(\s*request/i,
|
|
82
|
+
/exec\(\s*request/i,
|
|
83
|
+
/system\(\s*request/i,
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
for (const pattern of dangerousCode) {
|
|
87
|
+
if (pattern.test(sanitized)) {
|
|
88
|
+
this.log("warn", "Dangerous code pattern in output");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { safe: true, sanitized };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Check SSRF protection — block private IPs, internal services
|
|
97
|
+
*/
|
|
98
|
+
validateUrl(url: string): { safe: boolean; reason?: string } {
|
|
99
|
+
try {
|
|
100
|
+
const parsed = new URL(url);
|
|
101
|
+
|
|
102
|
+
// Block private/internal IP ranges
|
|
103
|
+
const blockedPatterns = [
|
|
104
|
+
/^localhost$/i,
|
|
105
|
+
/^127\.\d+\.\d+\.\d+$/,
|
|
106
|
+
/^10\.\d+\.\d+\.\d+$/,
|
|
107
|
+
/^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/,
|
|
108
|
+
/^192\.168\.\d+\.\d+$/,
|
|
109
|
+
/^0\.0\.0\.0$/,
|
|
110
|
+
/^::1$/,
|
|
111
|
+
/^fc00:/i,
|
|
112
|
+
/^fe80:/i,
|
|
113
|
+
/^169\.254\.\d+\.\d+$/,
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
const hostname = parsed.hostname;
|
|
117
|
+
for (const pattern of blockedPatterns) {
|
|
118
|
+
if (pattern.test(hostname)) {
|
|
119
|
+
this.log("blocked", `SSRF blocked: ${url}`);
|
|
120
|
+
return { safe: false, reason: "Internal/private address blocked (SSRF)" };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Block known internal services
|
|
125
|
+
const internalServices = [
|
|
126
|
+
/metadata\.google\.internal/i,
|
|
127
|
+
/169\.254\.169\.254/, // AWS/GCP/Azure metadata
|
|
128
|
+
/\.internal$/i,
|
|
129
|
+
/\.local$/i,
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
for (const pattern of internalServices) {
|
|
133
|
+
if (pattern.test(hostname)) {
|
|
134
|
+
this.log("blocked", `Internal service blocked: ${url}`);
|
|
135
|
+
return { safe: false, reason: "Cloud metadata endpoint blocked" };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { safe: true };
|
|
140
|
+
} catch {
|
|
141
|
+
return { safe: false, reason: "Invalid URL" };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Check for path traversal in file operations
|
|
147
|
+
*/
|
|
148
|
+
validatePath(requestedPath: string, projectRoot: string): { safe: boolean; resolvedPath?: string; reason?: string } {
|
|
149
|
+
const path = require("path");
|
|
150
|
+
const resolved = path.resolve(projectRoot, requestedPath);
|
|
151
|
+
|
|
152
|
+
// Ensure resolved path starts with project root
|
|
153
|
+
if (!resolved.startsWith(projectRoot)) {
|
|
154
|
+
this.log("blocked", `Path traversal blocked: ${requestedPath}`);
|
|
155
|
+
return { safe: false, reason: "Path traversal detected" };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Block sensitive files
|
|
159
|
+
const sensitiveFiles = [
|
|
160
|
+
/\.env$/,
|
|
161
|
+
/\.env\./,
|
|
162
|
+
/credentials\.json$/,
|
|
163
|
+
/config\.json$/i,
|
|
164
|
+
/\.ssh\//,
|
|
165
|
+
/id_rsa/,
|
|
166
|
+
/\.pem$/,
|
|
167
|
+
/\.key$/,
|
|
168
|
+
/\.cert$/,
|
|
169
|
+
/\/etc\//,
|
|
170
|
+
/\/proc\//,
|
|
171
|
+
/\/sys\//,
|
|
172
|
+
/\/dev\//,
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
for (const pattern of sensitiveFiles) {
|
|
176
|
+
if (pattern.test(resolved)) {
|
|
177
|
+
this.log("blocked", `Sensitive file blocked: ${resolved}`);
|
|
178
|
+
return { safe: false, reason: "Access to sensitive files blocked" };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { safe: true, resolvedPath: resolved };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Detect and block command injection in bash/shell commands
|
|
187
|
+
*/
|
|
188
|
+
validateCommand(command: string): { safe: boolean; reason?: string } {
|
|
189
|
+
// Block dangerous commands
|
|
190
|
+
const blockedCommands = [
|
|
191
|
+
/^rm\s+-rf\s+\//m, /^sudo\s+/m, /^mkfs/m, /^dd\s+if=/m,
|
|
192
|
+
/^:\(\)/, /^chmod\s+777\s+\//m,
|
|
193
|
+
/^>\s*\/dev\//m, /bash\s+-c\s+/i, /sh\s+-c\s+/i,
|
|
194
|
+
/powershell\s+-c\s+/i, /cmd\.exe\s+\/c/i,
|
|
195
|
+
/`.*`/, /\$\(.*\)/, /\|.*(bash|sh|zsh|fish)/i,
|
|
196
|
+
/curl.*\|.*bash/i, /wget.*\|.*bash/i,
|
|
197
|
+
/nc\s+/, /ncat\s+/, /netcat\s+/,
|
|
198
|
+
/mkpasswd/, /openssl\s+(passwd|enc)/i,
|
|
199
|
+
/\/etc\/(passwd|shadow)/,
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
for (const pattern of blockedCommands) {
|
|
203
|
+
if (pattern.test(command)) {
|
|
204
|
+
this.log("blocked", `Command injection blocked: ${command.slice(0, 50)}`);
|
|
205
|
+
return { safe: false, reason: "Command blocked for security" };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Check for excessive command length (potential buffer overflow)
|
|
210
|
+
if (command.length > 10000) {
|
|
211
|
+
return { safe: false, reason: "Command too long (max 10000 chars)" };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return { safe: true };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Check rate limit
|
|
219
|
+
*/
|
|
220
|
+
isRateLimited(provider: string, callCount: number, maxPerMinute = 10): boolean {
|
|
221
|
+
if (callCount > maxPerMinute) {
|
|
222
|
+
this.log("rate_limited", `${provider}: ${callCount} calls/min`);
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Validate API key format
|
|
230
|
+
*/
|
|
231
|
+
validateApiKey(provider: string, key: string): boolean {
|
|
232
|
+
const patterns: Record<string, RegExp> = {
|
|
233
|
+
openai: /^(sk-|sx-)[a-zA-Z0-9]{20,}$/,
|
|
234
|
+
anthropic: /^sk-ant-[a-zA-Z0-9]{20,}$/,
|
|
235
|
+
deepseek: /^sk-[a-zA-Z0-9]{20,}$/,
|
|
236
|
+
groq: /^gsk_[a-zA-Z0-9]{20,}$/,
|
|
237
|
+
google: /^AIza[a-zA-Z0-9_-]{35,}$/,
|
|
238
|
+
openrouter: /^sk-or-v1-[a-zA-Z0-9]{20,}$/,
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
if (patterns[provider]) {
|
|
242
|
+
return patterns[provider].test(key);
|
|
243
|
+
}
|
|
244
|
+
return key.length > 8; // Basic length check for unknown providers
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private log(type: string, detail: string): void {
|
|
248
|
+
this.auditLog.push(`[${new Date().toISOString()}] ${type}: ${detail}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
getAuditLog(): string[] {
|
|
252
|
+
return this.auditLog;
|
|
253
|
+
}
|
|
254
|
+
}
|