Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
65 KiB
JSON

{"repo": "asamassekou10/ship-safe", "files": {"cli/commands/agent-fix.js": {"66fae244ee5630de52b3610fe3f37bcc4857d90f": {"t": 1786068262.7508235, "n": 36262, "h": "190917185b61ec7e", "c": "/**\n * Ship Safe Security Agent — Interactive Fix Loop\n * ================================================\n *\n * Scans your codebase, then for each affected file:\n * 1. Generates a precise multi-edit fix plan via LLM (one plan per file,\n * addressing every finding in that file at once)\n * 2. Shows you exactly what it will change (unified diff with line numbers)\n * 3. Asks you to accept, skip, or quit\n * 4. Applies the changes atomically\n * 5. Re-scans to verify the findings are resolved\n * 6. Logs every change to .ship-safe/fixes.jsonl\n *\n * USAGE:\n * ship-safe agent [path] Interactive fix loop\n * ship-safe agent . --plan-only Generate plans, never write\n * ship-safe agent . --severity high Only fix high+ severity\n * ship-safe agent . --branch fixes Create a branch, commit per file\n * ship-safe agent . --pr After fixing, push and open a PR\n * ship-safe agent . --provider deepseek-flash\n *\n * SAFETY:\n * - Refuses to operate on a dirty git tree (use --allow-dirty to override)\n * - Always shows a diff before any write\n * - Re-scans after each batch to verify the fix\n * - Plans may create new files (e.g., .env.example) but cannot edit\n * .env, secrets, lockfiles, or build artifacts\n * - Every applied change is logged for audit & undo (`ship-safe undo`)\n */\n\nimport fs from 'fs';\nimport path from 'path';\nimport { createInterface } from 'readline';\nimport { execFileSync } from 'child_process';\nimport chalk from 'chalk';\nimport ora from 'ora';\nimport { autoDetectProvider } from '../providers/llm-provider.js';\nimport { auditCommand } from './audit.js';\nimport * as output from '../utils/output.js';\n\nconst SEV_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };\nconst NEVER_EDIT = [\n /(^|\\/)\\.env(\\.|$)/i,\n /\\.pem$|\\.key$|\\.p12$|\\.pfx$/i,\n /package-lock\\.json$|yarn\\.lock$|pnpm-lock\\.yaml$/i,\n /(^|\\/)node_modules\\//,\n /(^|\\/)dist\\//,\n /(^|\\/)build\\//,\n /\\.min\\.(js|css)$/,\n];\n// Files the agent IS allowed to create or update freely (companions to fixes)\nconst SAFE_NEW_FILES = [\n /(^|\\/)\\.env\\.example$/i,\n /(^|\\/)\\.env\\.sample$/i,\n /(^|\\/)\\.gitignore$/i,\n];\n\nconst FIX_LOG_DIR = '.ship-safe';\nconst FIX_LOG_FILE = 'fixes.jsonl';\n\n// =============================================================================\n// MAIN\n// =============================================================================\n\nexport async function agentFixCommand(targetPath = '.', options = {}) {\n const root = path.resolve(targetPath);\n\n if (!fs.existsSync(root)) {\n output.error(`Path does not exist: ${root}`);\n process.exit(1);\n }\n\n console.log();\n output.header('Ship Safe — Security Agent');\n console.log(chalk.gray(' I will scan, plan each fix, ask before changing anything,'));\n console.log(chalk.gray(' and verify the fix worked. You stay in control.'));\n console.log();\n\n // ── Git safety check ─────────────────────────────────────────────────────\n const initialBranch = getCurrentBranch(root);\n if (!options.allowDirty) {\n const state = checkGitState(root);\n if (state === 'not-a-repo') {\n console.log(chalk.yellow(' Note: this is not a git repository.'));\n console.log(chalk.gray(' Changes cannot be reverted automatically.'));\n const ok = await confirm(' Continue anyway?');\n if (!ok) { console.log(chalk.gray(' Aborted.\\n')); return; }\n } else if (state === 'dirty') {\n output.error('Working tree has uncommitted changes.');\n console.log(chalk.gray(' Commit or stash first, or pass --allow-dirty.'));\n process.exit(1);\n }\n }\n\n // ── Optional branch isolation ────────────────────────────────────────────\n let branchCreated = null;\n if (options.branch) {\n if (!initialBranch) {\n console.log(chalk.yellow(' --branch requires a git repository. Skipping branch creation.'));\n } else {\n const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 16);\n const branchName = options.branch === true\n ? `ship-safe/fixes-${stamp}`\n : String(options.branch);\n try {\n execFileSync('git', ['checkout', '-b', branchName], { cwd: root, stdio: 'pipe' });\n branchCreated = branchName;\n console.log(chalk.gray(` Branch: ${chalk.cyan(branchName)}`));\n } catch (err) {\n output.error(`Could not create branch ${branchName}: ${err.message}`);\n process.exit(1);\n }\n }\n }\n\n // ── Load LLM provider ────────────────────────────────────────────────────\n const provider = autoDetectProvider(root, {\n provider: options.provider,\n model: options.model,\n think: options.think || false,\n });\n if (!provider) {\n output.error('No LLM provider available.');\n console.log(chalk.gray(' Set one of: DEEPSEEK_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, MOONSHOT_API_KEY, KIMI_API_KEY, XAI_API_KEY'));\n process.exit(1);\n }\n console.log(chalk.gray(` Provider: ${chalk.cyan(provider.name)}`));\n\n if (options.sandbox) {\n console.log(chalk.yellow(' --sandbox is not yet implemented — falling back to in-process verification.'));\n console.log(chalk.gray(' Track this in the agent\\'s next milestone.'));\n }\n\n // ── Run the scan ─────────────────────────────────────────────────────────\n const scanSpinner = ora({ text: 'Scanning for issues...', color: 'cyan' }).start();\n let scanResult;\n try {\n scanResult = await auditCommand(root, { _agenticInner: true, deep: false, deps: false, noAi: true });\n } catch (err) {\n scanSpinner.fail('Scan failed');\n output.error(err.message);\n process.exit(1);\n }\n scanSpinner.stop();\n\n // ── Filter findings ──────────────────────────────────────────────────────\n const minSev = options.severity || 'low';\n const minRank = SEV_RANK[minSev] ?? 1;\n\n const findings = (scanResult.findings ?? []).filter(f => {\n if (!f.file) return false;\n if ((SEV_RANK[f.severity] ?? 0) < minRank) return false;\n const rel = f.file.replace(/\\\\/g, '/');\n if (NEVER_EDIT.some(p => p.test(rel))) return false;\n const abs = path.resolve(root, f.file);\n return fs.existsSync(abs);\n });\n\n if (findings.length === 0) {\n output.success('No fixable findings at the requested severity.');\n console.log();\n return;\n }\n\n // ── Group by file ────────────────────────────────────────────────────────\n const byFile = new Map();\n for (const f of findings) {\n const list = byFile.get(f.file) ?? [];\n list.push(f);\n byFile.set(f.file, list);\n }\n\n console.log(chalk.cyan(` Found ${findings.length} fixable finding(s) across ${byFile.size} file(s)`));\n console.log();\n\n // ── Fix loop ─────────────────────────────────────────────────────────────\n const applied = []; // { file, plan, verified }\n const skipped = []; // { file, findings, reason }\n let stopped = false;\n let i = 0;\n\n for (const [filePath, fileFindings] of byFile) {\n i++;\n if (stopped) break;\n\n const idx = `[${i}/${byFile.size}]`;\n console.log();\n console.log(chalk.bold(` ${idx} ${chalk.cyan(filePath)} ${chalk.gray(`— ${fileFindings.length} finding(s)`)}`));\n for (const f of fileFindings) {\n console.log(` ${severityLabel(f.severity)} ${f.title}${f.line ? chalk.gray(` (line ${f.line})`) : ''}`);\n }\n\n // Generate plan\n const planSpinner = ora({ text: 'Generating fix plan...', color: 'cyan', indent: 6 }).start();\n const planResult = await generateBatchPlan(provider, root, filePath, fileFindings);\n planSpinner.stop();\n\n if (!planResult.ok) {\n const detail = describePlanFailure(planResult);\n console.log(chalk.yellow(` ${detail.message}`));\n logFailure(root, { timestamp: new Date().toISOString(), file: filePath, findings: fileFindings.map(f => ({ title: f.title, line: f.line, rule: f.rule })), ...planResult });\n skipped.push({ file: filePath, findings: fileFindings, reason: detail.reason });\n continue;\n }\n const plan = planResult.plan;\n\n // Validate (allows new safe files like .env.example)\n const validation = validatePlan(root, plan);\n if (!validation.ok) {\n console.log(chalk.yellow(` Plan invalid: ${validation.reason}`));\n logFailure(root, { timestamp: new Date().toISOString(), file: filePath, findings: fileFindings.map(f => ({ title: f.title, line: f.line, rule: f.rule })), reason: 'validation-rejected', detail: validation.reason, plan });\n skipped.push({ file: filePath, findings: fileFindings, reason: `plan-invalid: ${validation.reason}` });\n continue;\n }\n\n // Show plan\n printPlan(plan, root);\n\n if (options.planOnly) {\n console.log(chalk.gray(' (plan-only mode — not applying)'));\n continue;\n }\n\n // Decision logic:\n // --yolo → auto-accept everything\n // --auto-low → auto-accept low-risk plans, prompt on medium/high\n // default → prompt every time\n let decision;\n const risk = (plan.risk || 'medium').toLowerCase();\n if (options.yolo) {\n decision = 'a';\n console.log(chalk.gray(' (yolo: auto-accepting)'));\n } else if (options.autoLow && risk === 'low') {\n decision = 'a';\n console.log(chalk.gray(' (auto-low: low-risk, auto-accepting)'));\n } else {\n // Interactive: prompt with [e]dit option\n decision = await promptDecision(plan, root);\n }\n\n if (decision === 'q' || decision === 'quit') {\n console.log(chalk.gray(' Stopping.'));\n // eslint-disable-next-line no-useless-assignment -- read by outer-loop guard at top of next iteration\n stopped = true;\n break;\n }\n if (!['a', 'accept', 'y', 'yes'].includes(decision)) {\n skipped.push({ file: filePath, findings: fileFindings, reason: 'user-skipped' });\n continue;\n }\n\n // Apply\n let applyErr = null;\n const written = [];\n try {\n for (const fileChange of plan.files) {\n applyEdit(root, fileChange);\n written.push(path.resolve(root, fileChange.path));\n }\n } catch (err) {\n applyErr = err.message;\n }\n\n if (applyErr) {\n console.log(chalk.red(` Apply failed: ${applyErr}`));\n skipped.push({ file: filePath, findings: fileFindings, reason: `apply-failed: ${applyErr}` });\n continue;\n }\n\n // Verify by re-scanning\n const verifySpinner = ora({ text: 'Verifying...', color: 'cyan', indent: 6 }).start();\n const verified = await verifyFile(root, filePath, fileFindings);\n if (verified.allResolved) {\n verifySpinner.succeed(chalk.green(`Fix verified — ${fileFindings.length} finding(s) resolved`));\n } else if (verified.someResolved) {\n verifySpinner.warn(chalk.yellow(`Partial: ${verified.resolvedCount}/${fileFindings.length} resolved`));\n } else {\n verifySpinner.warn(chalk.yellow('Fix applied, but findings still appear'));\n }\n\n // Per-fix commit (if branch isolation in use)\n if (branchCreated) {\n try {\n execFileSync('git', ['add', '--', ...written], { cwd: root, stdio: 'pipe' });\n const titles = fileFindings.slice(0, 3).map(f => f.title).join(', ');\n const more = fileFindings.length > 3 ? ` (+${fileFindings.length - 3} more)` : '';\n const msg = `fix(security): ${filePath} — ${titles}${more}`;\n execFileSync('git', ['commit', '-m', msg], { cwd: root, stdio: 'pipe' });\n } catch {\n // commit failed — most likely nothing staged because edits were no-ops\n }\n }\n\n // Log\n logFix(root, {\n timestamp: new Date().toISOString(),\n file: filePath,\n findings: fileFindings.map(f => ({ title: f.title, line: f.line, severity: f.severity, rule: f.rule })),\n plan,\n verified: verified.allResolved,\n branch: branchCreated,\n });\n\n applied.push({ file: filePath, plan, verified });\n }\n\n // ── Final report ─────────────────────────────────────────────────────────\n console.log();\n console.log();\n output.header('Summary');\n console.log();\n console.log(` ${chalk.green('Applied:')} ${applied.length} file(s)`);\n console.log(` ${chalk.gray('Skipped:')} ${skipped.length} file(s)`);\n\n if (applied.length > 0) {\n console.log();\n console.log(chalk.gray(' Applied:'));\n for (const a of applied) {\n const mark = a.verified.allResolved ? chalk.green('✓') : chalk.yellow('?');\n console.log(` ${mark} ${a.file}`);\n }\n console.log();\n console.log(chalk.gray(` Audit log: ${path.join(FIX_LOG_DIR, FIX_LOG_FILE)}`));\n if (branchCreated) {\n console.log(chalk.gray(` Branch: ${chalk.cyan(branchCreated)}`));\n console.log(chalk.gray(` Switch back: git checkout ${initialBranch}`));\n } else {\n console.log(chalk.gray(' Review: git diff'));\n console.log(chalk.gray(' Undo last: ship-safe undo'));\n }\n }\n\n // ── PR autopilot ─────────────────────────────────────────────────────────\n if (options.pr && applied.length > 0 && branchCreated) {\n console.log();\n await openPullRequest(root, branchCreated, applied);\n } else if (options.pr && !branchCreated) {\n console.log();\n console.log(chalk.yellow(' --pr requires --branch. Skipping PR creation.'));\n }\n\n if (skipped.length > 0 && applied.length === 0) {\n console.log();\n console.log(chalk.gray(' Tip: try a different provider with --provider, or run with --plan-only'));\n console.log(chalk.gray(' to inspect what would change.'));\n }\n\n console.log();\n}\n\n// =============================================================================\n// PLAN GENERATION\n// =============================================================================\n\n// Generate a fix plan for a file. Returns either:\n// { ok: true, plan } — plan generated successfully\n// { ok: false, reason, raw, error } — failed; reason is one of:\n// 'file-read-failed' | 'provider-error' | 'parse-error' |\n// 'llm-declined' | 'empty-response'\n// Caller decides what to do with the failure (log, persist, skip).\nasync function generateBatchPlan(provider, root, filePath, fileFindings) {\n const abs = path.resolve(root, filePath);\n let content;\n try {\n content = fs.readFileSync(abs, 'utf8');\n } catch (err) {\n return { ok: false, reason: 'file-read-failed', error: err.message };\n }\n\n const fileForPrompt = windowFileContent(content, fileFindings[0]?.line);\n\n const findingsBlock = fileFindings.map((f, i) => `\n${i + 1}. [${f.severity.toUpperCase()}] ${f.title}${f.line ? ` (line ${f.line})` : ''}\n Rule: ${f.rule ?? 'N/A'}\n Description: ${f.description ?? 'N/A'}${f.fix ? `\\n Suggested fix: ${f.fix}` : ''}\n`).join('');\n\n const systemPrompt = 'You are a security engineer. Produce precise code edits as structured JSON only. Never include prose, markdown, or code fences. Output a single JSON object.';\n\n const userPrompt = `Fix all of these security findings in a single file by producing one coordinated plan.\n\nFILE: ${filePath}\n\nFINDINGS (${fileFindings.length}):\n${findingsBlock}\n\nCURRENT FILE CONTENT:\n\\`\\`\\`\n${fileForPrompt}\n\\`\\`\\`\n\nOUTPUT this exact JSON shape:\n{\n \"summary\": \"one short sentence describing what you'll do across all findings\",\n \"files\": [\n {\n \"path\": \"${filePath}\",\n \"edits\": [\n { \"find\": \"EXACT verbatim substring\", \"replace\": \"new string\", \"reason\": \"addresses finding N\" }\n ]\n }\n ],\n \"risk\": \"low\"\n}\n\nYou MAY also include companion file changes (only these are allowed):\n - .env.example — add placeholders for any secrets you moved to env vars\n - .gitignore — add patterns for files that should not be committed\n\nFor companion files, use this shape (no \"find\" needed):\n { \"path\": \".env.example\", \"create\": true, \"content\": \"FULL FILE CONTENT\" }\nor to append:\n { \"path\": \".gitignore\", \"append\": \"PATTERN_TO_ADD\\\\n\" }\n\nRULES:\n- Each \"find\" string must appear EXACTLY ONCE in the file. Include enough context (3+ lines) for uniqueness.\n- \"replace\" must be the corrected code. Preserve indentation and surrounding style.\n- Address each finding listed above with at least one edit (or explain in summary why a finding can't be mechanically fixed).\n- Risk: \"low\" = mechanical, \"medium\" = behavior change, \"high\" = architectural. Use \"high\" sparingly.\n- If you cannot produce a precise mechanical plan, return {\"summary\":\"requires manual review\",\"files\":[],\"risk\":\"high\"}\n- JSON only. No prose. No code fences.`;\n\n let response;\n try {\n response = await provider.complete(systemPrompt, userPrompt, {\n maxTokens: 3000,\n jsonMode: true,\n });\n } catch (err) {\n return { ok: false, reason: 'provider-error', error: err.message };\n }\n\n if (!response || !response.trim()) {\n return { ok: false, reason: 'empty-response', raw: response ?? '' };\n }\n\n const plan = parseJsonLoose(response);\n if (!plan) {\n return { ok: false, reason: 'parse-error', raw: response };\n }\n if (!Array.isArray(plan.files) || plan.files.length === 0) {\n // The LLM returned valid JSON but explicitly declined to produce edits\n // (typically with summary like \"requires manual review\"). This is a\n // legitimate \"I don't know\" — not a bug.\n return { ok: false, reason: 'llm-declined', raw: response, plan };\n }\n\n return { ok: true, plan };\n}\n\nexport function parseJsonLoose(response) {\n if (!response) return null;\n const cleaned = response.trim()\n .replace(/^```(?:json)?\\s*/i, '')\n .replace(/```\\s*$/i, '')\n .trim();\n try {\n return JSON.parse(cleaned);\n } catch {\n const m = cleaned.match(/\\{[\\s\\S]*\\}/);\n if (m) {\n try { return JSON.parse(m[0]); } catch { return null; }\n }\n return null;\n }\n}\n\nexport function windowFileContent(content, line) {\n if (content.length <= 8000) return content;\n if (!line) return content.slice(0, 8000);\n const lines = content.split('\\n');\n const start = Math.max(0, line - 40);\n const end = Math.min(lines.length, line + 40);\n return lines.slice(start, end).join('\\n');\n}\n\n// =============================================================================\n// PLAN VALIDATION\n// =============================================================================\n\nexport function validatePlan(root, plan) {\n if (!Array.isArray(plan.files) || plan.files.length === 0) {\n return { ok: false, reason: 'no files in plan' };\n }\n\n for (const f of plan.files) {\n if (!f.path) return { ok: false, reason: 'file entry missing path' };\n\n const rel = f.path.replace(/\\\\/g, '/');\n const isSafeNew = SAFE_NEW_FILES.some(p => p.test(rel));\n\n // Block protected paths unless this is a known-safe companion file\n if (!isSafeNew && NEVER_EDIT.some(p => p.test(rel))) {\n return { ok: false, reason: `protected path: ${f.path}` };\n }\n\n const abs = path.resolve(root, f.path);\n const exists = fs.existsSync(abs);\n\n // Companion file forms (create / append)\n if (f.create || f.append !== undefined) {\n if (!exists && !isSafeNew) {\n return { ok: false, reason: `cannot create new file at ${f.path}` };\n }\n if (f.create && typeof f.content !== 'string') {\n return { ok: false, reason: 'create entry missing content' };\n }\n if (f.append && typeof f.append !== 'string') {\n return { ok: false, reason: 'append must be a string' };\n }\n continue;\n }\n\n // Standard edit form\n if (!exists) return { ok: false, reason: `file not found: ${f.path}` };\n if (!Array.isArray(f.edits) || f.edits.length === 0) {\n return { ok: false, reason: `no edits for ${f.path}` };\n }\n\n const content = fs.readFileSync(abs, 'utf8');\n for (const e of f.edits) {\n if (typeof e.find !== 'string' || typeof e.replace !== 'string') {\n return { ok: false, reason: 'edit missing find/replace' };\n }\n if (e.find === e.replace) {\n return { ok: false, reason: 'edit is a no-op' };\n }\n const match = locateFindString(content, e.find);\n if (match.kind === 'missing') {\n return { ok: false, reason: `find string not present in ${f.path}` };\n }\n if (match.kind === 'ambiguous') {\n return { ok: false, reason: `find string is ambiguous (${match.count} matches) in ${f.path}` };\n }\n // Annotate the edit with the resolved match for use during apply\n e._resolvedFind = match.matched;\n }\n }\n return { ok: true };\n}\n\n// Try exact match first, then whitespace-normalized match if exact misses.\n// Returns { kind: 'unique'|'ambiguous'|'missing', matched, count }\nexport function locateFindString(haystack, needle) {\n const exact = countOccurrences(haystack, needle);\n if (exact === 1) return { kind: 'unique', matched: needle, count: 1 };\n if (exact > 1) return { kind: 'ambiguous', matched: needle, count: exact };\n\n // Whitespace-tolerant fallback: collapse whitespace runs and try again\n const norm = (s) => s.replace(/[ \\t]+/g, ' ').replace(/\\s*\\n\\s*/g, '\\n').trim();\n const needleNorm = norm(needle);\n if (!needleNorm) return { kind: 'missing', matched: null, count: 0 };\n\n // Walk the haystack and check if any window normalizes to the same string\n // To keep this cheap, only attempt when needle has at least one newline (likely a code block)\n const lines = haystack.split('\\n');\n const needleLines = needleNorm.split('\\n').length;\n let foundIdx = -1;\n let foundCount = 0;\n for (let i = 0; i + needleLines <= lines.length; i++) {\n const window = lines.slice(i, i + needleLines).join('\\n');\n if (norm(window) === needleNorm) {\n foundIdx = i;\n foundCount++;\n if (foundCount > 1) break;\n }\n }\n if (foundCount === 1) {\n const matched = lines.slice(foundIdx, foundIdx + needleLines).join('\\n');\n return { kind: 'unique', matched, count: 1 };\n }\n if (foundCount > 1) return { kind: 'ambiguous', matched: null, count: foundCount };\n return { kind: 'missing', matched: null, count: 0 };\n}\n\nexport function countOccurrences(haystack, needle) {\n if (!needle) return 0;\n let count = 0, idx = 0;\n while ((idx = haystack.indexOf(needle, idx)) !== -1) { count++; idx += needle.length; }\n return count;\n}\n\n// =============================================================================\n// PRINTING\n// =============================================================================\n\nfunction printPlan(plan, _root) {\n console.log();\n console.log(chalk.bold(' Plan:'));\n console.log(chalk.white(` ${plan.summary || '(no summary)'}`));\n if (plan.risk) {\n const riskColor = plan.risk === 'low' ? chalk.green : plan.risk === 'medium' ? chalk.yellow : chalk.red;\n console.log(` Risk: ${riskColor(plan.risk)}`);\n }\n console.log();\n\n for (const f of plan.files) {\n if (f.create) {\n console.log(chalk.bold(` ${chalk.green('+ ')}${f.path} ${chalk.gray('(new file)')}`));\n printNewFilePreview(f.content);\n continue;\n }\n if (f.append !== undefined) {\n console.log(chalk.bold(` ${f.path} ${chalk.gray('(append)')}`));\n for (const l of f.append.split('\\n')) {\n if (l) console.log(chalk.green(` + ${l}`));\n }\n continue;\n }\n console.log(chalk.bold(` ${f.path}`));\n for (const e of f.edits) {\n console.log(chalk.gray(` — ${e.reason || 'edit'}`));\n printDiff(e._resolvedFind || e.find, e.replace);\n }\n }\n console.log();\n}\n\nfunction printNewFilePreview(content) {\n const lines = content.split('\\n');\n const max = 6;\n const shown = lines.slice(0, max);\n for (const l of shown) console.log(chalk.green(` + ${l}`));\n if (lines.length > max) {\n console.log(chalk.gray(` … +${lines.length - max} more line(s)`));\n }\n}\n\nfunction printDiff(oldStr, newStr) {\n const oldLines = oldStr.split('\\n');\n const newLines = newStr.split('\\n');\n for (const l of oldLines) console.log(chalk.red(` - ${l}`));\n for (const l of newLines) console.log(chalk.green(` + ${l}`));\n}\n\nfunction severityLabel(sev) {\n switch (sev) {\n case 'critical': return chalk.red.bold('[CRITICAL]');\n case 'high': return chalk.red('[HIGH]');\n case 'medium': return chalk.yellow('[MEDIUM]');\n case 'low': return chalk.blue('[LOW]');\n default: return chalk.gray(`[${(sev || 'INFO').toUpperCase()}]`);\n }\n}\n\n// =============================================================================\n// APPLY\n// =============================================================================\n\nfunction applyEdit(root, fileChange) {\n const abs = path.resolve(root, fileChange.path);\n\n if (fileChange.create) {\n fs.mkdirSync(path.dirname(abs), { recursive: true });\n fs.writeFileSync(abs, fileChange.content, 'utf8');\n return;\n }\n\n if (fileChange.append !== undefined) {\n fs.mkdirSync(path.dirname(abs), { recursive: true });\n const existing = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : '';\n // Avoid duplicate appends\n if (existing.includes(fileChange.append.trim())) return;\n const sep = existing && !existing.endsWith('\\n') ? '\\n' : '';\n fs.writeFileSync(abs, existing + sep + fileChange.append, 'utf8');\n return;\n }\n\n let content = fs.readFileSync(abs, 'utf8');\n for (const e of fileChange.edits) {\n const find = e._resolvedFind || e.find;\n if (!content.includes(find)) {\n throw new Error(`find string drifted in ${fileChange.path} (file changed mid-plan)`);\n }\n content = content.replace(find, e.replace);\n }\n fs.writeFileSync(abs, content, 'utf8');\n}\n\n// =============================================================================\n// VERIFY\n// =============================================================================\n\nasync function verifyFile(root, filePath, originalFindings) {\n try {\n const result = await auditCommand(root, { _agenticInner: true, deep: false, deps: false, noAi: true });\n const remaining = (result.findings ?? []).filter(f => f.file === filePath);\n\n let resolvedCount = 0;\n for (const orig of originalFindings) {\n const stillThere = remaining.some(f =>\n f.rule === orig.rule &&\n Math.abs((f.line ?? 0) - (orig.line ?? 0)) <= 2,\n );\n if (!stillThere) resolvedCount++;\n }\n\n return {\n allResolved: resolvedCount === originalFindings.length,\n someResolved: resolvedCount > 0,\n resolvedCount,\n };\n } catch {\n return { allResolved: false, someResolved: false, resolvedCount: 0 };\n }\n}\n\n// =============================================================================\n// LOGGING\n// =============================================================================\n\nfunction logFix(root, entry) {\n const dir = path.join(root, FIX_LOG_DIR);\n const file = path.join(dir, FIX_LOG_FILE);\n fs.mkdirSync(dir, { recursive: true });\n fs.appendFileSync(file, JSON.stringify(entry) + '\\n', 'utf8');\n}\n\n// Append a structured record for every plan that didn't produce an apply.\n// Useful for debugging regressions (the LLM said X, we rejected because Y).\nfunction logFailure(root, entry) {\n const dir = path.join(root, FIX_LOG_DIR);\n const file = path.join(dir, 'failures.jsonl');\n fs.mkdirSync(dir, { recursive: true });\n // Truncate raw response to keep the log readable\n const truncated = { ...entry };\n if (typeof truncated.raw === 'string' && truncated.raw.length > 4000) {\n truncated.raw = truncated.raw.slice(0, 4000) + '... [truncated]';\n }\n fs.appendFileSync(file, JSON.stringify(truncated) + '\\n', 'utf8');\n}\n\n// Turn a structured plan failure into a user-facing one-liner.\nfunction describePlanFailure(failure) {\n switch (failure.reason) {\n case 'file-read-failed':\n return { message: `Could not read source file: ${failure.error}`, reason: 'file-read-failed' };\n case 'provider-error':\n return { message: `LLM provider error — ${failure.error}`, reason: 'provider-error' };\n case 'empty-response':\n return { message: 'LLM returned an empty response (try again, or switch provider).', reason: 'empty-response' };\n case 'parse-error':\n return { message: 'LLM returned unparseable JSON — saved to .ship-safe/failures.jsonl for inspection.', reason: 'parse-error' };\n case 'llm-declined': {\n const summary = failure.plan?.summary;\n return { message: summary\n ? `LLM declined to fix this file — ${summary}`\n : 'LLM declined to fix this file (returned files=[] — needs manual review).',\n reason: 'llm-declined',\n };\n }\n default:\n return { message: `Plan failed: ${failure.reason}`, reason: failure.reason };\n }\n}\n\n// =============================================================================\n// PR AUTOPILOT\n// =============================================================================\n\nasync function openPullRequest(root, branch, applied) {\n const ghAvailable = (() => {\n try { execFileSync('gh', ['--version'], { stdio: 'pipe' }); return true; }\n catch { return false; }\n })();\n if (!ghAvailable) {\n console.log(chalk.yellow(' gh CLI not found. Install from https://cli.github.com to enable --pr.'));\n return;\n }\n\n // Push branch\n console.log(chalk.gray(' Pushing branch...'));\n try {\n execFileSync('git', ['push', '-u', 'origin', branch], { cwd: root, stdio: 'pipe' });\n } catch (err) {\n console.log(chalk.red(` Push failed: ${err.message}`));\n return;\n }\n\n // Build PR body\n const totalFindings = applied.reduce((n, a) => n + (a.plan.files?.[0]?.edits?.length ?? 0), 0);\n const body = [\n '## Ship Safe — Security Fixes',\n '',\n `Applied ${applied.length} file(s) of fixes (${totalFindings} edit(s)) generated and verified by the Ship Safe agent.`,\n '',\n '### Files changed',\n ...applied.map(a => {\n const mark = a.verified.allResolved ? '✓' : '⚠';\n return `- ${mark} \\`${a.file}\\` — ${a.plan.summary || 'security fix'}`;\n }),\n '',\n '### Notes',\n '- Each fix was generated by an LLM and verified by re-scanning the file.',\n '- Files marked ⚠ have residual findings; review carefully before merging.',\n '- Full audit log: `.ship-safe/fixes.jsonl`',\n '',\n 'Generated by `ship-safe agent`.',\n ].join('\\n');\n\n const title = `Security fixes: ${applied.length} file(s)`;\n\n console.log(chalk.gray(' Opening PR...'));\n let prUrl;\n try {\n prUrl = execFileSync('gh', ['pr', 'create', '--title', title, '--body', body], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();\n console.log(chalk.green(` PR opened: ${prUrl}`));\n } catch (err) {\n console.log(chalk.red(` PR creation failed: ${err.message}`));\n return;\n }\n\n // If we're running inside CI on a PR, leave a comment on the originating PR\n // pointing at our fix PR. Detect from common GitHub Actions env vars.\n const originPr = detectOriginPrNumber();\n if (originPr) {\n const note = [\n `### 🛡️ Ship Safe Agent — fix PR opened`,\n ``,\n `The Ship Safe agent found fixable security issues triggered by this PR and opened **${prUrl}** with proposed fixes.`,\n ``,\n `**Files changed:** ${applied.length}`,\n `**Total edits:** ${totalFindings}`,\n ``,\n `Review the fix PR and merge if it looks good.`,\n ].join('\\n');\n try {\n execFileSync('gh', ['pr', 'comment', String(originPr), '--body', note], { cwd: root, stdio: 'pipe' });\n console.log(chalk.green(` Commented on origin PR #${originPr}`));\n } catch (err) {\n console.log(chalk.yellow(` Could not comment on origin PR #${originPr}: ${err.message}`));\n }\n }\n}\n\n// Detect the PR number that triggered this CI run. Supports GitHub Actions'\n// pull_request and pull_request_target events. Returns null when not in CI\n// or when the event isn't a PR event.\nfunction detectOriginPrNumber() {\n // Explicit override (handy for testing or non-GHA CI providers)\n if (process.env.SHIP_SAFE_ORIGIN_PR) return process.env.SHIP_SAFE_ORIGIN_PR;\n\n // GitHub Actions: GITHUB_REF looks like \"refs/pull/<n>/merge\" or \"refs/pull/<n>/head\"\n const ref = process.env.GITHUB_REF || '';\n const m = ref.match(/^refs\\/pull\\/(\\d+)\\//);\n if (m) return m[1];\n\n // GitHub Actions PR event payload also exposes the number via GITHUB_EVENT_PATH\n const eventPath = process.env.GITHUB_EVENT_PATH;\n if (eventPath && fs.existsSync(eventPath)) {\n try {\n const payload = JSON.parse(fs.readFileSync(eventPath, 'utf8'));\n if (payload?.pull_request?.number) return String(payload.pull_request.number);\n if (payload?.number) return String(payload.number);\n } catch { /* malformed event payload */ }\n }\n\n return null;\n}\n\n// =============================================================================\n// GIT\n// =============================================================================\n\nfunction checkGitState(root) {\n try {\n execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: root, stdio: 'pipe' });\n } catch {\n return 'not-a-repo';\n }\n try {\n const out = execFileSync('git', ['status', '--porcelain'], { cwd: root, stdio: 'pipe' }).toString();\n const meaningful = out.split('\\n').filter(line => {\n const path = line.slice(3).trim();\n if (!path) return false;\n if (path.startsWith('.ship-safe/')) return false;\n if (path === 'ship-safe-report.html') return false;\n return true;\n });\n return meaningful.length === 0 ? 'clean' : 'dirty';\n } catch {\n return 'clean';\n }\n}\n\nfunction getCurrentBranch(root) {\n try {\n return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, stdio: 'pipe' }).toString().trim();\n } catch {\n return null;\n }\n}\n\n// =============================================================================\n// PROMPTS\n// =============================================================================\n\nfunction prompt(question) {\n return new Promise(resolve => {\n const rl = createInterface({ input: process.stdin, output: process.stdout });\n rl.question(question, answer => { rl.close(); resolve(answer); });\n });\n}\n\nasync function confirm(question) {\n const a = (await prompt(`${question} [y/N] `)).trim().toLowerCase();\n return a === 'y' || a === 'yes';\n}\n\n// Plan decision prompt with [e]dit support: opens the plan in $EDITOR for the\n// user to tweak, then re-validates. Loops until accept/skip/quit.\nasync function promptDecision(plan, root) {\n while (true) {\n const raw = (await prompt(chalk.cyan(' [a]ccept [s]kip [e]dit [q]uit > '))).trim().toLowerCase();\n if (['a', 'accept', 'y', 'yes'].includes(raw)) return 'a';\n if (['s', 'skip', 'n', 'no'].includes(raw)) return 's';\n if (['q', 'quit'].includes(raw)) return 'q';\n if (['e', 'edit'].includes(raw)) {\n const edited = await editPlanInEditor(plan, root);\n if (!edited) {\n console.log(chalk.yellow(' Edit cancelled — keeping original plan.'));\n } else {\n // Mutate plan in place so the caller's reference picks up the changes\n plan.summary = edited.summary;\n plan.files = edited.files;\n plan.risk = edited.risk;\n // Re-validate then re-show\n const validation = validatePlan(root, plan);\n if (!validation.ok) {\n console.log(chalk.red(` Edited plan invalid: ${validation.reason}`));\n console.log(chalk.gray(' Returning to prompt — try editing again, or skip.'));\n continue;\n }\n printPlan(plan, root);\n }\n // Loop back and re-prompt\n continue;\n }\n console.log(chalk.gray(' Unknown choice. Type a, s, e, or q.'));\n }\n}\n\nasync function editPlanInEditor(plan, root) {\n const editor = process.env.EDITOR || process.env.VISUAL || 'vi';\n const tmpFile = path.join(root, '.ship-safe', `plan-edit-${Date.now()}.json`);\n fs.mkdirSync(path.dirname(tmpFile), { recursive: true });\n // Strip _resolvedFind annotations before showing — they're internal\n const exportable = JSON.parse(JSON.stringify(plan, (k, v) => k === '_resolvedFind' ? undefined : v));\n fs.writeFileSync(tmpFile, JSON.stringify(exportable, null, 2), 'utf8');\n\n try {\n execFileSync(editor, [tmpFile], { stdio: 'inherit' });\n const updated = JSON.parse(fs.readFileSync(tmpFile, 'utf8'));\n fs.unlinkSync(tmpFile);\n return updated;\n } catch (err) {\n try { fs.unlinkSync(tmpFile); } catch { /* best effort */ }\n console.log(chalk.red(` Editor failed: ${err.message}`));\n return null;\n }\n}\n"}}, "cli/providers/llm-provider.js": {"66fae244ee5630de52b3610fe3f37bcc4857d90f": {"t": 1786068263.838904, "n": 27347, "h": "842bf5a2806203a7", "c": "/**\n * Multi-LLM Provider\n * ===================\n *\n * Abstraction layer for LLM providers.\n * Supports: Anthropic (Claude), OpenAI, Google (Gemini), Ollama (local),\n * and any OpenAI-compatible endpoint (Groq, Together AI, Mistral API,\n * LM Studio, Azure OpenAI, AWS Bedrock via proxy, etc.).\n *\n * USAGE:\n * const provider = createProvider('anthropic', apiKey);\n * const provider = createProvider('groq', apiKey);\n * const provider = createProvider('openai', apiKey, { baseUrl: 'https://custom/v1/chat/completions' });\n * const result = await provider.classify(findings, context);\n */\n\nimport fs from 'fs';\nimport path from 'path';\nimport { redactForLLM } from '../utils/llm-redaction.js';\n\n// =============================================================================\n// PROVIDER INTERFACE\n// =============================================================================\n\nclass BaseLLMProvider {\n constructor(name, apiKey, options = {}) {\n this.name = name;\n this.apiKey = apiKey;\n this.model = options.model || null;\n this.baseUrl = options.baseUrl || null;\n this.think = options.think || false;\n this.thinkLevel = options.thinkLevel || 'high';\n }\n\n /**\n * Send a prompt to the LLM and get a text response.\n */\n async complete(systemPrompt, userPrompt, options = {}) {\n throw new Error(`${this.name}.complete() not implemented`);\n }\n\n /**\n * Stream a completion as an async iterable of text chunks.\n * Default implementation falls back to complete() and yields the whole\n * response at once — providers that support real streaming should override.\n */\n async *stream(systemPrompt, userPrompt, options = {}) {\n const text = await this.complete(systemPrompt, userPrompt, options);\n if (text) yield text;\n }\n\n /**\n * Classify security findings using the LLM.\n */\n async classify(findings, context) {\n const prompt = this.buildClassificationPrompt(findings, context);\n const response = await this.complete(\n 'You are a security expert. Respond with JSON only, no markdown.',\n prompt,\n { maxTokens: 4096, think: this.think, thinkLevel: this.thinkLevel }\n );\n return this.parseJSON(response);\n }\n\n buildClassificationPrompt(findings, context) {\n const items = findings.map(f => ({\n id: `${f.file}:${f.line}`,\n rule: f.rule,\n severity: f.severity,\n title: f.title,\n matched: redactForLLM(f.matched || '').slice(0, 100),\n description: redactForLLM(f.description || ''),\n }));\n\n return `Classify each finding as REAL or FALSE_POSITIVE. For REAL findings, provide a specific fix.\n\nRespond with JSON array ONLY:\n[{\"id\":\"<id>\",\"classification\":\"REAL\"|\"FALSE_POSITIVE\",\"reason\":\"<brief reason>\",\"fix\":\"<specific fix or null>\"}]\n\nFindings:\n${JSON.stringify(items, null, 2)}`;\n }\n\n parseJSON(text) {\n const cleaned = text\n .replace(/^```(?:json)?\\s*/i, '')\n .replace(/\\s*```\\s*$/i, '')\n .trim();\n try {\n return JSON.parse(cleaned);\n } catch {\n return [];\n }\n }\n}\n\n// =============================================================================\n// ANTHROPIC PROVIDER (Claude)\n// =============================================================================\n\nclass AnthropicProvider extends BaseLLMProvider {\n constructor(apiKey, options = {}) {\n super('Anthropic', apiKey, options);\n this.model = options.model || 'claude-haiku-4-5-20251001';\n this.baseUrl = options.baseUrl || 'https://api.anthropic.com/v1/messages';\n }\n\n /** Whether this provider supports guaranteed-JSON tool-use output */\n get supportsStructuredOutput() { return true; }\n\n async complete(systemPrompt, userPrompt, options = {}) {\n const response = await fetch(this.baseUrl, {\n method: 'POST',\n headers: {\n 'x-api-key': this.apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json',\n },\n body: JSON.stringify({\n model: options.model || this.model,\n max_tokens: options.maxTokens || 2048,\n system: systemPrompt,\n messages: [{ role: 'user', content: userPrompt }],\n }),\n });\n\n if (!response.ok) {\n const body = await response.text().catch(() => '');\n throw new Error(`Anthropic API error: HTTP ${response.status} ${body.slice(0, 200)}`);\n }\n\n const data = await response.json();\n return data.content?.[0]?.text || '';\n }\n\n /**\n * Complete with guaranteed-JSON output via Anthropic tool-use API.\n * The LLM is forced to call the named tool, so the response always matches\n * the provided JSON Schema — no regex cleanup needed.\n *\n * @param {string} systemPrompt\n * @param {string} userPrompt\n * @param {string} toolName — Name of the forced tool call\n * @param {object} inputSchema — JSON Schema for the tool's input\n * @param {object} options — { maxTokens, model }\n * @returns {Promise<object|null>} — Parsed tool input object, or null on failure\n */\n async completeWithTools(systemPrompt, userPrompt, toolName, inputSchema, options = {}) {\n const response = await fetch(this.baseUrl, {\n method: 'POST',\n headers: {\n 'x-api-key': this.apiKey,\n 'anthropic-version': '2023-06-01',\n 'content-type': 'application/json',\n },\n body: JSON.stringify({\n model: options.model || this.model,\n max_tokens: options.maxTokens || 2048,\n system: systemPrompt,\n messages: [{ role: 'user', content: userPrompt }],\n tools: [{\n name: toolName,\n description: `Report ${toolName} results`,\n input_schema: inputSchema,\n }],\n tool_choice: { type: 'tool', name: toolName },\n }),\n });\n\n if (!response.ok) {\n const body = await response.text().catch(() => '');\n throw new Error(`Anthropic API error: HTTP ${response.status} ${body.slice(0, 200)}`);\n }\n\n const data = await response.json();\n const toolUse = data.content?.find(b => b.type === 'tool_use');\n return toolUse?.input ?? null;\n }\n}\n\n// =============================================================================\n// OPENAI PROVIDER (GPT-4o, etc.)\n// =============================================================================\n\nclass OpenAIProvider extends BaseLLMProvider {\n constructor(apiKey, options = {}) {\n super('OpenAI', apiKey, options);\n this.model = options.model || 'gpt-5.5';\n this.baseUrl = options.baseUrl || 'https://api.openai.com/v1/chat/completions';\n }\n\n async complete(systemPrompt, userPrompt, options = {}) {\n const body = {\n model: this.model,\n max_tokens: options.maxTokens || 2048,\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: userPrompt },\n ],\n };\n\n if (options.think) {\n body.reasoning_effort = options.thinkLevel || 'high';\n body.max_tokens = options.maxTokens || 16384;\n }\n\n const response = await fetch(this.baseUrl, {\n method: 'POST',\n headers: {\n 'Authorization': `Bearer ${this.apiKey}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(body),\n });\n\n if (!response.ok) {\n throw new Error(`OpenAI API error: HTTP ${response.status}`);\n }\n\n const data = await response.json();\n return data.choices?.[0]?.message?.content || '';\n }\n\n /**\n * Streaming variant for the OpenAI Chat Completions SSE protocol.\n * Yields content tokens as they arrive. Inherited by every\n * OpenAI-compatible provider (DeepSeek, Kimi, xAI, OpenRouter).\n */\n async *stream(systemPrompt, userPrompt, options = {}) {\n const body = {\n model: this.model,\n max_tokens: options.maxTokens || 2048,\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: userPrompt },\n ],\n stream: true,\n };\n if (options.think) {\n body.reasoning_effort = options.thinkLevel || 'high';\n body.max_tokens = options.maxTokens || 16384;\n }\n\n const response = await fetch(this.baseUrl, {\n method: 'POST',\n headers: {\n 'Authorization': `Bearer ${this.apiKey}`,\n 'Content-Type': 'application/json',\n 'Accept': 'text/event-stream',\n },\n body: JSON.stringify(body),\n });\n\n if (!response.ok) {\n const errBody = await response.text().catch(() => '');\n throw new Error(`${this.name} API error: HTTP ${response.status} ${errBody.slice(0, 200)}`);\n }\n\n const reader = response.body.getReader();\n const decoder = new TextDecoder('utf-8');\n let buffer = '';\n\n while (true) {\n const { value, done } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n\n // SSE events are separated by blank lines; parse line-by-line and only\n // act on `data: ` payloads. Everything else (event:, id:, comments) ignored.\n const lines = buffer.split('\\n');\n buffer = lines.pop() ?? ''; // keep trailing partial line for next chunk\n\n for (const raw of lines) {\n const line = raw.trim();\n if (!line.startsWith('data:')) continue;\n const payload = line.slice(5).trim();\n if (!payload || payload === '[DONE]') continue;\n try {\n const evt = JSON.parse(payload);\n const delta = evt.choices?.[0]?.delta;\n // Token text (delta.content) — yield as-is. Some providers also send\n // tool_calls; ignored here since the REPL doesn't use tools yet.\n if (delta?.content) yield delta.content;\n } catch { /* malformed chunk — skip */ }\n }\n }\n }\n}\n\n// =============================================================================\n// GOOGLE PROVIDER (Gemini)\n// =============================================================================\n\nclass GoogleProvider extends BaseLLMProvider {\n constructor(apiKey, options = {}) {\n super('Google', apiKey, options);\n this.model = options.model || 'gemini-2.0-flash';\n }\n\n async complete(systemPrompt, userPrompt, options = {}) {\n const url = `https://generativelanguage.googleapis.com/v1beta/models/${this.model}:generateContent`;\n\n const response = await fetch(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'x-goog-api-key': this.apiKey,\n },\n body: JSON.stringify({\n systemInstruction: { parts: [{ text: systemPrompt }] },\n contents: [{ parts: [{ text: userPrompt }] }],\n generationConfig: { maxOutputTokens: options.maxTokens || 2048 },\n }),\n });\n\n if (!response.ok) {\n throw new Error(`Google API error: HTTP ${response.status}`);\n }\n\n const data = await response.json();\n return data.candidates?.[0]?.content?.parts?.[0]?.text || '';\n }\n}\n\n// =============================================================================\n// OLLAMA PROVIDER (Local models)\n// =============================================================================\n\nclass OllamaProvider extends BaseLLMProvider {\n constructor(apiKey, options = {}) {\n super('Ollama', null, options);\n this.model = options.model || 'gemma4:e4b';\n this.baseUrl = options.baseUrl || 'http://localhost:11434/api/chat';\n }\n\n async complete(systemPrompt, userPrompt, options = {}) {\n const response = await fetch(this.baseUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n model: this.model,\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: userPrompt },\n ],\n stream: false,\n }),\n });\n\n if (!response.ok) {\n throw new Error(`Ollama error: HTTP ${response.status}`);\n }\n\n const data = await response.json();\n return data.message?.content || '';\n }\n}\n\n// =============================================================================\n// GEMMA 4 PROVIDER\n// Uses Ollama's structured output (format: schema) for guaranteed JSON —\n// no regex parsing, no silent dropped findings.\n// =============================================================================\n\nconst CLASSIFY_SCHEMA = {\n type: 'object',\n properties: {\n results: {\n type: 'array',\n items: {\n type: 'object',\n properties: {\n id: { type: 'string' },\n classification: { type: 'string', enum: ['REAL', 'FALSE_POSITIVE'] },\n reason: { type: 'string' },\n fix: { type: ['string', 'null'] },\n },\n required: ['id', 'classification', 'reason', 'fix'],\n },\n },\n },\n required: ['results'],\n};\n\nclass GemmaProvider extends OllamaProvider {\n constructor(options = {}) {\n super(null, {\n model: options.model || 'gemma4:e4b',\n baseUrl: options.baseUrl || 'http://localhost:11434/api/chat',\n });\n this.name = 'Gemma4';\n // 256K tokens for 27b/31b, 128K for e4b — set conservatively high\n this.contextWindow = options.model?.includes('27b') ? 131072 : 65536;\n }\n\n /**\n * Classify using Ollama structured output (format: schema).\n * Gemma 4 has trained-in function calling — the schema is enforced at the\n * token level, so the response is always valid JSON matching CLASSIFY_SCHEMA.\n */\n async classify(findings, context) {\n const prompt = this.buildClassificationPrompt(findings, context);\n\n const response = await fetch(this.baseUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n model: this.model,\n format: CLASSIFY_SCHEMA,\n stream: false,\n options: { num_ctx: this.contextWindow },\n messages: [\n { role: 'system', content: 'You are a security expert. Classify each finding as REAL or FALSE_POSITIVE and suggest a fix.' },\n { role: 'user', content: prompt },\n ],\n }),\n });\n\n if (!response.ok) {\n throw new Error(`Gemma4/Ollama error: HTTP ${response.status}`);\n }\n\n const data = await response.json();\n const text = data.message?.content || '';\n\n try {\n const parsed = JSON.parse(text);\n return parsed.results ?? [];\n } catch {\n // Fallback: schema enforcement failed (old Ollama version) — try regex parse\n return this.parseJSON(text);\n }\n }\n}\n\n// =============================================================================\n// OPENAI-COMPATIBLE PROVIDER\n// Handles Groq, Together AI, Mistral API, LM Studio, Azure OpenAI, Bedrock\n// proxy, and any other endpoint that speaks /v1/chat/completions.\n// =============================================================================\n\n// Well-known OpenAI-compatible base URLs and their default models.\nconst OPENAI_COMPATIBLE_PRESETS = {\n 'gpt-5.5': { baseUrl: 'https://api.openai.com/v1/chat/completions', model: 'gpt-5.5', envKey: 'OPENAI_API_KEY' },\n 'gpt-5.5-pro': { baseUrl: 'https://api.openai.com/v1/chat/completions', model: 'gpt-5.5-pro', envKey: 'OPENAI_API_KEY' },\n 'gpt-5.4': { baseUrl: 'https://api.openai.com/v1/chat/completions', model: 'gpt-5.4', envKey: 'OPENAI_API_KEY' },\n 'gpt-5.4-mini': { baseUrl: 'https://api.openai.com/v1/chat/completions', model: 'gpt-5.4-mini', envKey: 'OPENAI_API_KEY' },\n 'gpt-5.4-nano': { baseUrl: 'https://api.openai.com/v1/chat/completions', model: 'gpt-5.4-nano', envKey: 'OPENAI_API_KEY' },\n groq: { baseUrl: 'https://api.groq.com/openai/v1/chat/completions', model: 'llama-3.3-70b-versatile', envKey: 'GROQ_API_KEY' },\n together: { baseUrl: 'https://api.together.xyz/v1/chat/completions', model: 'meta-llama/Llama-3-70b-chat-hf', envKey: 'TOGETHER_API_KEY' },\n mistral: { baseUrl: 'https://api.mistral.ai/v1/chat/completions', model: 'mistral-large-latest', envKey: 'MISTRAL_API_KEY' },\n cohere: { baseUrl: 'https://api.cohere.com/compatibility/v1/chat/completions', model: 'command-r-plus', envKey: 'COHERE_API_KEY' },\n deepseek: { baseUrl: 'https://api.deepseek.com/v1/chat/completions', model: 'deepseek-v4-pro', envKey: 'DEEPSEEK_API_KEY' },\n 'deepseek-flash': { baseUrl: 'https://api.deepseek.com/v1/chat/completions', model: 'deepseek-v4-flash', envKey: 'DEEPSEEK_API_KEY' },\n perplexity: { baseUrl: 'https://api.perplexity.ai/chat/completions', model: 'llama-3.1-sonar-large-128k-online', envKey: 'PERPLEXITY_API_KEY' },\n lmstudio: { baseUrl: 'http://localhost:1234/v1/chat/completions', model: null, envKey: null },\n xai: { baseUrl: 'https://api.x.ai/v1/chat/completions', model: 'grok-3-mini', envKey: 'XAI_API_KEY' },\n kimi: { baseUrl: 'https://api.moonshot.ai/v1/chat/completions', model: 'kimi-k3', envKey: 'MOONSHOT_API_KEY' },\n moonshot: { baseUrl: 'https://api.moonshot.ai/v1/chat/completions', model: 'kimi-k3', envKey: 'MOONSHOT_API_KEY' },\n 'kimi-k3': { baseUrl: 'https://api.moonshot.ai/v1/chat/completions', model: 'kimi-k3', envKey: 'MOONSHOT_API_KEY' },\n 'kimi-k2.7-code': { baseUrl: 'https://api.moonshot.ai/v1/chat/completions', model: 'kimi-k2.7-code-preview', envKey: 'MOONSHOT_API_KEY' },\n 'kimi-k2.6': { baseUrl: 'https://api.moonshot.ai/v1/chat/completions', model: 'kimi-k2.6', envKey: 'MOONSHOT_API_KEY' },\n // Gemma 4 via Ollama — runs fully local, no API key required\n // e4b: MoE 4B active params, ~8GB RAM; 27b: dense, ~20GB RAM\n gemma4: { baseUrl: 'http://localhost:11434/v1/chat/completions', model: 'gemma4:e4b', envKey: null },\n 'gemma4:27b': { baseUrl: 'http://localhost:11434/v1/chat/completions', model: 'gemma4:27b', envKey: null },\n};\n\nclass OpenAICompatibleProvider extends OpenAIProvider {\n constructor(name, apiKey, options = {}) {\n super(apiKey, options);\n this.name = name;\n }\n\n get isKimi() {\n return /kimi|moonshot/i.test(this.name || '') || /kimi|moonshot/i.test(this.model || '');\n }\n\n get isKimiK3() {\n return /kimi-k3/i.test(this.model || '');\n }\n\n /** Models known to support OpenAI function calling reliably */\n get supportsStructuredOutput() {\n return /kimi|moonshot|gpt-4|gpt-5|grok|deepseek|mistral-large/i.test(this.model || '');\n }\n\n _applyReasoningOptions(body, options = {}) {\n if (!options.think) return;\n body.max_tokens = options.maxTokens || 16384;\n body.reasoning_effort = this.isKimiK3 ? 'max' : (options.thinkLevel || 'high');\n }\n\n async complete(systemPrompt, userPrompt, options = {}) {\n const body = {\n model: options.model || this.model,\n max_tokens: options.maxTokens || 2048,\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: userPrompt },\n ],\n };\n if (options.jsonMode) body.response_format = { type: 'json_object' };\n if (options.jsonSchema) {\n body.response_format = {\n type: 'json_schema',\n json_schema: options.jsonSchema,\n };\n }\n this._applyReasoningOptions(body, options);\n\n const response = await fetch(this.baseUrl, {\n method: 'POST',\n headers: {\n 'Authorization': `Bearer ${this.apiKey}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(body),\n });\n\n if (!response.ok) {\n const errBody = await response.text().catch(() => '');\n throw new Error(`${this.name} API error: HTTP ${response.status} ${errBody.slice(0, 200)}`);\n }\n\n const data = await response.json();\n const msg = data.choices?.[0]?.message;\n // Kimi K3/K2 reasoning lives in `reasoning_content`; never parse it as final\n // output, especially for JSON-mode security analysis.\n if (this.isKimi) return msg?.content || '';\n if (options.jsonMode || options.jsonSchema) return msg?.content || '';\n return msg?.content || msg?.reasoning_content || '';\n }\n\n /**\n * Complete with structured output via OpenAI tool-use format.\n * Used by DeepAnalyzer multi-tier pipeline on non-Anthropic providers.\n */\n async completeWithTools(systemPrompt, userPrompt, toolName, inputSchema, options = {}) {\n const response = await fetch(this.baseUrl, {\n method: 'POST',\n headers: {\n 'Authorization': `Bearer ${this.apiKey}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({\n model: options.model || this.model,\n max_tokens: options.maxTokens || 2048,\n messages: [\n { role: 'system', content: systemPrompt },\n { role: 'user', content: userPrompt },\n ],\n tools: [{\n type: 'function',\n function: {\n name: toolName,\n description: `Report ${toolName} results`,\n parameters: inputSchema,\n },\n }],\n tool_choice: 'required',\n }),\n });\n\n if (!response.ok) {\n const body = await response.text().catch(() => '');\n throw new Error(`${this.name} API error: HTTP ${response.status} ${body.slice(0, 200)}`);\n }\n\n const data = await response.json();\n const toolCall = data.choices?.[0]?.message?.tool_calls?.[0];\n if (!toolCall) return null;\n\n try {\n return JSON.parse(toolCall.function.arguments);\n } catch {\n return null;\n }\n }\n}\n\n// =============================================================================\n// FACTORY\n// =============================================================================\n\n/**\n * Create an LLM provider instance.\n *\n * @param {string} provider — 'anthropic' | 'openai' | 'google' | 'ollama'\n * or any preset: 'groq' | 'together' | 'mistral' |\n * 'cohere' | 'deepseek' | 'perplexity' | 'lmstudio' | 'xai'\n * @param {string} apiKey — API key (null for Ollama/LM Studio)\n * @param {object} options — { model, baseUrl }\n * baseUrl overrides the default for any provider.\n */\nexport function createProvider(provider, apiKey, options = {}) {\n const name = provider.toLowerCase();\n\n // First-class providers\n switch (name) {\n case 'anthropic':\n case 'claude':\n return new AnthropicProvider(apiKey, options);\n case 'openai':\n case 'gpt':\n return new OpenAIProvider(apiKey, options);\n case 'google':\n case 'gemini':\n return new GoogleProvider(apiKey, options);\n case 'ollama':\n case 'local':\n return new OllamaProvider(apiKey, options);\n case 'gemma4':\n case 'gemma':\n // Gemma 4 via Ollama — structured output, no API key needed\n return new GemmaProvider({\n model: options.model,\n baseUrl: options.baseUrl,\n });\n }\n\n // OpenAI-compatible presets\n if (OPENAI_COMPATIBLE_PRESETS[name]) {\n const preset = OPENAI_COMPATIBLE_PRESETS[name];\n return new OpenAICompatibleProvider(\n // Capitalise for display: \"groq\" → \"Groq\"\n name.charAt(0).toUpperCase() + name.slice(1),\n apiKey,\n {\n baseUrl: options.baseUrl || preset.baseUrl,\n model: options.model || preset.model || 'default',\n think: options.think,\n thinkLevel: options.thinkLevel,\n }\n );\n }\n\n // Unknown name but caller supplied a baseUrl — treat as generic OpenAI-compatible\n if (options.baseUrl) {\n return new OpenAICompatibleProvider(provider, apiKey, options);\n }\n\n throw new Error(\n `Unknown LLM provider: \"${provider}\".\\n` +\n `Built-in: anthropic, openai, google, ollama\\n` +\n `Presets: gpt-5.5, gpt-5.5-pro, gpt-5.4, gpt-5.4-mini, gpt-5.4-nano, groq, together, mistral, cohere, deepseek, deepseek-flash, perplexity, lmstudio, xai, kimi, kimi-k3\\n` +\n `Custom: pass any name with --base-url <url>`\n );\n}\n\n/**\n * Auto-detect the best available LLM provider from environment variables.\n *\n * @param {string} rootPath — Project root (for .env file scan)\n * @param {object} options — { provider, baseUrl, model } explicit overrides\n */\nexport function autoDetectProvider(rootPath, options = {}) {\n // Explicit provider name requested\n if (options.provider) {\n const apiKey = resolveApiKey(options.provider, rootPath);\n return createProvider(options.provider, apiKey, {\n model: options.model,\n baseUrl: options.baseUrl,\n think: options.think,\n thinkLevel: options.thinkLevel,\n });\n }\n\n // baseUrl supplied without a provider name → openai-compatible with auto key\n if (options.baseUrl) {\n const apiKey = process.env.OPENAI_API_KEY || resolveApiKey('openai', rootPath) || '';\n return new OpenAICompatibleProvider('custom', apiKey, {\n baseUrl: options.baseUrl,\n model: options.model || 'default',\n });\n }\n\n // Standard env-var auto-detection (first match wins)\n const envKeys = {\n ANTHROPIC_API_KEY: 'anthropic',\n OPENAI_API_KEY: 'openai',\n GOOGLE_API_KEY: 'google',\n GEMINI_API_KEY: 'google',\n GROQ_API_KEY: 'groq',\n TOGETHER_API_KEY: 'together',\n MISTRAL_API_KEY: 'mistral',\n DEEPSEEK_API_KEY: 'deepseek',\n XAI_API_KEY: 'xai',\n MOONSHOT_API_KEY: 'kimi',\n KIMI_API_KEY: 'kimi',\n };\n\n for (const [envVar, providerName] of Object.entries(envKeys)) {\n if (process.env[envVar]) {\n return createProvider(providerName, process.env[envVar], {\n model: options.model,\n think: options.think,\n thinkLevel: options.thinkLevel,\n });\n }\n }\n\n // Check .env file\n if (rootPath) {\n const envPath = path.join(rootPath, '.env');\n if (fs.existsSync(envPath)) {\n try {\n const content = fs.readFileSync(envPath, 'utf-8');\n for (const [envVar, providerName] of Object.entries(envKeys)) {\n const match = content.match(new RegExp(`^${envVar}\\\\s*=\\\\s*[\"']?([^\"'\\\\s]+)`, 'm'));\n if (match) return createProvider(providerName, match[1], {\n model: options.model,\n think: options.think,\n thinkLevel: options.thinkLevel,\n });\n }\n } catch { /* ignore */ }\n }\n }\n\n return null;\n}\n\n/**\n * Resolve an API key for a given provider name from env or .env file.\n */\nfunction resolveApiKey(providerName, rootPath) {\n const name = providerName.toLowerCase();\n const preset = OPENAI_COMPATIBLE_PRESETS[name];\n const envVar = preset?.envKey || `${name.toUpperCase()}_API_KEY`;\n const envVars = name === 'kimi' || name === 'moonshot' || name.startsWith('kimi-')\n ? ['MOONSHOT_API_KEY', 'KIMI_API_KEY']\n : [envVar];\n\n for (const key of envVars) {\n if (process.env[key]) return process.env[key];\n }\n\n if (rootPath) {\n const envPath = path.join(rootPath, '.env');\n if (fs.existsSync(envPath)) {\n try {\n const content = fs.readFileSync(envPath, 'utf-8');\n for (const key of envVars) {\n const match = content.match(new RegExp(`^${key}\\\\s*=\\\\s*[\"']?([^\"'\\\\s]+)`, 'm'));\n if (match) return match[1];\n }\n } catch { /* ignore */ }\n }\n }\n\n return null;\n}\n\nexport { OPENAI_COMPATIBLE_PRESETS };\nexport default { createProvider, autoDetectProvider };\n"}}}}