import fs from "node:fs"; import { HumanMessage, SystemMessage } from "@langchain/core/messages"; import { END, type GraphNode, START, StateGraph } from "@langchain/langgraph"; import { z } from "zod"; import { createLLM } from "../../config/llm.ts"; import { emitStep, logger } from "../../logger.ts"; import { MAX_DOC_CHARS, MAX_REFLECTIONS, MAX_SOL_CHARS, MIN_FILE_IMPORTANCE } from "./config.ts"; import { FIND_VULNERABILITIES_PROMPT, GATHER_CONTEXT_PROMPT, JUDGE_FINDINGS_PROMPT, RANK_FILES_PROMPT, REFINE_VULNERABILITIES_PROMPT, } from "./prompts.ts"; import { AuditorState, CandidateFindingSchema, FileRankingSchema, JudgeReviewSchema } from "./state.ts"; import { buildRepoTree } from "./tools/repo-tree/tool.ts"; import { analyzeSolidityFile } from "./tools/solidity-analyzer/tool.ts"; import { buildReviewBlocks, matchLines, walkDirectory } from "./utils.ts"; const llmHaiku = createLLM("anthropic", { model: "claude-haiku-4-5", maxTokens: 20000 }); const llmOpus = createLLM("anthropic", { model: "claude-opus-4-8", temperature: null, maxTokens: 20000 }); const llmSonnet = createLLM("anthropic", { model: "claude-sonnet-4-6", maxTokens: 20000 }); const defineScope: GraphNode = async (state) => { emitStep({ agent: "auditor", step: "scope", status: "running" }); logger.info(`[Auditor] defineScope: percorrendo repositório em ${state.repoPath}`); const solFiles: string[] = []; const docFiles: string[] = []; walkDirectory(state.repoPath, 0, solFiles, docFiles); const fileTree = buildRepoTree(state.repoPath); logger.info( `[Auditor] defineScope: encontrado(s) ${solFiles.length} arquivo(s) Solidity e ${docFiles.length} arquivo(s) de documentação`, ); logger.debug(`[Auditor] defineScope: arquivos Solidity: ${JSON.stringify(solFiles)}`); logger.debug(`[Auditor] defineScope: arquivos de documentação: ${JSON.stringify(docFiles)}`); logger.debug(`[Auditor] defineScope: árvore de arquivos:\n${fileTree}`); emitStep({ agent: "auditor", step: "scope", status: "done" }); logger.info("[Auditor] defineScope: rankeando arquivos por importância"); const RankFilesSchema = z.object({ rankings: z.array(FileRankingSchema) }); const rankingModel = llmHaiku.withStructuredOutput(RankFilesSchema); const { rankings } = await rankingModel.invoke([ new SystemMessage({ content: [{ type: "text", text: RANK_FILES_PROMPT, cache_control: { type: "ephemeral" } }] }), new HumanMessage( `Árvore de arquivos:\n\`\`\`\n${fileTree}\n\`\`\`\n\nArquivos Solidity para classificar:\n${solFiles.map((f) => `- ${f}`).join("\n")}`, ), ]); const sorted = [...rankings].sort((a, b) => b.importance - a.importance); logger.info( `[Auditor] defineScope: rankings:\n${sorted.map((r) => ` [${r.importance}/5] ${r.filePath} — ${r.reasoning}`).join("\n")}`, ); const importantFiles = sorted.filter((r) => r.importance >= MIN_FILE_IMPORTANCE).map((r) => r.filePath); const skipped = solFiles.length - importantFiles.length; if (skipped > 0) { logger.info( `[Auditor] defineScope: pulando ${skipped} arquivo(s) de baixa importância (importância < ${MIN_FILE_IMPORTANCE})`, ); } return { scope: importantFiles, docs: docFiles, fileTree, fileRankings: sorted }; }; const gatherContext: GraphNode = async (state) => { emitStep({ agent: "auditor", step: "ctx", status: "running" }); logger.info( `[Auditor] gatherContext: processando ${state.scope.length} arquivo(s) Solidity e ${state.docs.length} arquivo(s) de documentação`, ); const readFile = (filePath: string): string => { try { return fs.readFileSync(filePath, "utf-8"); } catch { return ""; } }; const solidityEntries: { filePath: string; source: string; analysis: string }[] = []; for (const filePath of state.scope) { const source = readFile(filePath).slice(0, MAX_SOL_CHARS); if (!source) continue; const ranking = state.fileRankings.find((r) => r.filePath === filePath); const mode = ranking && ranking.importance >= 4 ? "full" : "short"; const analysis = await analyzeSolidityFile(source, mode, filePath, ranking?.importance); solidityEntries.push({ filePath, source, analysis }); } const docEntries: { filePath: string; content: string }[] = []; for (const filePath of state.docs) { const content = readFile(filePath).slice(0, MAX_DOC_CHARS); if (content) docEntries.push({ filePath, content }); } const parts: string[] = []; if (docEntries.length > 0) { parts.push("## Documentação\n"); for (const { filePath, content } of docEntries) { parts.push(`### ${filePath}\n${content}`); } } parts.push(`## Árvore de Arquivos\n\n\`\`\`\n${state.fileTree}\n\`\`\``); parts.push("## Análise Estrutural\n"); for (const { analysis } of solidityEntries) { parts.push(analysis); } const model = llmHaiku.withStructuredOutput(z.object({ context: z.string() })); const result = await model.invoke([ new SystemMessage({ content: [{ type: "text", text: GATHER_CONTEXT_PROMPT, cache_control: { type: "ephemeral" } }], }), new HumanMessage(parts.join("\n\n")), ]); const fileTreeBlock = `## Árvore de Arquivos\n\n\`\`\`\n${state.fileTree}\n\`\`\``; const structuralBlock = `## Análise Estrutural dos Contratos\n\n${solidityEntries.map(({ analysis }) => analysis).join("\n\n---\n\n")}`; const repoContext = [result.context, fileTreeBlock, structuralBlock].join("\n\n"); logger.debug(`[Auditor] gatherContext: contexto completo:\n${parts.join("\n\n")}`); logger.info(`[Auditor] gatherContext: contexto construído (${repoContext.length} caracteres)`); logger.debug(`[Auditor] gatherContext: contexto compactado:\n${repoContext}`); emitStep({ agent: "auditor", step: "ctx", status: "done" }); return { repoContext }; }; const findVulnerabilities: GraphNode = async (state) => { const model = llmOpus.withStructuredOutput(z.object({ findings: z.array(CandidateFindingSchema) })); const isReflection = state.judgeReviews.length > 0; logger.info( `[Auditor] findVulnerabilities: invocando LLM para ${state.scope.length} arquivo(s) em paralelo (iteração ${state.reflectionCount + 1})`, ); emitStep({ agent: "auditor", step: "find", status: "running", detail: `iter ${state.reflectionCount + 1}` }); const cachedContext = { type: "text" as const, text: `Contexto do Protocolo:\n${state.repoContext}`, cache_control: { type: "ephemeral" as const }, }; const processFile = async (filePath: string) => { let source: string; try { source = fs.readFileSync(filePath, "utf-8").slice(0, MAX_SOL_CHARS); } catch { return []; } if (!source) return []; const fileEntries = isReflection ? state.candidateFindings .map((f, i) => ({ finding: f, review: state.judgeReviews[i] })) .filter(({ finding }) => finding.path === filePath) : []; const isRefinement = fileEntries.length > 0; const promptText = isRefinement ? REFINE_VULNERABILITIES_PROMPT : FIND_VULNERABILITIES_PROMPT; const contractText = isRefinement ? `Contrato (${filePath}):\n\n${source}\n\n${buildReviewBlocks(fileEntries, state.reflectionCount)}` : `Contrato (${filePath}):\n\n${source}`; logger.debug(`[Auditor] findVulnerabilities: processando ${filePath}`); const result = await model.invoke([ new SystemMessage({ content: [{ type: "text", text: promptText, cache_control: { type: "ephemeral" } }] }), new HumanMessage({ content: [cachedContext, { type: "text", text: contractText }] }), ]); return result.findings.map((finding: any) => ({ ...finding, path: filePath, location: matchLines(source, finding.codeSnippet) ?? "", })); }; const [firstFile, ...restFiles] = state.scope; const firstFindings = firstFile ? await processFile(firstFile) : []; const restFindings = await Promise.all(restFiles.map(processFile)); const candidateFindings = [firstFindings, ...restFindings].flat(); logger.info( `[Auditor] findVulnerabilities: LLM retornou ${candidateFindings.length} finding(s) candidato(s) no total`, ); logger.debug(`[Auditor] findVulnerabilities: findings:\n${JSON.stringify(candidateFindings, null, 2)}`); emitStep({ agent: "auditor", step: "find", status: "done" }); return { candidateFindings }; }; const judgeFindings: GraphNode = async (state) => { emitStep({ agent: "auditor", step: "judge", status: "running" }); if (state.candidateFindings.length === 0) { logger.info("[Auditor] judgeFindings: sem findings candidatos para revisar, pulando chamada ao LLM"); emitStep({ agent: "auditor", step: "judge", status: "done" }); return { judgeReviews: [], findings: [], reflectionCount: state.reflectionCount + 1, }; } const model = llmSonnet.withStructuredOutput(JudgeReviewSchema); logger.info( `[Auditor] judgeFindings: revisando ${state.candidateFindings.length} finding(s) candidato(s) em paralelo`, ); const cachedContext = { type: "text" as const, text: `Contexto do Protocolo:\n${state.repoContext}`, cache_control: { type: "ephemeral" as const }, }; const reviewFinding = async (finding: (typeof state.candidateFindings)[number], i: number) => { let source: string; try { source = fs.readFileSync(finding.path, "utf-8").slice(0, MAX_SOL_CHARS); } catch { source = ""; } const findingText = `[Achado ${i + 1}] ${finding.title}\nSeveridade: ${finding.severity}\nDescrição: ${finding.description}\nLocalização: ${finding.path} linhas ${finding.location}\nCódigo:\n\`\`\`solidity\n${finding.codeSnippet}\n\`\`\``; logger.debug(`[Auditor] judgeFindings: revisando finding ${i + 1}: ${finding.title}`); return model.invoke([ new SystemMessage({ content: [{ type: "text", text: JUDGE_FINDINGS_PROMPT, cache_control: { type: "ephemeral" } }], }), new HumanMessage({ content: [ cachedContext, { type: "text", text: `Contrato (${finding.path}):\n\n${source}\n\nAchado para Revisão:\n\n${findingText}` }, ], }), ]); }; const [firstFinding, ...restFindings] = state.candidateFindings; const firstReview = await reviewFinding(firstFinding, 0); const restReviews = await Promise.all(restFindings.map((f, i) => reviewFinding(f, i + 1))); const reviews = [firstReview, ...restReviews]; const confirmedEntries = state.candidateFindings .map((finding, i) => ({ finding, review: reviews[i] })) .filter(({ review }) => !review.isFalsePositive); const findings = confirmedEntries.map(({ finding, review }) => ({ ...finding, judgeReview: { review: review.review, confidence: review.confidence, exploitablePaths: review.exploitablePaths, }, })); const falsePositiveCount = state.candidateFindings.length - findings.length; logger.info(`[Auditor] judgeFindings: ${findings.length} confirmado(s), ${falsePositiveCount} falso(s) positivo(s)`); logger.debug(`[Auditor] judgeFindings: revisões:\n${JSON.stringify(reviews, null, 2)}`); emitStep({ agent: "auditor", step: "judge", status: "done" }); return { judgeReviews: reviews, findings, reflectionCount: state.reflectionCount + 1, }; }; export const auditorAgent = new StateGraph(AuditorState) .addNode("defineScope", defineScope) .addNode("gatherContext", gatherContext) .addNode("findVulnerabilities", findVulnerabilities) .addNode("judgeFindings", judgeFindings) .addEdge(START, "defineScope") .addEdge("defineScope", "gatherContext") .addEdge("gatherContext", "findVulnerabilities") .addEdge("findVulnerabilities", "judgeFindings") .addConditionalEdges("judgeFindings", (state) => { const hasFalsePositives = state.judgeReviews.some((r) => r.isFalsePositive); if (hasFalsePositives && state.reflectionCount < MAX_REFLECTIONS) { return "findVulnerabilities"; } return END; }) .compile(); export const testAgent = new StateGraph(AuditorState) .addNode("defineScope", defineScope) .addNode("gatherContext", gatherContext) .addEdge(START, "defineScope") .addEdge("defineScope", "gatherContext") .addEdge("gatherContext", END) .compile();