agent-security-scanner-mcp 3.20.0 → 4.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.
Files changed (126) hide show
  1. package/README.md +144 -43
  2. package/code-review-agent/.env.example +8 -0
  3. package/code-review-agent/README.md +142 -0
  4. package/code-review-agent/TODO.md +149 -0
  5. package/code-review-agent/bin/cr-agent.ts +313 -0
  6. package/code-review-agent/dist/bin/cr-agent.d.ts +3 -0
  7. package/code-review-agent/dist/bin/cr-agent.d.ts.map +1 -0
  8. package/code-review-agent/dist/bin/cr-agent.js +299 -0
  9. package/code-review-agent/dist/bin/cr-agent.js.map +1 -0
  10. package/code-review-agent/dist/src/analyzer/engine.d.ts +16 -0
  11. package/code-review-agent/dist/src/analyzer/engine.d.ts.map +1 -0
  12. package/code-review-agent/dist/src/analyzer/engine.js +298 -0
  13. package/code-review-agent/dist/src/analyzer/engine.js.map +1 -0
  14. package/code-review-agent/dist/src/analyzer/intent.d.ts +10 -0
  15. package/code-review-agent/dist/src/analyzer/intent.d.ts.map +1 -0
  16. package/code-review-agent/dist/src/analyzer/intent.js +40 -0
  17. package/code-review-agent/dist/src/analyzer/intent.js.map +1 -0
  18. package/code-review-agent/dist/src/analyzer/semantic.d.ts +19 -0
  19. package/code-review-agent/dist/src/analyzer/semantic.d.ts.map +1 -0
  20. package/code-review-agent/dist/src/analyzer/semantic.js +150 -0
  21. package/code-review-agent/dist/src/analyzer/semantic.js.map +1 -0
  22. package/code-review-agent/dist/src/context/assembler.d.ts +16 -0
  23. package/code-review-agent/dist/src/context/assembler.d.ts.map +1 -0
  24. package/code-review-agent/dist/src/context/assembler.js +135 -0
  25. package/code-review-agent/dist/src/context/assembler.js.map +1 -0
  26. package/code-review-agent/dist/src/context/file.d.ts +6 -0
  27. package/code-review-agent/dist/src/context/file.d.ts.map +1 -0
  28. package/code-review-agent/dist/src/context/file.js +139 -0
  29. package/code-review-agent/dist/src/context/file.js.map +1 -0
  30. package/code-review-agent/dist/src/context/project.d.ts +4 -0
  31. package/code-review-agent/dist/src/context/project.d.ts.map +1 -0
  32. package/code-review-agent/dist/src/context/project.js +252 -0
  33. package/code-review-agent/dist/src/context/project.js.map +1 -0
  34. package/code-review-agent/dist/src/graph/dependency.d.ts +11 -0
  35. package/code-review-agent/dist/src/graph/dependency.d.ts.map +1 -0
  36. package/code-review-agent/dist/src/graph/dependency.js +102 -0
  37. package/code-review-agent/dist/src/graph/dependency.js.map +1 -0
  38. package/code-review-agent/dist/src/graph/resolver.d.ts +9 -0
  39. package/code-review-agent/dist/src/graph/resolver.d.ts.map +1 -0
  40. package/code-review-agent/dist/src/graph/resolver.js +124 -0
  41. package/code-review-agent/dist/src/graph/resolver.js.map +1 -0
  42. package/code-review-agent/dist/src/index.d.ts +21 -0
  43. package/code-review-agent/dist/src/index.d.ts.map +1 -0
  44. package/code-review-agent/dist/src/index.js +21 -0
  45. package/code-review-agent/dist/src/index.js.map +1 -0
  46. package/code-review-agent/dist/src/llm/anthropic.d.ts +13 -0
  47. package/code-review-agent/dist/src/llm/anthropic.d.ts.map +1 -0
  48. package/code-review-agent/dist/src/llm/anthropic.js +83 -0
  49. package/code-review-agent/dist/src/llm/anthropic.js.map +1 -0
  50. package/code-review-agent/dist/src/llm/claude-cli.d.ts +13 -0
  51. package/code-review-agent/dist/src/llm/claude-cli.d.ts.map +1 -0
  52. package/code-review-agent/dist/src/llm/claude-cli.js +142 -0
  53. package/code-review-agent/dist/src/llm/claude-cli.js.map +1 -0
  54. package/code-review-agent/dist/src/llm/openai.d.ts +13 -0
  55. package/code-review-agent/dist/src/llm/openai.d.ts.map +1 -0
  56. package/code-review-agent/dist/src/llm/openai.js +78 -0
  57. package/code-review-agent/dist/src/llm/openai.js.map +1 -0
  58. package/code-review-agent/dist/src/llm/provider.d.ts +18 -0
  59. package/code-review-agent/dist/src/llm/provider.d.ts.map +1 -0
  60. package/code-review-agent/dist/src/llm/provider.js +11 -0
  61. package/code-review-agent/dist/src/llm/provider.js.map +1 -0
  62. package/code-review-agent/dist/src/llm/router.d.ts +14 -0
  63. package/code-review-agent/dist/src/llm/router.d.ts.map +1 -0
  64. package/code-review-agent/dist/src/llm/router.js +67 -0
  65. package/code-review-agent/dist/src/llm/router.js.map +1 -0
  66. package/code-review-agent/dist/src/llm/schemas.d.ts +18 -0
  67. package/code-review-agent/dist/src/llm/schemas.d.ts.map +1 -0
  68. package/code-review-agent/dist/src/llm/schemas.js +91 -0
  69. package/code-review-agent/dist/src/llm/schemas.js.map +1 -0
  70. package/code-review-agent/dist/src/types/analysis.d.ts +56 -0
  71. package/code-review-agent/dist/src/types/analysis.d.ts.map +1 -0
  72. package/code-review-agent/dist/src/types/analysis.js +2 -0
  73. package/code-review-agent/dist/src/types/analysis.js.map +1 -0
  74. package/code-review-agent/dist/src/types/config.d.ts +24 -0
  75. package/code-review-agent/dist/src/types/config.d.ts.map +1 -0
  76. package/code-review-agent/dist/src/types/config.js +42 -0
  77. package/code-review-agent/dist/src/types/config.js.map +1 -0
  78. package/code-review-agent/dist/src/types/findings.d.ts +236 -0
  79. package/code-review-agent/dist/src/types/findings.d.ts.map +1 -0
  80. package/code-review-agent/dist/src/types/findings.js +64 -0
  81. package/code-review-agent/dist/src/types/findings.js.map +1 -0
  82. package/code-review-agent/package.json +36 -0
  83. package/code-review-agent/src/analyzer/engine.ts +374 -0
  84. package/code-review-agent/src/analyzer/intent.ts +49 -0
  85. package/code-review-agent/src/analyzer/semantic.ts +222 -0
  86. package/code-review-agent/src/context/assembler.ts +165 -0
  87. package/code-review-agent/src/context/file.ts +145 -0
  88. package/code-review-agent/src/context/project.ts +253 -0
  89. package/code-review-agent/src/graph/dependency.ts +116 -0
  90. package/code-review-agent/src/graph/resolver.ts +138 -0
  91. package/code-review-agent/src/index.ts +58 -0
  92. package/code-review-agent/src/llm/anthropic.ts +106 -0
  93. package/code-review-agent/src/llm/claude-cli.ts +188 -0
  94. package/code-review-agent/src/llm/openai.ts +95 -0
  95. package/code-review-agent/src/llm/provider.ts +33 -0
  96. package/code-review-agent/src/llm/router.ts +86 -0
  97. package/code-review-agent/src/llm/schemas.ts +125 -0
  98. package/code-review-agent/src/types/analysis.ts +62 -0
  99. package/code-review-agent/src/types/config.ts +72 -0
  100. package/code-review-agent/src/types/findings.ts +81 -0
  101. package/code-review-agent/tests/analyzer/engine.test.ts +194 -0
  102. package/code-review-agent/tests/analyzer/intent.test.ts +76 -0
  103. package/code-review-agent/tests/analyzer/semantic.test.ts +131 -0
  104. package/code-review-agent/tests/context/file.test.ts +21 -0
  105. package/code-review-agent/tests/context/project.test.ts +20 -0
  106. package/code-review-agent/tests/fixtures/safe-build-tool/README.md +19 -0
  107. package/code-review-agent/tests/fixtures/safe-build-tool/builder.js +52 -0
  108. package/code-review-agent/tests/fixtures/safe-file-manager/README.md +16 -0
  109. package/code-review-agent/tests/fixtures/safe-file-manager/organizer.py +70 -0
  110. package/code-review-agent/tests/fixtures/vuln-api-server/README.md +17 -0
  111. package/code-review-agent/tests/fixtures/vuln-api-server/server.js +52 -0
  112. package/code-review-agent/tests/fixtures/vuln-ecommerce/README.md +18 -0
  113. package/code-review-agent/tests/fixtures/vuln-ecommerce/checkout.js +63 -0
  114. package/code-review-agent/tests/graph/dependency.test.ts +136 -0
  115. package/code-review-agent/tests/helpers/mock-provider.ts +48 -0
  116. package/code-review-agent/tests/llm/claude-cli.test.ts +251 -0
  117. package/code-review-agent/tests/llm/router.test.ts +77 -0
  118. package/code-review-agent/tests/llm/schemas.test.ts +142 -0
  119. package/code-review-agent/tsconfig.json +20 -0
  120. package/code-review-agent/vitest.config.ts +11 -0
  121. package/index.js +18 -18
  122. package/openclaw.plugin.json +2 -2
  123. package/package.json +13 -3
  124. package/server.json +3 -3
  125. package/src/cli/init-hooks.js +3 -3
  126. package/src/cli/init.js +1 -1
@@ -0,0 +1,138 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+
4
+ export interface ImportInfo {
5
+ specifier: string;
6
+ isLocal: boolean;
7
+ resolved: string | null;
8
+ }
9
+
10
+ const JS_EXTENSIONS = ['.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs'];
11
+ const PY_EXTENSIONS = ['.py'];
12
+
13
+ export function extractImports(content: string, language: string): ImportInfo[] {
14
+ const imports: ImportInfo[] = [];
15
+
16
+ if (['javascript', 'typescript'].includes(language)) {
17
+ // ES imports
18
+ for (const m of content.matchAll(/import\s+(?:.*?\s+from\s+)?['"]([^'"]+)['"]/g)) {
19
+ const spec = m[1];
20
+ imports.push({ specifier: spec, isLocal: isLocalImport(spec, language), resolved: null });
21
+ }
22
+ // Re-exports: export { ... } from '...', export * from '...', export { default } from '...'
23
+ for (const m of content.matchAll(/export\s+(?:\*|{[^}]*})\s+from\s+['"]([^'"]+)['"]/g)) {
24
+ const spec = m[1];
25
+ imports.push({ specifier: spec, isLocal: isLocalImport(spec, language), resolved: null });
26
+ }
27
+ // require
28
+ for (const m of content.matchAll(/require\s*\(\s*['"]([^'"]+)['"]\s*\)/g)) {
29
+ const spec = m[1];
30
+ imports.push({ specifier: spec, isLocal: isLocalImport(spec, language), resolved: null });
31
+ }
32
+ // dynamic import
33
+ for (const m of content.matchAll(/import\s*\(\s*['"]([^'"]+)['"]\s*\)/g)) {
34
+ const spec = m[1];
35
+ imports.push({ specifier: spec, isLocal: isLocalImport(spec, language), resolved: null });
36
+ }
37
+ } else if (language === 'python') {
38
+ // from .module import ... (relative) and from package import ... (absolute)
39
+ for (const m of content.matchAll(/from\s+(\S+)\s+import/g)) {
40
+ const spec = m[1];
41
+ imports.push({ specifier: spec, isLocal: isLocalImport(spec, language), resolved: null });
42
+ }
43
+ // import module
44
+ for (const m of content.matchAll(/^import\s+(\S+)/gm)) {
45
+ const spec = m[1];
46
+ imports.push({ specifier: spec, isLocal: isLocalImport(spec, language), resolved: null });
47
+ }
48
+ } else if (language === 'go') {
49
+ for (const m of content.matchAll(/["']([^"']+)["']/g)) {
50
+ const spec = m[1];
51
+ if (spec.includes('/')) {
52
+ imports.push({ specifier: spec, isLocal: isLocalImport(spec, language), resolved: null });
53
+ }
54
+ }
55
+ }
56
+
57
+ return imports;
58
+ }
59
+
60
+ export function resolveImportPath(
61
+ specifier: string,
62
+ fromFile: string,
63
+ language: string,
64
+ ): string | null {
65
+ if (!isLocalImport(specifier, language)) return null;
66
+
67
+ const fromDir = path.dirname(fromFile);
68
+
69
+ if (['javascript', 'typescript'].includes(language)) {
70
+ // Try exact path first, then with extensions, then index files
71
+ const candidates: string[] = [];
72
+ const base = path.resolve(fromDir, specifier);
73
+
74
+ candidates.push(base);
75
+ for (const ext of JS_EXTENSIONS) {
76
+ candidates.push(base + ext);
77
+ }
78
+ for (const ext of JS_EXTENSIONS) {
79
+ candidates.push(path.join(base, `index${ext}`));
80
+ }
81
+
82
+ for (const candidate of candidates) {
83
+ try {
84
+ if (fs.statSync(candidate).isFile()) return candidate;
85
+ } catch { /* not found */ }
86
+ }
87
+ } else if (language === 'python') {
88
+ // Handle relative imports: .utils, ..models, .sub.module
89
+ let resolveDir = fromDir;
90
+ let moduleSpec = specifier;
91
+
92
+ if (specifier.startsWith('.')) {
93
+ // Count leading dots for parent traversal
94
+ const dotMatch = specifier.match(/^(\.+)(.*)/);
95
+ if (dotMatch) {
96
+ const dots = dotMatch[1].length;
97
+ moduleSpec = dotMatch[2]; // remainder after dots (may be empty for bare ".")
98
+ // Each dot beyond the first goes up one directory
99
+ resolveDir = fromDir;
100
+ for (let i = 1; i < dots; i++) {
101
+ resolveDir = path.dirname(resolveDir);
102
+ }
103
+ }
104
+ }
105
+
106
+ // Convert module.path to filesystem path
107
+ const modulePath = moduleSpec ? moduleSpec.replace(/\./g, path.sep) : '';
108
+ const base = modulePath ? path.resolve(resolveDir, modulePath) : resolveDir;
109
+
110
+ for (const ext of PY_EXTENSIONS) {
111
+ try {
112
+ const candidate = base + ext;
113
+ if (fs.statSync(candidate).isFile()) return candidate;
114
+ } catch { /* not found */ }
115
+ }
116
+
117
+ // Try as package (directory with __init__.py)
118
+ try {
119
+ const initFile = path.join(base, '__init__.py');
120
+ if (fs.statSync(initFile).isFile()) return initFile;
121
+ } catch { /* not found */ }
122
+ }
123
+
124
+ return null;
125
+ }
126
+
127
+ export function isLocalImport(specifier: string, language: string): boolean {
128
+ if (['javascript', 'typescript'].includes(language)) {
129
+ return specifier.startsWith('./') || specifier.startsWith('../');
130
+ }
131
+ if (language === 'python') {
132
+ return specifier.startsWith('.');
133
+ }
134
+ if (language === 'go') {
135
+ return !specifier.includes('.');
136
+ }
137
+ return false;
138
+ }
@@ -0,0 +1,58 @@
1
+ // Core engine
2
+ export { AnalysisEngine, type ProgressCallback } from './analyzer/engine.js';
3
+ export { IntentProfiler } from './analyzer/intent.js';
4
+ export { SemanticAnalyzer } from './analyzer/semantic.js';
5
+
6
+ // LLM providers
7
+ export { AnthropicProvider } from './llm/anthropic.js';
8
+ export { ClaudeCliProvider } from './llm/claude-cli.js';
9
+ export { OpenAIProvider } from './llm/openai.js';
10
+ export { ModelRouter } from './llm/router.js';
11
+ export type { LLMProvider, ChatMessage } from './llm/provider.js';
12
+ export { SchemaValidationError } from './llm/provider.js';
13
+ export { zodToJsonSchema, zodToAnthropicTool, zodToOpenAIResponseFormat } from './llm/schemas.js';
14
+
15
+ // Context
16
+ export { buildProjectContext, formatProjectContextForLLM } from './context/project.js';
17
+ export { buildFileContext, isTestFile, isConfigFile, isGeneratedFile } from './context/file.js';
18
+ export { ContextAssembler } from './context/assembler.js';
19
+
20
+ // Graph
21
+ export { DependencyGraphBuilder } from './graph/dependency.js';
22
+ export { resolveImportPath, extractImports, isLocalImport } from './graph/resolver.js';
23
+
24
+ // Types
25
+ export type {
26
+ AnalysisResult,
27
+ AnalysisStats,
28
+ FileAnalysisResult,
29
+ ProjectContext,
30
+ FileContext,
31
+ DependencyNode,
32
+ DependencyGraph,
33
+ } from './types/analysis.js';
34
+ export type {
35
+ AnalysisOptions,
36
+ CRAgentConfig,
37
+ } from './types/config.js';
38
+ export { loadConfig, resolveOptions } from './types/config.js';
39
+ export {
40
+ FindingSchema,
41
+ FileAnalysisResponseSchema,
42
+ IntentProfileSchema,
43
+ TriageDecisionSchema,
44
+ SeveritySchema,
45
+ CategorySchema,
46
+ IntentAlignmentSchema,
47
+ RiskDomainSchema,
48
+ } from './types/findings.js';
49
+ export type {
50
+ Finding,
51
+ FileAnalysisResponse,
52
+ IntentProfile,
53
+ TriageDecision,
54
+ Severity,
55
+ Category,
56
+ IntentAlignment,
57
+ RiskDomain,
58
+ } from './types/findings.js';
@@ -0,0 +1,106 @@
1
+ import Anthropic from '@anthropic-ai/sdk';
2
+ import { countTokens } from '@anthropic-ai/tokenizer';
3
+ import type { z } from 'zod';
4
+ import { type ChatMessage, type LLMProvider, SchemaValidationError } from './provider.js';
5
+ import { zodToAnthropicTool } from './schemas.js';
6
+
7
+ const MAX_RETRIES = 3;
8
+
9
+ export class AnthropicProvider implements LLMProvider {
10
+ private client: Anthropic;
11
+ readonly modelId: string;
12
+ readonly providerName = 'anthropic';
13
+
14
+ constructor(apiKey: string, model?: string) {
15
+ this.client = new Anthropic({ apiKey });
16
+ this.modelId = model ?? 'claude-sonnet-4-20250514';
17
+ }
18
+
19
+ async chat(messages: ChatMessage[]): Promise<string> {
20
+ const { system, userMessages } = this.splitMessages(messages);
21
+
22
+ const response = await this.client.messages.create({
23
+ model: this.modelId,
24
+ max_tokens: 4096,
25
+ system: system ?? undefined,
26
+ messages: userMessages,
27
+ });
28
+
29
+ const textBlock = response.content.find((b) => b.type === 'text');
30
+ return textBlock?.text ?? '';
31
+ }
32
+
33
+ async chatStructured<T>(
34
+ messages: ChatMessage[],
35
+ schema: z.ZodType<T>,
36
+ schemaName: string,
37
+ ): Promise<T> {
38
+ const tool = zodToAnthropicTool(schema, schemaName, `Respond with ${schemaName}`);
39
+ const { system, userMessages } = this.splitMessages(messages);
40
+
41
+ let lastError: Error | null = null;
42
+ const conversationMessages = [...userMessages];
43
+
44
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
45
+ const response = await this.client.messages.create({
46
+ model: this.modelId,
47
+ max_tokens: 8192,
48
+ system: system ?? undefined,
49
+ messages: conversationMessages,
50
+ tools: [tool as Anthropic.Tool],
51
+ tool_choice: { type: 'tool' as const, name: schemaName },
52
+ });
53
+
54
+ const toolBlock = response.content.find((b) => b.type === 'tool_use');
55
+ if (!toolBlock || toolBlock.type !== 'tool_use') {
56
+ lastError = new Error('No tool_use block in response');
57
+ continue;
58
+ }
59
+
60
+ const result = schema.safeParse(toolBlock.input);
61
+ if (result.success) {
62
+ return result.data;
63
+ }
64
+
65
+ lastError = new Error(result.error.message);
66
+
67
+ // Append error feedback for retry
68
+ conversationMessages.push({
69
+ role: 'assistant',
70
+ content: JSON.stringify(toolBlock.input),
71
+ });
72
+ conversationMessages.push({
73
+ role: 'user',
74
+ content: `Schema validation error: ${result.error.message}. Please fix and respond again.`,
75
+ });
76
+ }
77
+
78
+ throw new SchemaValidationError(MAX_RETRIES, lastError!);
79
+ }
80
+
81
+ countTokens(text: string): number {
82
+ try {
83
+ return countTokens(text);
84
+ } catch {
85
+ return Math.ceil(text.length / 4);
86
+ }
87
+ }
88
+
89
+ private splitMessages(messages: ChatMessage[]): {
90
+ system: string | null;
91
+ userMessages: Array<{ role: 'user' | 'assistant'; content: string }>;
92
+ } {
93
+ let system: string | null = null;
94
+ const userMessages: Array<{ role: 'user' | 'assistant'; content: string }> = [];
95
+
96
+ for (const msg of messages) {
97
+ if (msg.role === 'system') {
98
+ system = msg.content;
99
+ } else {
100
+ userMessages.push({ role: msg.role, content: msg.content });
101
+ }
102
+ }
103
+
104
+ return { system, userMessages };
105
+ }
106
+ }
@@ -0,0 +1,188 @@
1
+ import { spawn } from 'node:child_process';
2
+ import type { z } from 'zod';
3
+ import { type ChatMessage, type LLMProvider, SchemaValidationError } from './provider.js';
4
+ import { zodToJsonSchema } from './schemas.js';
5
+
6
+ const MAX_RETRIES = 3;
7
+
8
+ interface ClaudeCliResult {
9
+ type: string;
10
+ result: string;
11
+ is_error: boolean;
12
+ usage?: {
13
+ input_tokens?: number;
14
+ output_tokens?: number;
15
+ };
16
+ }
17
+
18
+ export class ClaudeCliProvider implements LLMProvider {
19
+ readonly modelId: string;
20
+ readonly providerName = 'claude-cli';
21
+
22
+ constructor(model?: string) {
23
+ this.modelId = model ?? 'sonnet';
24
+ }
25
+
26
+ async chat(messages: ChatMessage[]): Promise<string> {
27
+ const prompt = this.formatMessages(messages);
28
+ return this.runClaude(prompt);
29
+ }
30
+
31
+ async chatStructured<T>(
32
+ messages: ChatMessage[],
33
+ schema: z.ZodType<T>,
34
+ schemaName: string,
35
+ ): Promise<T> {
36
+ const jsonSchema = zodToJsonSchema(schema);
37
+ const schemaInstruction = [
38
+ `You MUST respond with ONLY a valid JSON object matching this schema (no markdown, no explanation, no wrapping):`,
39
+ `Schema name: ${schemaName}`,
40
+ '```json',
41
+ JSON.stringify(jsonSchema, null, 2),
42
+ '```',
43
+ 'Respond with ONLY the JSON object. No other text.',
44
+ ].join('\n');
45
+
46
+ let lastError: Error | null = null;
47
+ const conversationParts = [...messages];
48
+
49
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
50
+ const prompt = this.formatMessages([
51
+ ...conversationParts,
52
+ { role: 'user', content: schemaInstruction },
53
+ ]);
54
+
55
+ const raw = await this.runClaude(prompt);
56
+
57
+ // Extract JSON from response (handle markdown code blocks)
58
+ const jsonStr = extractJson(raw);
59
+
60
+ let parsed: unknown;
61
+ try {
62
+ parsed = JSON.parse(jsonStr);
63
+ } catch (e) {
64
+ lastError = e instanceof Error ? e : new Error(String(e));
65
+ conversationParts.push(
66
+ { role: 'assistant', content: raw },
67
+ { role: 'user', content: `That was not valid JSON. Parse error: ${lastError.message}. Respond with ONLY a valid JSON object.` },
68
+ );
69
+ continue;
70
+ }
71
+
72
+ const result = schema.safeParse(parsed);
73
+ if (result.success) {
74
+ return result.data;
75
+ }
76
+
77
+ lastError = new Error(result.error.message);
78
+ conversationParts.push(
79
+ { role: 'assistant', content: raw },
80
+ { role: 'user', content: `Schema validation error: ${result.error.message}. Fix the JSON and respond with ONLY the corrected JSON object.` },
81
+ );
82
+ }
83
+
84
+ throw new SchemaValidationError(MAX_RETRIES, lastError!);
85
+ }
86
+
87
+ countTokens(text: string): number {
88
+ // Approximate — Claude CLI doesn't expose a token counter
89
+ return Math.ceil(text.length / 4);
90
+ }
91
+
92
+ private formatMessages(messages: ChatMessage[]): string {
93
+ const parts: string[] = [];
94
+
95
+ for (const msg of messages) {
96
+ if (msg.role === 'system') {
97
+ parts.push(`[System Instructions]\n${msg.content}\n`);
98
+ } else if (msg.role === 'user') {
99
+ parts.push(`${msg.content}`);
100
+ } else if (msg.role === 'assistant') {
101
+ parts.push(`[Previous response]\n${msg.content}\n`);
102
+ }
103
+ }
104
+
105
+ return parts.join('\n\n');
106
+ }
107
+
108
+ private runClaude(prompt: string): Promise<string> {
109
+ return new Promise((resolve, reject) => {
110
+ const args = [
111
+ '-p', '-',
112
+ '--output-format', 'json',
113
+ '--model', this.modelId,
114
+ '--no-session-persistence',
115
+ ];
116
+
117
+ const child = spawn('claude', args, {
118
+ stdio: ['pipe', 'pipe', 'pipe'],
119
+ timeout: 180_000,
120
+ });
121
+
122
+ let stdout = '';
123
+ let stderr = '';
124
+
125
+ child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
126
+ child.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
127
+
128
+ child.on('close', (code) => {
129
+ if (code !== 0 && !stdout) {
130
+ // Sanitize stderr — only show first 200 chars, never leak prompt content
131
+ const safeStderr = stderr ? sanitizeError(stderr) : '';
132
+ reject(new Error(`claude CLI exited with code ${code}${safeStderr ? `: ${safeStderr}` : ''}`));
133
+ return;
134
+ }
135
+
136
+ try {
137
+ const result = JSON.parse(stdout) as ClaudeCliResult;
138
+ if (result.is_error) {
139
+ reject(new Error(`claude CLI error: ${sanitizeError(result.result)}`));
140
+ return;
141
+ }
142
+ resolve(result.result);
143
+ } catch {
144
+ resolve(stdout.trim());
145
+ }
146
+ });
147
+
148
+ child.on('error', (err) => {
149
+ reject(new Error(`claude CLI failed to start: ${sanitizeError(err.message)}`));
150
+ });
151
+
152
+ // Handle stdin write errors (e.g., broken pipe if child exits early)
153
+ child.stdin.on('error', (err) => {
154
+ // Ignore EPIPE — child may have already exited, close handler will deal with it
155
+ if ((err as NodeJS.ErrnoException).code !== 'EPIPE') {
156
+ reject(new Error(`Failed to write prompt to claude CLI: ${sanitizeError(err.message)}`));
157
+ }
158
+ });
159
+
160
+ // Write prompt to stdin and close it
161
+ child.stdin.write(prompt);
162
+ child.stdin.end();
163
+ });
164
+ }
165
+ }
166
+
167
+ function sanitizeError(msg: string): string {
168
+ // Never leak prompt content in errors — truncate and strip anything
169
+ // that looks like it could be prompt/code/schema content
170
+ const firstLine = msg.split('\n')[0].trim();
171
+ return firstLine.length > 200 ? firstLine.slice(0, 200) + '...' : firstLine;
172
+ }
173
+
174
+ function extractJson(text: string): string {
175
+ // Try to extract JSON from markdown code blocks
176
+ const codeBlockMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
177
+ if (codeBlockMatch) {
178
+ return codeBlockMatch[1].trim();
179
+ }
180
+
181
+ // Try to find a JSON object directly
182
+ const objectMatch = text.match(/\{[\s\S]*\}/);
183
+ if (objectMatch) {
184
+ return objectMatch[0];
185
+ }
186
+
187
+ return text.trim();
188
+ }
@@ -0,0 +1,95 @@
1
+ import OpenAI from 'openai';
2
+ import { type Tiktoken, encoding_for_model } from 'tiktoken';
3
+ import type { z } from 'zod';
4
+ import { type ChatMessage, type LLMProvider, SchemaValidationError } from './provider.js';
5
+ import { zodToOpenAIResponseFormat } from './schemas.js';
6
+
7
+ const MAX_RETRIES = 3;
8
+
9
+ export class OpenAIProvider implements LLMProvider {
10
+ private client: OpenAI;
11
+ private encoder: Tiktoken | null = null;
12
+ readonly modelId: string;
13
+ readonly providerName = 'openai';
14
+
15
+ constructor(apiKey: string, model?: string) {
16
+ this.client = new OpenAI({ apiKey });
17
+ this.modelId = model ?? 'gpt-4o';
18
+ }
19
+
20
+ async chat(messages: ChatMessage[]): Promise<string> {
21
+ const response = await this.client.chat.completions.create({
22
+ model: this.modelId,
23
+ messages: messages.map((m) => ({ role: m.role, content: m.content })),
24
+ max_tokens: 4096,
25
+ });
26
+
27
+ return response.choices[0]?.message?.content ?? '';
28
+ }
29
+
30
+ async chatStructured<T>(
31
+ messages: ChatMessage[],
32
+ schema: z.ZodType<T>,
33
+ schemaName: string,
34
+ ): Promise<T> {
35
+ const responseFormat = zodToOpenAIResponseFormat(schema, schemaName);
36
+
37
+ let lastError: Error | null = null;
38
+ const conversationMessages = messages.map((m) => ({
39
+ role: m.role,
40
+ content: m.content,
41
+ }));
42
+
43
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
44
+ const response = await this.client.chat.completions.create({
45
+ model: this.modelId,
46
+ messages: conversationMessages,
47
+ max_tokens: 8192,
48
+ response_format: responseFormat,
49
+ });
50
+
51
+ const content = response.choices[0]?.message?.content;
52
+ if (!content) {
53
+ lastError = new Error('Empty response from OpenAI');
54
+ continue;
55
+ }
56
+
57
+ let parsed: unknown;
58
+ try {
59
+ parsed = JSON.parse(content);
60
+ } catch (e) {
61
+ lastError = e instanceof Error ? e : new Error(String(e));
62
+ continue;
63
+ }
64
+
65
+ const result = schema.safeParse(parsed);
66
+ if (result.success) {
67
+ return result.data;
68
+ }
69
+
70
+ lastError = new Error(result.error.message);
71
+
72
+ conversationMessages.push({
73
+ role: 'assistant' as const,
74
+ content,
75
+ });
76
+ conversationMessages.push({
77
+ role: 'user' as const,
78
+ content: `Schema validation error: ${result.error.message}. Please fix and respond again.`,
79
+ });
80
+ }
81
+
82
+ throw new SchemaValidationError(MAX_RETRIES, lastError!);
83
+ }
84
+
85
+ countTokens(text: string): number {
86
+ try {
87
+ if (!this.encoder) {
88
+ this.encoder = encoding_for_model(this.modelId as Parameters<typeof encoding_for_model>[0]);
89
+ }
90
+ return this.encoder.encode(text).length;
91
+ } catch {
92
+ return Math.ceil(text.length / 4);
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,33 @@
1
+ import type { z } from 'zod';
2
+
3
+ export interface ChatMessage {
4
+ role: 'system' | 'user' | 'assistant';
5
+ content: string;
6
+ }
7
+
8
+ export interface LLMProvider {
9
+ readonly modelId: string;
10
+ readonly providerName: string;
11
+
12
+ chat(messages: ChatMessage[]): Promise<string>;
13
+
14
+ chatStructured<T>(
15
+ messages: ChatMessage[],
16
+ schema: z.ZodType<T>,
17
+ schemaName: string,
18
+ ): Promise<T>;
19
+
20
+ countTokens(text: string): number;
21
+ }
22
+
23
+ export class SchemaValidationError extends Error {
24
+ constructor(
25
+ public readonly attempts: number,
26
+ public readonly lastError: Error,
27
+ ) {
28
+ super(
29
+ `Schema validation failed after ${attempts} attempts: ${lastError.message}`,
30
+ );
31
+ this.name = 'SchemaValidationError';
32
+ }
33
+ }