Spaces:
Runtime error
Runtime error
Merge pull request #6 from uandersonricardo/improve-audit-agent
Browse files- biome.json +51 -53
- package-lock.json +12 -10
- package.json +1 -1
- src/agents/auditor/agent.ts +146 -128
- src/agents/auditor/config.ts +2 -1
- src/agents/auditor/prompts.ts +113 -32
- src/agents/auditor/state.ts +7 -0
- src/agents/auditor/tools/{repo-tree-tool.ts → repo-tree/tool.ts} +9 -12
- src/agents/auditor/tools/solidity-analyzer-tool.ts +0 -567
- src/agents/auditor/tools/solidity-analyzer/tool.ts +172 -0
- src/agents/auditor/tools/solidity-analyzer/utils.ts +520 -0
- src/agents/auditor/utils.ts +81 -0
- src/config/llm.ts +18 -11
biome.json
CHANGED
|
@@ -1,55 +1,53 @@
|
|
| 1 |
{
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
}
|
| 54 |
-
}
|
| 55 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
| 3 |
+
"vcs": {
|
| 4 |
+
"enabled": true,
|
| 5 |
+
"clientKind": "git",
|
| 6 |
+
"useIgnoreFile": true
|
| 7 |
+
},
|
| 8 |
+
"files": {
|
| 9 |
+
"includes": ["**", "!!**/dist"]
|
| 10 |
+
},
|
| 11 |
+
"formatter": {
|
| 12 |
+
"enabled": true,
|
| 13 |
+
"lineWidth": 120,
|
| 14 |
+
"indentStyle": "space"
|
| 15 |
+
},
|
| 16 |
+
"linter": {
|
| 17 |
+
"enabled": true,
|
| 18 |
+
"rules": {
|
| 19 |
+
"recommended": true,
|
| 20 |
+
"suspicious": {
|
| 21 |
+
"noUnknownAtRules": "off",
|
| 22 |
+
"noArrayIndexKey": "off",
|
| 23 |
+
"noExplicitAny": "off",
|
| 24 |
+
"noAssignInExpressions": "off"
|
| 25 |
+
},
|
| 26 |
+
"complexity": {
|
| 27 |
+
"noStaticOnlyClass": "off"
|
| 28 |
+
},
|
| 29 |
+
"correctness": {
|
| 30 |
+
"useExhaustiveDependencies": "off",
|
| 31 |
+
"useParseIntRadix": "off"
|
| 32 |
+
},
|
| 33 |
+
"a11y": "off",
|
| 34 |
+
"style": {
|
| 35 |
+
"noNonNullAssertion": "off",
|
| 36 |
+
"useTemplate": "off"
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
},
|
| 40 |
+
"javascript": {
|
| 41 |
+
"formatter": {
|
| 42 |
+
"quoteStyle": "double"
|
| 43 |
+
}
|
| 44 |
+
},
|
| 45 |
+
"assist": {
|
| 46 |
+
"enabled": true,
|
| 47 |
+
"actions": {
|
| 48 |
+
"source": {
|
| 49 |
+
"organizeImports": "on"
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
}
|
|
|
|
|
|
|
| 53 |
}
|
package-lock.json
CHANGED
|
@@ -11,7 +11,7 @@
|
|
| 11 |
"license": "ISC",
|
| 12 |
"dependencies": {
|
| 13 |
"@hono/node-server": "^2.0.3",
|
| 14 |
-
"@langchain/anthropic": "^1.
|
| 15 |
"@langchain/core": "^1.1.45",
|
| 16 |
"@langchain/google-genai": "^2.1.31",
|
| 17 |
"@langchain/langgraph": "^1.3.2",
|
|
@@ -36,9 +36,9 @@
|
|
| 36 |
}
|
| 37 |
},
|
| 38 |
"node_modules/@anthropic-ai/sdk": {
|
| 39 |
-
"version": "0.
|
| 40 |
-
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.
|
| 41 |
-
"integrity": "sha512-
|
| 42 |
"license": "MIT",
|
| 43 |
"dependencies": {
|
| 44 |
"json-schema-to-ts": "^3.1.1",
|
|
@@ -199,23 +199,25 @@
|
|
| 199 |
}
|
| 200 |
},
|
| 201 |
"node_modules/@langchain/anthropic": {
|
| 202 |
-
"version": "1.4.
|
| 203 |
-
"resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.4.
|
| 204 |
-
"integrity": "sha512-
|
| 205 |
"license": "MIT",
|
| 206 |
"dependencies": {
|
| 207 |
-
"@anthropic-ai/sdk": "^0.
|
| 208 |
"zod": "^3.25.76 || ^4"
|
| 209 |
},
|
| 210 |
"engines": {
|
| 211 |
"node": ">=20"
|
| 212 |
},
|
| 213 |
"peerDependencies": {
|
| 214 |
-
"@langchain/core": "^1.1.
|
| 215 |
}
|
| 216 |
},
|
| 217 |
"node_modules/@langchain/core": {
|
| 218 |
-
"version": "1.1.
|
|
|
|
|
|
|
| 219 |
"license": "MIT",
|
| 220 |
"dependencies": {
|
| 221 |
"@cfworker/json-schema": "^4.0.2",
|
|
|
|
| 11 |
"license": "ISC",
|
| 12 |
"dependencies": {
|
| 13 |
"@hono/node-server": "^2.0.3",
|
| 14 |
+
"@langchain/anthropic": "^1.4.1",
|
| 15 |
"@langchain/core": "^1.1.45",
|
| 16 |
"@langchain/google-genai": "^2.1.31",
|
| 17 |
"@langchain/langgraph": "^1.3.2",
|
|
|
|
| 36 |
}
|
| 37 |
},
|
| 38 |
"node_modules/@anthropic-ai/sdk": {
|
| 39 |
+
"version": "0.103.0",
|
| 40 |
+
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.103.0.tgz",
|
| 41 |
+
"integrity": "sha512-1uG7RNgoHTUxzOXqSCODKt0UTVlxWiHk/2Tt2/uQJiPW7XzBeKVuJyd3Aw6T3LPyvZV/jDTnPLX7SaM70WLLjA==",
|
| 42 |
"license": "MIT",
|
| 43 |
"dependencies": {
|
| 44 |
"json-schema-to-ts": "^3.1.1",
|
|
|
|
| 199 |
}
|
| 200 |
},
|
| 201 |
"node_modules/@langchain/anthropic": {
|
| 202 |
+
"version": "1.4.1",
|
| 203 |
+
"resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.4.1.tgz",
|
| 204 |
+
"integrity": "sha512-h3b6hxThcfh0WdmpuWr+qBi74MN+0BpNI/4H681vwXxbD3hLr2qMYN6ghqcPQhCxGJjg8ufs85qu2/ldSWonYQ==",
|
| 205 |
"license": "MIT",
|
| 206 |
"dependencies": {
|
| 207 |
+
"@anthropic-ai/sdk": "^0.103.0",
|
| 208 |
"zod": "^3.25.76 || ^4"
|
| 209 |
},
|
| 210 |
"engines": {
|
| 211 |
"node": ">=20"
|
| 212 |
},
|
| 213 |
"peerDependencies": {
|
| 214 |
+
"@langchain/core": "^1.1.49"
|
| 215 |
}
|
| 216 |
},
|
| 217 |
"node_modules/@langchain/core": {
|
| 218 |
+
"version": "1.1.49",
|
| 219 |
+
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.49.tgz",
|
| 220 |
+
"integrity": "sha512-7wkN3Qv/qZqsY0p3h48CNu6E6y5GMYatYxj+JrX4uVNBiqIVQm1Z528QrmayJWVW9SQTQicqRNoyTCzl+K9F8Q==",
|
| 221 |
"license": "MIT",
|
| 222 |
"dependencies": {
|
| 223 |
"@cfworker/json-schema": "^4.0.2",
|
package.json
CHANGED
|
@@ -34,7 +34,7 @@
|
|
| 34 |
},
|
| 35 |
"dependencies": {
|
| 36 |
"@hono/node-server": "^2.0.3",
|
| 37 |
-
"@langchain/anthropic": "^1.
|
| 38 |
"@langchain/core": "^1.1.45",
|
| 39 |
"@langchain/google-genai": "^2.1.31",
|
| 40 |
"@langchain/langgraph": "^1.3.2",
|
|
|
|
| 34 |
},
|
| 35 |
"dependencies": {
|
| 36 |
"@hono/node-server": "^2.0.3",
|
| 37 |
+
"@langchain/anthropic": "^1.4.1",
|
| 38 |
"@langchain/core": "^1.1.45",
|
| 39 |
"@langchain/google-genai": "^2.1.31",
|
| 40 |
"@langchain/langgraph": "^1.3.2",
|
src/agents/auditor/agent.ts
CHANGED
|
@@ -1,60 +1,27 @@
|
|
| 1 |
import fs from "node:fs";
|
| 2 |
-
import path from "node:path";
|
| 3 |
|
| 4 |
import { HumanMessage, SystemMessage } from "@langchain/core/messages";
|
| 5 |
import { END, type GraphNode, START, StateGraph } from "@langchain/langgraph";
|
| 6 |
import { z } from "zod";
|
| 7 |
|
| 8 |
-
import { logger } from "../../logger.ts";
|
| 9 |
import { createLLM } from "../../config/llm.ts";
|
| 10 |
-
import {
|
| 11 |
-
import {
|
| 12 |
-
import { analyzeSolidityFile } from "./tools/solidity-analyzer-tool.ts";
|
| 13 |
-
import { buildRepoTree } from "./tools/repo-tree-tool.ts";
|
| 14 |
import {
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
} from "./
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
const
|
| 28 |
-
|
| 29 |
-
const walkDirectory = (dir: string, depth: number, solFiles: string[], docFiles: string[]) => {
|
| 30 |
-
if (depth > MAX_DEPTH) return;
|
| 31 |
-
|
| 32 |
-
let entries: fs.Dirent[];
|
| 33 |
-
try {
|
| 34 |
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
| 35 |
-
} catch {
|
| 36 |
-
return;
|
| 37 |
-
}
|
| 38 |
-
|
| 39 |
-
for (const entry of entries) {
|
| 40 |
-
if (entry.isDirectory()) {
|
| 41 |
-
if (!SKIP_DIRS.has(entry.name)) {
|
| 42 |
-
walkDirectory(path.join(dir, entry.name), depth + 1, solFiles, docFiles);
|
| 43 |
-
}
|
| 44 |
-
} else if (entry.isFile()) {
|
| 45 |
-
const fullPath = path.join(dir, entry.name);
|
| 46 |
-
const ext = path.extname(entry.name).toLowerCase();
|
| 47 |
-
const base = path.basename(entry.name, ext).toLowerCase();
|
| 48 |
-
|
| 49 |
-
if (ext === SOL_EXT) {
|
| 50 |
-
const isTest = SOL_TEST_SUFFIXES.some((suffix) => entry.name.endsWith(suffix));
|
| 51 |
-
if (!isTest) solFiles.push(fullPath);
|
| 52 |
-
} else if (DOC_EXTS.has(ext) || DOC_BASENAMES.has(base)) {
|
| 53 |
-
docFiles.push(fullPath);
|
| 54 |
-
}
|
| 55 |
-
}
|
| 56 |
-
}
|
| 57 |
-
};
|
| 58 |
|
| 59 |
const defineScope: GraphNode<typeof AuditorState> = async (state) => {
|
| 60 |
logger.info(`defineScope: walking repo at ${state.repoPath}`);
|
|
@@ -71,7 +38,30 @@ const defineScope: GraphNode<typeof AuditorState> = async (state) => {
|
|
| 71 |
logger.debug(`defineScope: doc files: ${JSON.stringify(docFiles)}`);
|
| 72 |
logger.debug(`defineScope: file tree:\n${fileTree}`);
|
| 73 |
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
};
|
| 76 |
|
| 77 |
const gatherContext: GraphNode<typeof AuditorState> = async (state) => {
|
|
@@ -85,99 +75,113 @@ const gatherContext: GraphNode<typeof AuditorState> = async (state) => {
|
|
| 85 |
}
|
| 86 |
};
|
| 87 |
|
| 88 |
-
// Read and analyze each Solidity file
|
| 89 |
const solidityEntries: { filePath: string; source: string; analysis: string }[] = [];
|
| 90 |
for (const filePath of state.scope) {
|
| 91 |
const source = readFile(filePath).slice(0, MAX_SOL_CHARS);
|
| 92 |
if (!source) continue;
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
| 94 |
solidityEntries.push({ filePath, source, analysis });
|
| 95 |
}
|
| 96 |
|
| 97 |
-
// Read documentation files
|
| 98 |
const docEntries: { filePath: string; content: string }[] = [];
|
| 99 |
for (const filePath of state.docs) {
|
| 100 |
const content = readFile(filePath).slice(0, MAX_DOC_CHARS);
|
| 101 |
if (content) docEntries.push({ filePath, content });
|
| 102 |
}
|
| 103 |
|
| 104 |
-
// Build the LLM input
|
| 105 |
const parts: string[] = [];
|
| 106 |
|
| 107 |
if (docEntries.length > 0) {
|
| 108 |
-
parts.push("##
|
| 109 |
for (const { filePath, content } of docEntries) {
|
| 110 |
parts.push(`### ${filePath}\n${content}`);
|
| 111 |
}
|
| 112 |
}
|
| 113 |
|
| 114 |
-
parts.push(
|
| 115 |
-
for (const { filePath, analysis } of solidityEntries) {
|
| 116 |
-
parts.push(`### ${filePath}\n${analysis}`);
|
| 117 |
-
}
|
| 118 |
|
| 119 |
-
parts.push("##
|
| 120 |
-
for (const {
|
| 121 |
-
parts.push(
|
| 122 |
}
|
| 123 |
|
| 124 |
-
const model =
|
| 125 |
-
const result = await model.invoke([
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
-
logger.info(`gatherContext: context built (${parts.join("\n\n").length} chars)`);
|
| 128 |
logger.debug(`gatherContext: full context:\n${parts.join("\n\n")}`);
|
|
|
|
|
|
|
| 129 |
|
| 130 |
-
return { repoContext
|
| 131 |
};
|
| 132 |
|
| 133 |
const findVulnerabilities: GraphNode<typeof AuditorState> = async (state) => {
|
| 134 |
-
const model =
|
| 135 |
-
|
| 136 |
-
const
|
| 137 |
-
state.judgeReviews.length > 0
|
| 138 |
-
? state.judgeReviews
|
| 139 |
-
.map((r, i) => {
|
| 140 |
-
const title = state.candidateFindings[i]?.title ?? `Finding ${i + 1}`;
|
| 141 |
-
return `- "${title}": ${r.isFalsePositive ? "FALSE POSITIVE" : "TRUE POSITIVE"}\n Judge: ${r.review}`;
|
| 142 |
-
})
|
| 143 |
-
.join("\n")
|
| 144 |
-
: null;
|
| 145 |
|
| 146 |
logger.info(
|
| 147 |
`findVulnerabilities: invoking LLM for ${state.scope.length} file(s) in parallel (iteration ${state.reflectionCount + 1})`,
|
| 148 |
);
|
| 149 |
|
| 150 |
-
const
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
})
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
-
const candidateFindings = allFindings.flat();
|
| 181 |
logger.info(`findVulnerabilities: LLM returned ${candidateFindings.length} total candidate finding(s)`);
|
| 182 |
logger.debug(`findVulnerabilities: findings:\n${JSON.stringify(candidateFindings, null, 2)}`);
|
| 183 |
|
|
@@ -194,30 +198,44 @@ const judgeFindings: GraphNode<typeof AuditorState> = async (state) => {
|
|
| 194 |
};
|
| 195 |
}
|
| 196 |
|
| 197 |
-
const model =
|
| 198 |
|
| 199 |
logger.info(`judgeFindings: reviewing ${state.candidateFindings.length} candidate finding(s) in parallel`);
|
| 200 |
|
| 201 |
-
const
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
|
| 222 |
const confirmedEntries = state.candidateFindings
|
| 223 |
.map((finding, i) => ({ finding, review: reviews[i] }))
|
|
|
|
| 1 |
import fs from "node:fs";
|
|
|
|
| 2 |
|
| 3 |
import { HumanMessage, SystemMessage } from "@langchain/core/messages";
|
| 4 |
import { END, type GraphNode, START, StateGraph } from "@langchain/langgraph";
|
| 5 |
import { z } from "zod";
|
| 6 |
|
|
|
|
| 7 |
import { createLLM } from "../../config/llm.ts";
|
| 8 |
+
import { logger } from "../../logger.ts";
|
| 9 |
+
import { MAX_DOC_CHARS, MAX_REFLECTIONS, MAX_SOL_CHARS, MIN_FILE_IMPORTANCE } from "./config.ts";
|
|
|
|
|
|
|
| 10 |
import {
|
| 11 |
+
FIND_VULNERABILITIES_PROMPT,
|
| 12 |
+
GATHER_CONTEXT_PROMPT,
|
| 13 |
+
JUDGE_FINDINGS_PROMPT,
|
| 14 |
+
RANK_FILES_PROMPT,
|
| 15 |
+
REFINE_VULNERABILITIES_PROMPT,
|
| 16 |
+
} from "./prompts.ts";
|
| 17 |
+
import { AuditorState, CandidateFindingSchema, FileRankingSchema, JudgeReviewSchema } from "./state.ts";
|
| 18 |
+
import { buildRepoTree } from "./tools/repo-tree/tool.ts";
|
| 19 |
+
import { analyzeSolidityFile } from "./tools/solidity-analyzer/tool.ts";
|
| 20 |
+
import { buildReviewBlocks, matchLines, walkDirectory } from "./utils.ts";
|
| 21 |
+
|
| 22 |
+
const llmHaiku = createLLM("anthropic", { model: "claude-haiku-4-5", maxTokens: 20000 });
|
| 23 |
+
const llmOpus = createLLM("anthropic", { model: "claude-opus-4-8", temperature: null, maxTokens: 20000 });
|
| 24 |
+
const llmSonnet = createLLM("anthropic", { model: "claude-sonnet-4-6", maxTokens: 20000 });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
const defineScope: GraphNode<typeof AuditorState> = async (state) => {
|
| 27 |
logger.info(`defineScope: walking repo at ${state.repoPath}`);
|
|
|
|
| 38 |
logger.debug(`defineScope: doc files: ${JSON.stringify(docFiles)}`);
|
| 39 |
logger.debug(`defineScope: file tree:\n${fileTree}`);
|
| 40 |
|
| 41 |
+
logger.info("defineScope: ranking files by importance");
|
| 42 |
+
|
| 43 |
+
const RankFilesSchema = z.object({ rankings: z.array(FileRankingSchema) });
|
| 44 |
+
const rankingModel = llmHaiku.withStructuredOutput(RankFilesSchema);
|
| 45 |
+
|
| 46 |
+
const { rankings } = await rankingModel.invoke([
|
| 47 |
+
new SystemMessage({ content: [{ type: "text", text: RANK_FILES_PROMPT, cache_control: { type: "ephemeral" } }] }),
|
| 48 |
+
new HumanMessage(
|
| 49 |
+
`Árvore de arquivos:\n\`\`\`\n${fileTree}\n\`\`\`\n\nArquivos Solidity para classificar:\n${solFiles.map((f) => `- ${f}`).join("\n")}`,
|
| 50 |
+
),
|
| 51 |
+
]);
|
| 52 |
+
|
| 53 |
+
const sorted = [...rankings].sort((a, b) => b.importance - a.importance);
|
| 54 |
+
logger.info(
|
| 55 |
+
`defineScope: rankings:\n${sorted.map((r) => ` [${r.importance}/5] ${r.filePath} — ${r.reasoning}`).join("\n")}`,
|
| 56 |
+
);
|
| 57 |
+
|
| 58 |
+
const importantFiles = sorted.filter((r) => r.importance >= MIN_FILE_IMPORTANCE).map((r) => r.filePath);
|
| 59 |
+
const skipped = solFiles.length - importantFiles.length;
|
| 60 |
+
if (skipped > 0) {
|
| 61 |
+
logger.info(`defineScope: skipping ${skipped} low-importance file(s) (importance < ${MIN_FILE_IMPORTANCE})`);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
return { scope: importantFiles, docs: docFiles, fileTree, fileRankings: sorted };
|
| 65 |
};
|
| 66 |
|
| 67 |
const gatherContext: GraphNode<typeof AuditorState> = async (state) => {
|
|
|
|
| 75 |
}
|
| 76 |
};
|
| 77 |
|
|
|
|
| 78 |
const solidityEntries: { filePath: string; source: string; analysis: string }[] = [];
|
| 79 |
for (const filePath of state.scope) {
|
| 80 |
const source = readFile(filePath).slice(0, MAX_SOL_CHARS);
|
| 81 |
if (!source) continue;
|
| 82 |
+
|
| 83 |
+
const ranking = state.fileRankings.find((r) => r.filePath === filePath);
|
| 84 |
+
const mode = ranking && ranking.importance >= 4 ? "full" : "short";
|
| 85 |
+
const analysis = await analyzeSolidityFile(source, mode, filePath, ranking?.importance);
|
| 86 |
solidityEntries.push({ filePath, source, analysis });
|
| 87 |
}
|
| 88 |
|
|
|
|
| 89 |
const docEntries: { filePath: string; content: string }[] = [];
|
| 90 |
for (const filePath of state.docs) {
|
| 91 |
const content = readFile(filePath).slice(0, MAX_DOC_CHARS);
|
| 92 |
if (content) docEntries.push({ filePath, content });
|
| 93 |
}
|
| 94 |
|
|
|
|
| 95 |
const parts: string[] = [];
|
| 96 |
|
| 97 |
if (docEntries.length > 0) {
|
| 98 |
+
parts.push("## Documentação\n");
|
| 99 |
for (const { filePath, content } of docEntries) {
|
| 100 |
parts.push(`### ${filePath}\n${content}`);
|
| 101 |
}
|
| 102 |
}
|
| 103 |
|
| 104 |
+
parts.push(`## Árvore de Arquivos\n\n\`\`\`\n${state.fileTree}\n\`\`\``);
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
+
parts.push("## Análise Estrutural\n");
|
| 107 |
+
for (const { analysis } of solidityEntries) {
|
| 108 |
+
parts.push(analysis);
|
| 109 |
}
|
| 110 |
|
| 111 |
+
const model = llmHaiku.withStructuredOutput(z.object({ context: z.string() }));
|
| 112 |
+
const result = await model.invoke([
|
| 113 |
+
new SystemMessage({
|
| 114 |
+
content: [{ type: "text", text: GATHER_CONTEXT_PROMPT, cache_control: { type: "ephemeral" } }],
|
| 115 |
+
}),
|
| 116 |
+
new HumanMessage(parts.join("\n\n")),
|
| 117 |
+
]);
|
| 118 |
+
|
| 119 |
+
const fileTreeBlock = `## Árvore de Arquivos\n\n\`\`\`\n${state.fileTree}\n\`\`\``;
|
| 120 |
+
const structuralBlock = `## Análise Estrutural dos Contratos\n\n${solidityEntries.map(({ analysis }) => analysis).join("\n\n---\n\n")}`;
|
| 121 |
+
const repoContext = [result.context, fileTreeBlock, structuralBlock].join("\n\n");
|
| 122 |
|
|
|
|
| 123 |
logger.debug(`gatherContext: full context:\n${parts.join("\n\n")}`);
|
| 124 |
+
logger.info(`gatherContext: context built (${repoContext.length} chars)`);
|
| 125 |
+
logger.debug(`gatherContext: compact context:\n${repoContext}`);
|
| 126 |
|
| 127 |
+
return { repoContext };
|
| 128 |
};
|
| 129 |
|
| 130 |
const findVulnerabilities: GraphNode<typeof AuditorState> = async (state) => {
|
| 131 |
+
const model = llmOpus.withStructuredOutput(z.object({ findings: z.array(CandidateFindingSchema) }));
|
| 132 |
+
|
| 133 |
+
const isReflection = state.judgeReviews.length > 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
logger.info(
|
| 136 |
`findVulnerabilities: invoking LLM for ${state.scope.length} file(s) in parallel (iteration ${state.reflectionCount + 1})`,
|
| 137 |
);
|
| 138 |
|
| 139 |
+
const cachedContext = {
|
| 140 |
+
type: "text" as const,
|
| 141 |
+
text: `Contexto do Protocolo:\n${state.repoContext}`,
|
| 142 |
+
cache_control: { type: "ephemeral" as const },
|
| 143 |
+
};
|
| 144 |
+
|
| 145 |
+
const processFile = async (filePath: string) => {
|
| 146 |
+
let source: string;
|
| 147 |
+
try {
|
| 148 |
+
source = fs.readFileSync(filePath, "utf-8").slice(0, MAX_SOL_CHARS);
|
| 149 |
+
} catch {
|
| 150 |
+
return [];
|
| 151 |
+
}
|
| 152 |
+
if (!source) return [];
|
| 153 |
+
|
| 154 |
+
const fileEntries = isReflection
|
| 155 |
+
? state.candidateFindings
|
| 156 |
+
.map((f, i) => ({ finding: f, review: state.judgeReviews[i] }))
|
| 157 |
+
.filter(({ finding }) => finding.path === filePath)
|
| 158 |
+
: [];
|
| 159 |
+
|
| 160 |
+
const isRefinement = fileEntries.length > 0;
|
| 161 |
+
const promptText = isRefinement ? REFINE_VULNERABILITIES_PROMPT : FIND_VULNERABILITIES_PROMPT;
|
| 162 |
+
const contractText = isRefinement
|
| 163 |
+
? `Contrato (${filePath}):\n\n${source}\n\n${buildReviewBlocks(fileEntries, state.reflectionCount)}`
|
| 164 |
+
: `Contrato (${filePath}):\n\n${source}`;
|
| 165 |
+
|
| 166 |
+
logger.debug(`findVulnerabilities: processing ${filePath}`);
|
| 167 |
+
|
| 168 |
+
const result = await model.invoke([
|
| 169 |
+
new SystemMessage({ content: [{ type: "text", text: promptText, cache_control: { type: "ephemeral" } }] }),
|
| 170 |
+
new HumanMessage({ content: [cachedContext, { type: "text", text: contractText }] }),
|
| 171 |
+
]);
|
| 172 |
+
|
| 173 |
+
return result.findings.map((finding: any) => ({
|
| 174 |
+
...finding,
|
| 175 |
+
path: filePath,
|
| 176 |
+
location: matchLines(source, finding.codeSnippet) ?? "",
|
| 177 |
+
}));
|
| 178 |
+
};
|
| 179 |
+
|
| 180 |
+
const [firstFile, ...restFiles] = state.scope;
|
| 181 |
+
const firstFindings = firstFile ? await processFile(firstFile) : [];
|
| 182 |
+
const restFindings = await Promise.all(restFiles.map(processFile));
|
| 183 |
+
const candidateFindings = [firstFindings, ...restFindings].flat();
|
| 184 |
|
|
|
|
| 185 |
logger.info(`findVulnerabilities: LLM returned ${candidateFindings.length} total candidate finding(s)`);
|
| 186 |
logger.debug(`findVulnerabilities: findings:\n${JSON.stringify(candidateFindings, null, 2)}`);
|
| 187 |
|
|
|
|
| 198 |
};
|
| 199 |
}
|
| 200 |
|
| 201 |
+
const model = llmSonnet.withStructuredOutput(JudgeReviewSchema);
|
| 202 |
|
| 203 |
logger.info(`judgeFindings: reviewing ${state.candidateFindings.length} candidate finding(s) in parallel`);
|
| 204 |
|
| 205 |
+
const cachedContext = {
|
| 206 |
+
type: "text" as const,
|
| 207 |
+
text: `Contexto do Protocolo:\n${state.repoContext}`,
|
| 208 |
+
cache_control: { type: "ephemeral" as const },
|
| 209 |
+
};
|
| 210 |
+
|
| 211 |
+
const reviewFinding = async (finding: (typeof state.candidateFindings)[number], i: number) => {
|
| 212 |
+
let source: string;
|
| 213 |
+
try {
|
| 214 |
+
source = fs.readFileSync(finding.path, "utf-8").slice(0, MAX_SOL_CHARS);
|
| 215 |
+
} catch {
|
| 216 |
+
source = "";
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
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\`\`\``;
|
| 220 |
+
|
| 221 |
+
logger.debug(`judgeFindings: reviewing finding ${i + 1}: ${finding.title}`);
|
| 222 |
+
return model.invoke([
|
| 223 |
+
new SystemMessage({
|
| 224 |
+
content: [{ type: "text", text: JUDGE_FINDINGS_PROMPT, cache_control: { type: "ephemeral" } }],
|
| 225 |
+
}),
|
| 226 |
+
new HumanMessage({
|
| 227 |
+
content: [
|
| 228 |
+
cachedContext,
|
| 229 |
+
{ type: "text", text: `Contrato (${finding.path}):\n\n${source}\n\nAchado para Revisão:\n\n${findingText}` },
|
| 230 |
+
],
|
| 231 |
+
}),
|
| 232 |
+
]);
|
| 233 |
+
};
|
| 234 |
+
|
| 235 |
+
const [firstFinding, ...restFindings] = state.candidateFindings;
|
| 236 |
+
const firstReview = await reviewFinding(firstFinding, 0);
|
| 237 |
+
const restReviews = await Promise.all(restFindings.map((f, i) => reviewFinding(f, i + 1)));
|
| 238 |
+
const reviews = [firstReview, ...restReviews];
|
| 239 |
|
| 240 |
const confirmedEntries = state.candidateFindings
|
| 241 |
.map((finding, i) => ({ finding, review: reviews[i] }))
|
src/agents/auditor/config.ts
CHANGED
|
@@ -20,4 +20,5 @@ export const SKIP_DIRS = new Set([
|
|
| 20 |
export const MAX_DEPTH = 6;
|
| 21 |
export const MAX_DOC_CHARS = 12_000;
|
| 22 |
export const MAX_SOL_CHARS = 40_000;
|
| 23 |
-
export const MAX_REFLECTIONS =
|
|
|
|
|
|
| 20 |
export const MAX_DEPTH = 6;
|
| 21 |
export const MAX_DOC_CHARS = 12_000;
|
| 22 |
export const MAX_SOL_CHARS = 40_000;
|
| 23 |
+
export const MAX_REFLECTIONS = 3;
|
| 24 |
+
export const MIN_FILE_IMPORTANCE = 3;
|
src/agents/auditor/prompts.ts
CHANGED
|
@@ -1,50 +1,131 @@
|
|
| 1 |
-
export const
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
-
|
| 9 |
-
Liste todas as variáveis de estado relevantes entre os contratos, o que representam e quais funções as leem ou escrevem. Sinalize armazenamento compartilhado ou herdado.
|
| 10 |
|
| 11 |
-
|
| 12 |
-
Trace os principais caminhos de execução e transições de estado de ponta a ponta entre contratos (ex.: depósito → cunhar shares → atualizar recompensas; saque → queimar shares → transferir ETH). Inclua chamadas entre contratos.
|
| 13 |
|
| 14 |
-
|
| 15 |
-
Condições que devem sempre ser verdadeiras (ex.: "o supply total deve ser igual à soma de todos os saldos", "o saldo de ETH do contrato ≥ soma de todos os depósitos dos usuários"). Derive-as tanto do código-fonte quanto da documentação.
|
| 16 |
|
| 17 |
-
|
| 18 |
-
O que o protocolo assume sobre chamadores, contratos externos, oráculos, chaves de administrador e comportamento de tokens (ex.: "tokens são compatíveis com ERC-20", "o admin é confiável", "sem tokens com taxa de transferência").
|
| 19 |
|
| 20 |
-
|
| 21 |
-
Controles de acesso, estruturas de taxas, timelocks, limites, mecanismos de pausa, padrões de atualização e quaisquer outras restrições de domínio.
|
| 22 |
|
| 23 |
-
|
|
|
|
| 24 |
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
- **
|
| 32 |
-
- **
|
| 33 |
-
- **codeSnippet**: O bloco de código vulnerável exatamente como aparece no código-fonte.
|
| 34 |
|
| 35 |
-
|
|
|
|
|
|
|
| 36 |
|
| 37 |
-
|
|
|
|
| 38 |
|
| 39 |
-
|
| 40 |
|
| 41 |
-
|
| 42 |
|
| 43 |
-
|
| 44 |
-
- **isFalsePositive**: true se o achado NÃO for explorável na prática; false se for uma vulnerabilidade real.
|
| 45 |
-
- **confidence**: Número inteiro de 0 a 100 refletindo sua confiança no veredicto.
|
| 46 |
-
- **exploitablePaths**: Array de strings. Se for verdadeiro positivo, forneça caminhos concretos confirmando a explorabilidade com valores reais. Cada rastreamento deve descrever os passos do atacante com entradas/valores realistas (ex.: "1. Atacante chama deposit(100 ETH) 2. Contrato do atacante no fallback chama withdraw() novamente antes da atualização do saldo 3. Atacante drena 100 ETH duas vezes"). Se for falso positivo, forneça o raciocínio que bloqueia o exploit.
|
| 47 |
|
| 48 |
-
|
| 49 |
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const RANK_FILES_PROMPT = `Você é um especialista em segurança de smart contracts. Dada a árvore de arquivos do repositório e uma lista de contratos Solidity, classifique cada arquivo pela sua importância para a descoberta de vulnerabilidades de segurança.
|
| 2 |
|
| 3 |
+
Para cada arquivo atribua:
|
| 4 |
+
- importance: inteiro de 1 (menos importante) a 5 (mais importante)
|
| 5 |
+
- reasoning: uma frase concisa justificando a classificação (mencione o papel específico do contrato, não categorias genéricas)
|
| 6 |
|
| 7 |
+
Critérios de classificação:
|
| 8 |
+
- Importância 5: lógica central do protocolo (vaults, pools, engines), custódia de tokens/ETH, mecanismos de upgrade/proxy, cálculos financeiros críticos (preço, juros, liquidação), controle de acesso raiz.
|
| 9 |
+
- Importância 4: contratos que movem valor e interagem diretamente com os de importância 5, máquinas de estado complexas, distribuição de taxas/recompensas, roteadores de entrada.
|
| 10 |
+
- Importância 3: helpers periféricos com lógica de negócio, bibliotecas reutilizáveis com efeitos colaterais, governança com timelocks.
|
| 11 |
+
- Importância 2: interfaces, adaptadores, wrappers triviais, contratos utilitários sem lógica crítica.
|
| 12 |
+
- Importância 1: views somente-leitura, arquivos de constantes/configuração pura, mocks e scripts de deploy.
|
| 13 |
|
| 14 |
+
Retorne a classificação de TODOS os arquivos fornecidos, sem omitir nenhum.`;
|
|
|
|
| 15 |
|
| 16 |
+
export const GATHER_CONTEXT_PROMPT = `Você é um especialista em segurança de smart contracts criando um modelo mental preciso do protocolo para auditoria. Você receberá documentação e a análise estrutural dos contratos Solidity em escopo.
|
|
|
|
| 17 |
|
| 18 |
+
**REGRA DE FIDELIDADE FACTUAL — NÃO NEGOCIÁVEL**: Baseie-se EXCLUSIVAMENTE no que está EXPLICITAMENTE presente no código-fonte (linhas de código, NatSpec, comentários inline) ou na documentação fornecida. NUNCA infira, suponha, extrapole ou "complete" informações que não estejam literalmente escritas nas fontes. Se uma informação não aparece explicitamente, NÃO a inclua e NÃO a invente. Esta regra se aplica a TODAS as seções. **Na seção Trust Assumptions esta proibição é ABSOLUTA: se não há declaração explícita (código, NatSpec, comentário ou doc) sobre uma suposição, ela não existe para você.**
|
|
|
|
| 19 |
|
| 20 |
+
Produza um contexto factual e detalhado de auditoria sem introduções, sem padding e sem repetições. Preserve nomes concretos (funções, variáveis, tipos, valores numéricos) exatamente como aparecem no código. Sem prosa explicativa.
|
|
|
|
| 21 |
|
| 22 |
+
A árvore de arquivos do repositório e a análise estrutural completa de cada contrato serão anexadas automaticamente ao final do contexto — **não as duplique**. Concentre-se nas seções de síntese abaixo.
|
|
|
|
| 23 |
|
| 24 |
+
## Visão geral
|
| 25 |
+
Descreva o propósito do protocolo, o fluxo econômico principal, os participantes envolvidos e os ativos protegidos — apenas o que estiver explicitamente declarado no código ou na documentação.
|
| 26 |
|
| 27 |
+
## Estado Crítico
|
| 28 |
+
Todas as variáveis de estado com impacto em lógica de negócio, segurança ou contabilidade, extraídas diretamente da seção Storage da análise estrutural.
|
| 29 |
+
Formato por bullet: \`filePath::nomeVar (tipo, visibilidade) — NatSpec/comentário se presente — funções que escrevem nela — impacto se manipulada\`.
|
| 30 |
+
Omita apenas constantes e imutáveis puramente administrativas (nome do token, símbolo, decimals, versão de string).
|
| 31 |
|
| 32 |
+
## Fluxos Principais (máx. 5 fluxos, 3–6 passos cada)
|
| 33 |
+
Apenas os caminhos críticos ponta a ponta que movem valor ou alteram estado relevante, derivados das funções e call graphs observados no código.
|
| 34 |
+
Formato por passo: \`ação (função) → efeito colateral → variável/estado alterado\`.
|
| 35 |
+
Inclua chamadas cross-contract quando materiais para entender superfície de ataque.
|
| 36 |
|
| 37 |
+
## Invariantes e Propriedades
|
| 38 |
+
Condições que devem ser verdadeiras para o protocolo operar corretamente, derivadas APENAS de \`require\`/\`assert\`/\`revert\` explícitos no código, NatSpec \`@dev\`, ou comentários que as declarem literalmente. Separe em dois grupos:
|
| 39 |
+
- **Econômicas**: balanços, totais, proporções (ex.: \`totalDebt == Σ userDebt[i]\`, \`reservas >= totalSupply * exchangeRate\`)
|
| 40 |
+
- **De controle**: acesso, sequência de operações, transições de estado permitidas
|
|
|
|
| 41 |
|
| 42 |
+
## Trust Assumptions
|
| 43 |
+
**SOMENTE o que estiver EXPLICITAMENTE declarado** em código-fonte (require, NatSpec, comentários inline) ou na documentação. **NÃO inferir. NÃO supor. NÃO extrapolar.** Se não há declaração explícita sobre confiança em um componente externo ou comportamento esperado, ele NÃO entra nesta seção — mesmo que pareça óbvio.
|
| 44 |
+
Bullets curtos com referência à fonte (ex.: "owner pode pausar o contrato — \`onlyOwner\` em \`pause()\`").
|
| 45 |
|
| 46 |
+
## Regras de Negócio e Restrições de Segurança
|
| 47 |
+
Em bullets: roles e modifiers relevantes (nomes exatos do código), limites numéricos (apenas valores literais presentes no código-fonte), taxas e destinatários, timelocks, pausabilidade, condições de upgrade, restrições de whitelist/blacklist. Inclua apenas regras com impacto direto em vetores de ataque.
|
| 48 |
|
| 49 |
+
**Formato obrigatório**: bullets e frases curtas. Dados concretos (nomes de funções, variáveis, valores numéricos) exatamente como aparecem no código. Sem prosa explicativa.`;
|
| 50 |
|
| 51 |
+
export const FIND_VULNERABILITIES_PROMPT = `Você é um auditor especialista em segurança de smart contracts com profundo conhecimento em Solidity, execução EVM e design de protocolos. Assuma que todos os usuários são adversariais e estão ativamente tentando explorar o contrato.
|
| 52 |
|
| 53 |
+
Sua tarefa é analisar o código-fonte fornecido linha a linha e identificar todas as vulnerabilidades de segurança, design, lógica e econômicas. Qualquer discrepância entre a implementação e o comportamento esperado, as suposições do protocolo ou o design econômico deve ser reportada como vulnerabilidade, mesmo que o contrato execute sem erros em runtime.
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
+
**Escopo obrigatório da análise**
|
| 56 |
|
| 57 |
+
Além das categorias técnicas listadas abaixo, sua análise deve cobrir:
|
| 58 |
+
|
| 59 |
+
- **Invariantes de protocolo**: identifique invariantes implícitas e explícitas (ex.: depósitos devem igualar saques, ativos devem permanecer colateralizados, recompensas devem corresponder aos inputs) e verifique se podem ser quebradas.
|
| 60 |
+
- **Fluxos de valor**: analise exaustivamente todas as transferências de valor (taxas, saldos, depósitos, saques, recompensas, deltas), verificando: quem provê os fundos, quem os recebe, e se os fundos são corretamente custodiados (escrowed) antes da transferência.
|
| 61 |
+
- **Validação de ownership**: verifique se usuários podem interagir com tokens, NFTs ou permissões que não controlam.
|
| 62 |
+
- **Observabilidade**: avalie se eventos, logs e chamadas de métodos representam corretamente as ações do protocolo. Emissão ausente, incorreta ou enganosa é uma vulnerabilidade.
|
| 63 |
+
- **Implementação vs. intenção**: qualquer desvio entre o comportamento implementado e o design pretendido do protocolo deve ser reportado.
|
| 64 |
+
|
| 65 |
+
## Categorias técnicas a verificar sistematicamente
|
| 66 |
+
|
| 67 |
+
Reentrância (simples, cross-function, cross-contract, read-only), controle de acesso (funções privilegiadas desprotegidas, erros em herança de roles), overflow/underflow (Solidity <0.8 ou uso de \`unchecked\`), manipulação de oráculo (TWAP curto, preço spot, valor de reserves), ataques de flash loan (price impact, liquidações artificiais), front-running e MEV (sandwich, race condition em aprovações), replay de assinatura (nonce ausente, falta de chainId), colisões de storage (proxies, delegatecall), proxies não inicializados (initializer sem proteção), delegatecall inseguro (destino controlável pelo usuário), griefing de gas (loops ilimitados, arrays crescentes), negação de serviço (push payments, dependência de chamada externa), perda de precisão (divisão antes de multiplicação, truncamento acumulativo), lógica de negócio (violação de invariantes, casos de borda em math financeira, race conditions de estado).
|
| 68 |
+
|
| 69 |
+
## Formato de saída
|
| 70 |
+
|
| 71 |
+
Para cada vulnerabilidade encontrada, forneça OBRIGATORIAMENTE todos os campos abaixo. Cada entrada deve ser **atômica**: reporte exatamente um problema por entrada. Não agrupe múltiplos problemas em um único achado, mesmo que ocorram na mesma função ou linha. Não há limite para o número de vulnerabilidades reportadas.
|
| 72 |
+
|
| 73 |
+
- **title**: Nome curto e preciso (ex.: "Reentrância em \`withdraw\`", "Controle de acesso ausente em \`setFee\`").
|
| 74 |
+
- **description**: Descreva (a) o comportamento **esperado** pelo protocolo, (b) o comportamento **observado** no código vulnerável, e (c) o impacto concreto se explorado. Mínimo 3 frases, máximo 5.
|
| 75 |
+
- **exploit_scenario**: Descreva um cenário concreto e passo a passo de como um atacante exploraria a vulnerabilidade.
|
| 76 |
+
- **recommendation**: Correção específica e acionável com referência ao padrão ou mecanismo correto (ex.: "Aplicar checks-effects-interactions: mover \`balances[msg.sender] -= amount\` para antes da chamada externa").
|
| 77 |
+
- **severity**: Exatamente um de: \`"high"\` (perda direta de fundos ou tomada de controle do contrato), \`"medium"\` (risco indireto ou condicional), \`"low"\` (problema de boas práticas, sem risco financeiro imediato).
|
| 78 |
+
- **location**: Função e/ou número de linha onde o problema ocorre.
|
| 79 |
+
- **codeSnippet**: O trecho exato e completo do código vulnerável, copiado literalmente do código-fonte. **Proibido** usar reticências (\`...\`), omissões, pseudocódigo ou paráfrases. Inclua as linhas exatas conforme aparecem no arquivo, com indentação original preservada. Se o snippet for maior que 40 linhas, inclua o intervalo completo sem cortes.
|
| 80 |
+
|
| 81 |
+
## Regra de completude
|
| 82 |
+
|
| 83 |
+
Reporte vulnerabilidades mesmo que não sejam imediatamente exploráveis. Vulnerabilidades podem ser de segurança, inconsistências de design, desalinhamentos econômicos ou falhas de observabilidade. Se nenhuma vulnerabilidade for encontrada, retorne um array vazio.`;
|
| 84 |
+
|
| 85 |
+
export const REFINE_VULNERABILITIES_PROMPT = `Você é um auditor sênior de segurança de smart contracts refinando seus próprios achados com base no feedback de um revisor especialista independente.
|
| 86 |
+
|
| 87 |
+
Na iteração anterior, você analisou um contrato Solidity e gerou uma lista de vulnerabilidades candidatas. Um revisor especialista avaliou cada achado e forneceu: veredicto (verdadeiro ou falso positivo), análise técnica detalhada, nível de confiança e caminhos de exploit ou razões de bloqueio.
|
| 88 |
+
|
| 89 |
+
**Sua tarefa**: produzir uma lista final e refinada de vulnerabilidades incorporando o feedback do revisor.
|
| 90 |
+
|
| 91 |
+
## Regras de refinamento
|
| 92 |
+
|
| 93 |
+
1. **Falso positivo com confiança ≥ 80%**: remova o achado sem exceção.
|
| 94 |
+
2. **Falso positivo com confiança < 80%**: reavalie com base na análise do revisor. Mantenha apenas se encontrar evidência nova ou argumento técnico que o revisor não considerou — e reflita isso na descrição.
|
| 95 |
+
3. **Verdadeiro positivo**: mantenha o achado. Incorpore melhorias sugeridas pelo revisor (descrição mais precisa, snippet mais completo, recomendação mais específica, caminhos de exploit detalhados).
|
| 96 |
+
4. **Novos achados**: se o revisor apontou superfícies de ataque não cobertas em seus achados originais, investigue o código-fonte e adicione novos achados para elas.
|
| 97 |
+
5. Não adicione achados que não sejam suportados pelo código-fonte ou pelo feedback do revisor.
|
| 98 |
+
|
| 99 |
+
## Formato de saída
|
| 100 |
+
|
| 101 |
+
Idêntico ao da análise inicial. Para cada vulnerabilidade:
|
| 102 |
+
- **title**: nome curto e preciso
|
| 103 |
+
- **description**: (a) comportamento esperado, (b) comportamento observado, (c) impacto concreto — mínimo 3 frases, máximo 5
|
| 104 |
+
- **recommendation**: correção específica e acionável com referência ao padrão correto
|
| 105 |
+
- **severity**: \`"high"\` / \`"medium"\` / \`"low"\`
|
| 106 |
+
- **codeSnippet**: trecho exato e completo copiado literalmente do código-fonte, sem omissões, reticências ou pseudocódigo
|
| 107 |
+
|
| 108 |
+
Se nenhuma vulnerabilidade restar após o refinamento, retorne um array vazio.`;
|
| 109 |
+
|
| 110 |
+
export const JUDGE_FINDINGS_PROMPT = `Você é um revisor rigoroso de segurança de smart contracts com profundo conhecimento em Solidity, execução EVM e design de protocolos. Avalie cada vulnerabilidade candidata submetida pelo auditor e determine se é um verdadeiro positivo ou um falso positivo.
|
| 111 |
+
|
| 112 |
+
Para cada achado, forneça OBRIGATORIAMENTE todos os campos abaixo:
|
| 113 |
+
|
| 114 |
+
- **review**: Análise técnica detalhada (3 a 6 frases) explicando o veredicto. Referencie: (a) o código específico envolvido, (b) invariantes ou premissas do protocolo que confirmam ou bloqueiam o exploit, (c) pré-condições necessárias para exploração, (d) controles mitigadores existentes que o auditor pode ter ignorado. Seja preciso — cite nomes de funções, variáveis e valores.
|
| 115 |
+
- **isFalsePositive**: \`true\` se o achado NÃO for explorável na prática; \`false\` se for uma vulnerabilidade real.
|
| 116 |
+
- **confidence**: Inteiro de 0 a 100 refletindo sua certeza no veredicto. Use < 60 apenas quando existir ambiguidade genuína no código.
|
| 117 |
+
- **exploitablePaths**: Retorne uma array de strings.
|
| 118 |
+
- Se verdadeiro positivo (\`isFalsePositive: false\`): forneça 1 a 3 caminhos concretos de exploit, cada um com passos numerados, entradas realistas e estado do contrato antes/depois. Ex.: ["1. Atacante chama flashLoan(500k USDC). 2. No callback, chama deposit() inflando reserves. 3. Chama withdraw() com preço manipulado. 4. Lucra 50k USDC. Estado: reserves inflado temporariamente, totalShares inalterado."].
|
| 119 |
+
- Se falso positivo (\`isFalsePositive: true\`): forneça o raciocínio exato que bloqueia cada caminho de exploit tentado pelo auditor.
|
| 120 |
+
|
| 121 |
+
## Critérios para falso positivo (aplique com rigor — não seja permissivo)
|
| 122 |
+
|
| 123 |
+
1. O caminho de exploit é bloqueado por controle de acesso verificável no código.
|
| 124 |
+
2. A vulnerabilidade já é totalmente mitigada por outro mecanismo no código (ex.: nonReentrant, onlyOwner, require com validação suficiente).
|
| 125 |
+
3. A condição necessária para o exploit é impossível ou economicamente inviável dado o modelo do protocolo (ex.: requer ser o próprio contrato, ou lucro < custo de gas em qualquer cenário realista).
|
| 126 |
+
4. O comportamento é explicitamente documentado como intencional nas premissas de design do protocolo.
|
| 127 |
+
|
| 128 |
+
## Critérios para verdadeiro positivo
|
| 129 |
+
- Existe pelo menos um caminho de exploit concreto e realista que viola uma invariante ou permite extração de valor não autorizada.
|
| 130 |
+
- Não exige condições impossíveis nem assume acesso privilegiado não disponível ao atacante.
|
| 131 |
+
- Inclui qualquer discrepância entre a implementação e a intenção, premissas ou objetivos documentados — mesmo que o contrato opere sem erros em runtime. Isso abrange problemas de segurança, inconsistências de design, desalinhamentos econômicos e falhas de observabilidade, independentemente de exploitabilidade direta.`;
|
src/agents/auditor/state.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
| 1 |
import { StateSchema } from "@langchain/langgraph";
|
| 2 |
import { z } from "zod";
|
| 3 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
export const CandidateFindingSchema = z.object({
|
| 5 |
title: z.string(),
|
| 6 |
description: z.string(),
|
|
@@ -34,6 +40,7 @@ export const AuditorState = new StateSchema({
|
|
| 34 |
scope: z.array(z.string()).default([]),
|
| 35 |
docs: z.array(z.string()).default([]),
|
| 36 |
fileTree: z.string().default(""),
|
|
|
|
| 37 |
repoContext: z.string().default(""),
|
| 38 |
candidateFindings: z.array(LocatedFindingSchema).default([]),
|
| 39 |
judgeReviews: z.array(JudgeReviewSchema).default([]),
|
|
|
|
| 1 |
import { StateSchema } from "@langchain/langgraph";
|
| 2 |
import { z } from "zod";
|
| 3 |
|
| 4 |
+
export const FileRankingSchema = z.object({
|
| 5 |
+
filePath: z.string(),
|
| 6 |
+
importance: z.number().int().min(1).max(5),
|
| 7 |
+
reasoning: z.string(),
|
| 8 |
+
});
|
| 9 |
+
|
| 10 |
export const CandidateFindingSchema = z.object({
|
| 11 |
title: z.string(),
|
| 12 |
description: z.string(),
|
|
|
|
| 40 |
scope: z.array(z.string()).default([]),
|
| 41 |
docs: z.array(z.string()).default([]),
|
| 42 |
fileTree: z.string().default(""),
|
| 43 |
+
fileRankings: z.array(FileRankingSchema).default([]),
|
| 44 |
repoContext: z.string().default(""),
|
| 45 |
candidateFindings: z.array(LocatedFindingSchema).default([]),
|
| 46 |
judgeReviews: z.array(JudgeReviewSchema).default([]),
|
src/agents/auditor/tools/{repo-tree-tool.ts → repo-tree/tool.ts}
RENAMED
|
@@ -4,7 +4,7 @@ import path from "node:path";
|
|
| 4 |
import { tool } from "langchain";
|
| 5 |
import { z } from "zod";
|
| 6 |
|
| 7 |
-
import { DOC_BASENAMES, DOC_EXTS, MAX_DEPTH, SKIP_DIRS, SOL_EXT, SOL_TEST_SUFFIXES } from "../config.ts";
|
| 8 |
|
| 9 |
const CONFIG_FILES = new Set([
|
| 10 |
"foundry.toml",
|
|
@@ -90,14 +90,11 @@ export const buildRepoTree = (repoPath: string): string => {
|
|
| 90 |
return `${repoName}/\n${renderTree(nodes, "")}`;
|
| 91 |
};
|
| 92 |
|
| 93 |
-
export const repoTreeTool = tool(
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
}),
|
| 102 |
-
},
|
| 103 |
-
);
|
|
|
|
| 4 |
import { tool } from "langchain";
|
| 5 |
import { z } from "zod";
|
| 6 |
|
| 7 |
+
import { DOC_BASENAMES, DOC_EXTS, MAX_DEPTH, SKIP_DIRS, SOL_EXT, SOL_TEST_SUFFIXES } from "../../config.ts";
|
| 8 |
|
| 9 |
const CONFIG_FILES = new Set([
|
| 10 |
"foundry.toml",
|
|
|
|
| 90 |
return `${repoName}/\n${renderTree(nodes, "")}`;
|
| 91 |
};
|
| 92 |
|
| 93 |
+
export const repoTreeTool = tool(async ({ repoPath }) => buildRepoTree(repoPath), {
|
| 94 |
+
name: "repo_tree",
|
| 95 |
+
description:
|
| 96 |
+
"Walk a repository and return a file-system tree of relevant files tagged by kind: [sol] for auditable Solidity contracts, [test] for Solidity test files, [doc] for documentation, and [config] for project config files. Use this during Define Scope to understand repository layout before selecting which files to audit.",
|
| 97 |
+
schema: z.object({
|
| 98 |
+
repoPath: z.string().describe("Absolute path to the repository root."),
|
| 99 |
+
}),
|
| 100 |
+
});
|
|
|
|
|
|
|
|
|
src/agents/auditor/tools/solidity-analyzer-tool.ts
DELETED
|
@@ -1,567 +0,0 @@
|
|
| 1 |
-
import { parse, visit } from "@solidity-parser/parser";
|
| 2 |
-
import { tool } from "langchain";
|
| 3 |
-
import { z } from "zod";
|
| 4 |
-
|
| 5 |
-
const ASSIGNMENT_OPS = new Set(["=", "+=", "-=", "*=", "/=", "%=", "|=", "&=", "^=", "<<=", ">>=", "**="]);
|
| 6 |
-
const BUILTIN_NAMESPACES = new Set(["abi", "block", "msg", "tx", "bytes", "string", "type"]);
|
| 7 |
-
|
| 8 |
-
interface StateVar {
|
| 9 |
-
name: string;
|
| 10 |
-
type: string;
|
| 11 |
-
visibility: string;
|
| 12 |
-
constant: boolean;
|
| 13 |
-
immutable: boolean;
|
| 14 |
-
}
|
| 15 |
-
|
| 16 |
-
interface EventDef {
|
| 17 |
-
name: string;
|
| 18 |
-
params: string[];
|
| 19 |
-
anonymous: boolean;
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
interface ModifierDef {
|
| 23 |
-
name: string;
|
| 24 |
-
params: string[];
|
| 25 |
-
}
|
| 26 |
-
|
| 27 |
-
interface FunctionDef {
|
| 28 |
-
name: string;
|
| 29 |
-
isConstructor: boolean;
|
| 30 |
-
isReceive: boolean;
|
| 31 |
-
isFallback: boolean;
|
| 32 |
-
visibility: string;
|
| 33 |
-
mutability: string;
|
| 34 |
-
params: string[];
|
| 35 |
-
returns: string[];
|
| 36 |
-
modifiers: string[];
|
| 37 |
-
internalCalls: string[];
|
| 38 |
-
externalCalls: string[];
|
| 39 |
-
stateReads: string[];
|
| 40 |
-
stateWrites: string[];
|
| 41 |
-
}
|
| 42 |
-
|
| 43 |
-
interface ContractAnalysis {
|
| 44 |
-
name: string;
|
| 45 |
-
kind: string;
|
| 46 |
-
baseContracts: string[];
|
| 47 |
-
usingFor: string[];
|
| 48 |
-
stateVars: StateVar[];
|
| 49 |
-
events: EventDef[];
|
| 50 |
-
modifiers: ModifierDef[];
|
| 51 |
-
functions: FunctionDef[];
|
| 52 |
-
}
|
| 53 |
-
|
| 54 |
-
const typeToString = (node: any): string => {
|
| 55 |
-
if (!node) return "unknown";
|
| 56 |
-
|
| 57 |
-
switch (node.type) {
|
| 58 |
-
case "ElementaryTypeName":
|
| 59 |
-
return node.name as string;
|
| 60 |
-
case "UserDefinedTypeName":
|
| 61 |
-
return (node.namePath ?? node.name) as string;
|
| 62 |
-
case "ArrayTypeName":
|
| 63 |
-
return `${typeToString(node.baseTypeName)}[${node.length ?? ""}]`;
|
| 64 |
-
case "Mapping":
|
| 65 |
-
return `mapping(${typeToString(node.keyType)} => ${typeToString(node.valueType)})`;
|
| 66 |
-
case "FunctionTypeName":
|
| 67 |
-
return "function";
|
| 68 |
-
default:
|
| 69 |
-
return "unknown";
|
| 70 |
-
}
|
| 71 |
-
};
|
| 72 |
-
|
| 73 |
-
const paramToString = (p: any) => {
|
| 74 |
-
if (!p) return "?";
|
| 75 |
-
const type = typeToString(p.typeName);
|
| 76 |
-
return p.name ? `${type} ${p.name}` : type;
|
| 77 |
-
};
|
| 78 |
-
|
| 79 |
-
const collectLHSRoots = (node: any, targets: Set<string>) => {
|
| 80 |
-
if (!node) return;
|
| 81 |
-
switch (node.type) {
|
| 82 |
-
case "Identifier":
|
| 83 |
-
targets.add(node.name as string);
|
| 84 |
-
break;
|
| 85 |
-
case "MemberAccess":
|
| 86 |
-
collectLHSRoots(node.expression, targets);
|
| 87 |
-
break;
|
| 88 |
-
case "IndexAccess":
|
| 89 |
-
collectLHSRoots(node.base, targets);
|
| 90 |
-
break;
|
| 91 |
-
case "TupleExpression":
|
| 92 |
-
for (const c of node.components ?? []) collectLHSRoots(c, targets);
|
| 93 |
-
break;
|
| 94 |
-
}
|
| 95 |
-
};
|
| 96 |
-
|
| 97 |
-
const analyzeFunction = (funcNode: any, stateVarNames: Set<string>) => {
|
| 98 |
-
const internalCalls = new Set<string>();
|
| 99 |
-
const externalCalls = new Set<string>();
|
| 100 |
-
const writeTargets = new Set<string>();
|
| 101 |
-
const allStateAccesses = new Set<string>();
|
| 102 |
-
const localVars = new Set<string>();
|
| 103 |
-
|
| 104 |
-
if (!funcNode.body) {
|
| 105 |
-
return { internalCalls: [], externalCalls: [], stateReads: [], stateWrites: [] };
|
| 106 |
-
}
|
| 107 |
-
|
| 108 |
-
// Collect function params and return params as locals so they don't shadow state vars
|
| 109 |
-
for (const p of funcNode.parameters ?? []) {
|
| 110 |
-
if (p?.name) localVars.add(p.name as string);
|
| 111 |
-
}
|
| 112 |
-
for (const p of funcNode.returnParameters ?? []) {
|
| 113 |
-
if (p?.name) localVars.add(p.name as string);
|
| 114 |
-
}
|
| 115 |
-
|
| 116 |
-
// Collect local variable declarations
|
| 117 |
-
visit(funcNode.body, {
|
| 118 |
-
VariableDeclarationStatement: (node: any) => {
|
| 119 |
-
for (const v of node.variables ?? []) {
|
| 120 |
-
if (v?.name) localVars.add(v.name as string);
|
| 121 |
-
}
|
| 122 |
-
},
|
| 123 |
-
});
|
| 124 |
-
|
| 125 |
-
const effectiveStateVars = new Set([...stateVarNames].filter((v) => !localVars.has(v)));
|
| 126 |
-
|
| 127 |
-
// Collect write targets from assignment LHS, unary mutations, and delete
|
| 128 |
-
visit(funcNode.body, {
|
| 129 |
-
ExpressionStatement: (node: any) => {
|
| 130 |
-
const expr = node.expression;
|
| 131 |
-
if (expr?.type === "BinaryOperation" && ASSIGNMENT_OPS.has(expr.operator as string)) {
|
| 132 |
-
collectLHSRoots(expr.left, writeTargets);
|
| 133 |
-
}
|
| 134 |
-
// Handle ++, --, and delete — all work on any lvalue (arr[i]++, delete s.field, etc.)
|
| 135 |
-
if (
|
| 136 |
-
expr?.type === "UnaryOperation" &&
|
| 137 |
-
(expr.operator === "++" || expr.operator === "--" || expr.operator === "delete")
|
| 138 |
-
) {
|
| 139 |
-
collectLHSRoots(expr.subExpression, writeTargets);
|
| 140 |
-
}
|
| 141 |
-
},
|
| 142 |
-
});
|
| 143 |
-
|
| 144 |
-
// Collect calls and state-var identifier accesses
|
| 145 |
-
visit(funcNode.body, {
|
| 146 |
-
FunctionCall: (node: any) => {
|
| 147 |
-
const expr = node.expression;
|
| 148 |
-
if (expr?.type === "Identifier") {
|
| 149 |
-
internalCalls.add(expr.name as string);
|
| 150 |
-
} else if (expr?.type === "MemberAccess") {
|
| 151 |
-
const base = expr.expression;
|
| 152 |
-
if (base?.type === "Identifier" && (base.name === "this" || base.name === "super")) {
|
| 153 |
-
internalCalls.add(expr.memberName as string);
|
| 154 |
-
} else if (base?.type === "Identifier" && BUILTIN_NAMESPACES.has(base.name as string)) {
|
| 155 |
-
// abi.encode, block.xxx, msg.xxx, etc. — not external calls
|
| 156 |
-
} else {
|
| 157 |
-
const baseStr = base?.type === "Identifier" ? (base.name as string) : "<expr>";
|
| 158 |
-
externalCalls.add(`${baseStr}.${expr.memberName as string}`);
|
| 159 |
-
}
|
| 160 |
-
}
|
| 161 |
-
},
|
| 162 |
-
Identifier: (node: any) => {
|
| 163 |
-
if (effectiveStateVars.has(node.name as string)) {
|
| 164 |
-
allStateAccesses.add(node.name as string);
|
| 165 |
-
}
|
| 166 |
-
},
|
| 167 |
-
});
|
| 168 |
-
|
| 169 |
-
const stateWrites = [...allStateAccesses].filter((v) => writeTargets.has(v));
|
| 170 |
-
// A var can be in both — e.g. x = x + 1 is both a read and a write.
|
| 171 |
-
const stateReads = [...allStateAccesses];
|
| 172 |
-
|
| 173 |
-
return {
|
| 174 |
-
internalCalls: [...internalCalls],
|
| 175 |
-
externalCalls: [...externalCalls],
|
| 176 |
-
stateReads,
|
| 177 |
-
stateWrites,
|
| 178 |
-
};
|
| 179 |
-
};
|
| 180 |
-
|
| 181 |
-
const hasCycle = (start: string, current: string, callMap: Map<string, string[]>, visited: Set<string>) => {
|
| 182 |
-
for (const callee of callMap.get(current) ?? []) {
|
| 183 |
-
if (callee === start) return true;
|
| 184 |
-
if (!visited.has(callee)) {
|
| 185 |
-
visited.add(callee);
|
| 186 |
-
if (hasCycle(start, callee, callMap, visited)) return true;
|
| 187 |
-
}
|
| 188 |
-
}
|
| 189 |
-
|
| 190 |
-
return false;
|
| 191 |
-
};
|
| 192 |
-
|
| 193 |
-
const fnLabel = (fn: FunctionDef) => {
|
| 194 |
-
if (fn.isConstructor) return "constructor";
|
| 195 |
-
if (fn.isReceive) return "receive";
|
| 196 |
-
if (fn.isFallback) return "fallback";
|
| 197 |
-
return fn.name;
|
| 198 |
-
};
|
| 199 |
-
|
| 200 |
-
const generateShortMarkdown = (imports: string[], contracts: ContractAnalysis[]) => {
|
| 201 |
-
const lines: string[] = [];
|
| 202 |
-
lines.push("# Solidity Analysis\n");
|
| 203 |
-
|
| 204 |
-
if (imports.length > 0) {
|
| 205 |
-
lines.push(`**Imports:** ${imports.map((i) => `\`${i}\``).join(", ")}\n`);
|
| 206 |
-
}
|
| 207 |
-
|
| 208 |
-
for (const contract of contracts) {
|
| 209 |
-
const inheritance =
|
| 210 |
-
contract.baseContracts.length > 0 ? ` : ${contract.baseContracts.map((b) => `\`${b}\``).join(", ")}` : "";
|
| 211 |
-
lines.push(`---\n\n## \`${contract.name}\` (${contract.kind})${inheritance}\n`);
|
| 212 |
-
|
| 213 |
-
// State variables — one line, name:type
|
| 214 |
-
if (contract.stateVars.length > 0) {
|
| 215 |
-
const vars = contract.stateVars.map((v) => {
|
| 216 |
-
const flags = [v.constant && "constant", v.immutable && "immutable"].filter(Boolean);
|
| 217 |
-
const suffix = flags.length > 0 ? `, ${flags.join(", ")}` : "";
|
| 218 |
-
return `\`${v.name}: ${v.type}\` (${v.visibility}${suffix})`;
|
| 219 |
-
});
|
| 220 |
-
lines.push(`**State:** ${vars.join(", ")}\n`);
|
| 221 |
-
}
|
| 222 |
-
|
| 223 |
-
// Modifiers — names only
|
| 224 |
-
if (contract.modifiers.length > 0) {
|
| 225 |
-
const mods = contract.modifiers.map(
|
| 226 |
-
(m) => `\`${m.name}${m.params.length > 0 ? `(${m.params.join(", ")})` : ""}\``,
|
| 227 |
-
);
|
| 228 |
-
lines.push(`**Modifiers:** ${mods.join(", ")}\n`);
|
| 229 |
-
}
|
| 230 |
-
|
| 231 |
-
// Events — name + params, one line each
|
| 232 |
-
if (contract.events.length > 0) {
|
| 233 |
-
const evts = contract.events.map((e) => `\`${e.name}(${e.params.join(", ")})\``);
|
| 234 |
-
lines.push(`**Events:** ${evts.join(", ")}\n`);
|
| 235 |
-
}
|
| 236 |
-
|
| 237 |
-
// Function list — compact, one line per function
|
| 238 |
-
if (contract.functions.length > 0) {
|
| 239 |
-
lines.push("**Functions:**");
|
| 240 |
-
for (const fn of contract.functions) {
|
| 241 |
-
const label = fnLabel(fn);
|
| 242 |
-
const params = fn.params.join(", ");
|
| 243 |
-
const ret = fn.returns.length > 0 ? ` → ${fn.returns.join(", ")}` : "";
|
| 244 |
-
const mods = fn.modifiers.length > 0 ? ` [${fn.modifiers.join(", ")}]` : "";
|
| 245 |
-
lines.push(`- \`${label}(${params})${ret}\` — ${fn.visibility} ${fn.mutability}${mods}`);
|
| 246 |
-
}
|
| 247 |
-
lines.push("");
|
| 248 |
-
}
|
| 249 |
-
|
| 250 |
-
// External calls — only functions that make them
|
| 251 |
-
const externalFuncs = contract.functions.filter((f) => f.externalCalls.length > 0);
|
| 252 |
-
if (externalFuncs.length > 0) {
|
| 253 |
-
lines.push("**External Calls:**");
|
| 254 |
-
for (const fn of externalFuncs) {
|
| 255 |
-
lines.push(`- \`${fnLabel(fn)}\`: ${fn.externalCalls.map((c) => `\`${c}\``).join(", ")}`);
|
| 256 |
-
}
|
| 257 |
-
lines.push("");
|
| 258 |
-
}
|
| 259 |
-
}
|
| 260 |
-
|
| 261 |
-
return lines.join("\n");
|
| 262 |
-
};
|
| 263 |
-
|
| 264 |
-
const generateMarkdown = (imports: string[], contracts: ContractAnalysis[]) => {
|
| 265 |
-
const lines: string[] = [];
|
| 266 |
-
lines.push("# Solidity Contract Analysis\n");
|
| 267 |
-
|
| 268 |
-
// Imports
|
| 269 |
-
lines.push("## Imports\n");
|
| 270 |
-
if (imports.length === 0) {
|
| 271 |
-
lines.push("_No imports._\n");
|
| 272 |
-
} else {
|
| 273 |
-
for (const imp of imports) lines.push(`- \`${imp}\``);
|
| 274 |
-
lines.push("");
|
| 275 |
-
}
|
| 276 |
-
|
| 277 |
-
for (const contract of contracts) {
|
| 278 |
-
const kindLabel = contract.kind.charAt(0).toUpperCase() + contract.kind.slice(1);
|
| 279 |
-
lines.push(`---\n\n## ${kindLabel}: \`${contract.name}\`\n`);
|
| 280 |
-
|
| 281 |
-
// Inheritance
|
| 282 |
-
lines.push("### Inheritance\n");
|
| 283 |
-
if (contract.baseContracts.length === 0) {
|
| 284 |
-
lines.push("_None._\n");
|
| 285 |
-
} else {
|
| 286 |
-
for (const base of contract.baseContracts) lines.push(`- \`${base}\``);
|
| 287 |
-
lines.push("");
|
| 288 |
-
}
|
| 289 |
-
|
| 290 |
-
// Using For
|
| 291 |
-
if (contract.usingFor.length > 0) {
|
| 292 |
-
lines.push("### Using For\n");
|
| 293 |
-
for (const u of contract.usingFor) lines.push(`- ${u}`);
|
| 294 |
-
lines.push("");
|
| 295 |
-
}
|
| 296 |
-
|
| 297 |
-
// Storage layout
|
| 298 |
-
lines.push("### Storage Layout (State Variables)\n");
|
| 299 |
-
if (contract.stateVars.length === 0) {
|
| 300 |
-
lines.push("_No state variables._\n");
|
| 301 |
-
} else {
|
| 302 |
-
lines.push("| Slot | Name | Type | Visibility | Flags |");
|
| 303 |
-
lines.push("|------|------|------|------------|-------|");
|
| 304 |
-
contract.stateVars.forEach((v, i) => {
|
| 305 |
-
const flags = [v.constant && "constant", v.immutable && "immutable"].filter(Boolean).join(", ");
|
| 306 |
-
lines.push(`| ${i} | \`${v.name}\` | \`${v.type}\` | ${v.visibility} | ${flags} |`);
|
| 307 |
-
});
|
| 308 |
-
lines.push("");
|
| 309 |
-
}
|
| 310 |
-
|
| 311 |
-
// Events
|
| 312 |
-
lines.push("### Events\n");
|
| 313 |
-
if (contract.events.length === 0) {
|
| 314 |
-
lines.push("_No events._\n");
|
| 315 |
-
} else {
|
| 316 |
-
for (const evt of contract.events) {
|
| 317 |
-
const params = evt.params.join(", ");
|
| 318 |
-
lines.push(`- **\`${evt.name}\`**\`(${params})\`${evt.anonymous ? " _(anonymous)_" : ""}`);
|
| 319 |
-
}
|
| 320 |
-
lines.push("");
|
| 321 |
-
}
|
| 322 |
-
|
| 323 |
-
// Modifiers
|
| 324 |
-
lines.push("### Modifiers\n");
|
| 325 |
-
if (contract.modifiers.length === 0) {
|
| 326 |
-
lines.push("_No modifiers._\n");
|
| 327 |
-
} else {
|
| 328 |
-
for (const mod of contract.modifiers) {
|
| 329 |
-
lines.push(`- **\`${mod.name}\`**\`(${mod.params.join(", ")})\``);
|
| 330 |
-
}
|
| 331 |
-
lines.push("");
|
| 332 |
-
}
|
| 333 |
-
|
| 334 |
-
// Function list
|
| 335 |
-
lines.push("### Function List\n");
|
| 336 |
-
if (contract.functions.length === 0) {
|
| 337 |
-
lines.push("_No functions._\n");
|
| 338 |
-
} else {
|
| 339 |
-
lines.push("| Name | Visibility | Mutability | Parameters | Returns | Modifiers |");
|
| 340 |
-
lines.push("|------|------------|------------|------------|---------|-----------|");
|
| 341 |
-
for (const fn of contract.functions) {
|
| 342 |
-
lines.push(
|
| 343 |
-
`| \`${fnLabel(fn)}\` | ${fn.visibility} | ${fn.mutability} | \`${fn.params.join(", ")}\` | \`${fn.returns.join(", ")}\` | ${fn.modifiers.join(", ")} |`,
|
| 344 |
-
);
|
| 345 |
-
}
|
| 346 |
-
lines.push("");
|
| 347 |
-
}
|
| 348 |
-
|
| 349 |
-
// Call graph
|
| 350 |
-
lines.push("### Call Graph\n");
|
| 351 |
-
const hasCalls = contract.functions.some((f) => f.internalCalls.length > 0 || f.externalCalls.length > 0);
|
| 352 |
-
if (!hasCalls) {
|
| 353 |
-
lines.push("_No function calls detected._\n");
|
| 354 |
-
} else {
|
| 355 |
-
for (const fn of contract.functions) {
|
| 356 |
-
if (fn.internalCalls.length === 0 && fn.externalCalls.length === 0) continue;
|
| 357 |
-
lines.push(`**\`${fnLabel(fn)}\`**`);
|
| 358 |
-
for (const call of fn.internalCalls) lines.push(` - → \`${call}\` _(internal)_`);
|
| 359 |
-
for (const call of fn.externalCalls) lines.push(` - → \`${call}\` _(external)_`);
|
| 360 |
-
}
|
| 361 |
-
lines.push("");
|
| 362 |
-
}
|
| 363 |
-
|
| 364 |
-
// External calls
|
| 365 |
-
lines.push("### External Calls\n");
|
| 366 |
-
const externalFuncs = contract.functions.filter((f) => f.externalCalls.length > 0);
|
| 367 |
-
if (externalFuncs.length === 0) {
|
| 368 |
-
lines.push("_No external calls detected._\n");
|
| 369 |
-
} else {
|
| 370 |
-
for (const fn of externalFuncs) {
|
| 371 |
-
lines.push(`**\`${fnLabel(fn)}\`**`);
|
| 372 |
-
for (const call of fn.externalCalls) lines.push(` - \`${call}\``);
|
| 373 |
-
}
|
| 374 |
-
lines.push("");
|
| 375 |
-
}
|
| 376 |
-
|
| 377 |
-
// Internal recursion
|
| 378 |
-
lines.push("### Internal Recursion\n");
|
| 379 |
-
const callMap = new Map(contract.functions.map((f) => [fnLabel(f), f.internalCalls]));
|
| 380 |
-
const recursiveFns = contract.functions.filter((fn) => hasCycle(fnLabel(fn), fnLabel(fn), callMap, new Set()));
|
| 381 |
-
if (recursiveFns.length === 0) {
|
| 382 |
-
lines.push("_No recursive functions detected._\n");
|
| 383 |
-
} else {
|
| 384 |
-
for (const fn of recursiveFns) lines.push(`- **\`${fnLabel(fn)}\`** is recursive`);
|
| 385 |
-
lines.push("");
|
| 386 |
-
}
|
| 387 |
-
|
| 388 |
-
// State variable touchpoints
|
| 389 |
-
lines.push("### State Variable Touchpoints\n");
|
| 390 |
-
const touchedFns = contract.functions.filter((f) => f.stateReads.length > 0 || f.stateWrites.length > 0);
|
| 391 |
-
if (touchedFns.length === 0) {
|
| 392 |
-
lines.push("_No state variable accesses detected._\n");
|
| 393 |
-
} else {
|
| 394 |
-
lines.push("| Function | Reads | Writes |");
|
| 395 |
-
lines.push("|----------|-------|--------|");
|
| 396 |
-
for (const fn of touchedFns) {
|
| 397 |
-
const reads = fn.stateReads.map((r) => `\`${r}\``).join(", ");
|
| 398 |
-
const writes = fn.stateWrites.map((w) => `\`${w}\``).join(", ");
|
| 399 |
-
lines.push(`| \`${fnLabel(fn)}\` | ${reads} | ${writes} |`);
|
| 400 |
-
}
|
| 401 |
-
lines.push("");
|
| 402 |
-
}
|
| 403 |
-
}
|
| 404 |
-
|
| 405 |
-
// External dependencies summary
|
| 406 |
-
lines.push("---\n\n## External Dependencies\n");
|
| 407 |
-
|
| 408 |
-
lines.push("### Import Paths\n");
|
| 409 |
-
if (imports.length === 0) {
|
| 410 |
-
lines.push("_No imports._\n");
|
| 411 |
-
} else {
|
| 412 |
-
for (const imp of imports) lines.push(`- \`${imp}\``);
|
| 413 |
-
lines.push("");
|
| 414 |
-
}
|
| 415 |
-
|
| 416 |
-
const externalTargets = new Set<string>();
|
| 417 |
-
for (const contract of contracts) {
|
| 418 |
-
for (const fn of contract.functions) {
|
| 419 |
-
for (const call of fn.externalCalls) {
|
| 420 |
-
const target = call.split(".")[0];
|
| 421 |
-
if (target && target !== "<expr>") externalTargets.add(target);
|
| 422 |
-
}
|
| 423 |
-
}
|
| 424 |
-
}
|
| 425 |
-
|
| 426 |
-
lines.push("### External Contract Interactions\n");
|
| 427 |
-
if (externalTargets.size === 0) {
|
| 428 |
-
lines.push("_No external contract interactions detected._\n");
|
| 429 |
-
} else {
|
| 430 |
-
for (const dep of externalTargets) lines.push(`- \`${dep}\``);
|
| 431 |
-
lines.push("");
|
| 432 |
-
}
|
| 433 |
-
|
| 434 |
-
return lines.join("\n");
|
| 435 |
-
};
|
| 436 |
-
|
| 437 |
-
export const analyzeSolidityFile = async (soliditySource: string, mode: "full" | "short") => {
|
| 438 |
-
let ast: any;
|
| 439 |
-
|
| 440 |
-
try {
|
| 441 |
-
ast = parse(soliditySource, { tolerant: true, loc: true, range: true });
|
| 442 |
-
} catch (e: any) {
|
| 443 |
-
return `# Parse Error\n\nFailed to parse Solidity source: ${e.message as string}`;
|
| 444 |
-
}
|
| 445 |
-
|
| 446 |
-
const imports: string[] = [];
|
| 447 |
-
const contracts: ContractAnalysis[] = [];
|
| 448 |
-
|
| 449 |
-
for (const node of ast.children ?? []) {
|
| 450 |
-
if (node.type === "ImportDirective") {
|
| 451 |
-
imports.push(node.path as string);
|
| 452 |
-
}
|
| 453 |
-
}
|
| 454 |
-
|
| 455 |
-
for (const node of ast.children ?? []) {
|
| 456 |
-
if (node.type !== "ContractDefinition") continue;
|
| 457 |
-
|
| 458 |
-
const contract: ContractAnalysis = {
|
| 459 |
-
name: node.name as string,
|
| 460 |
-
kind: (node.kind as string) ?? "contract",
|
| 461 |
-
baseContracts: (node.baseContracts ?? []).map(
|
| 462 |
-
(bc: any) => (bc.baseName?.namePath ?? bc.baseName?.name ?? "?") as string,
|
| 463 |
-
),
|
| 464 |
-
usingFor: [],
|
| 465 |
-
stateVars: [],
|
| 466 |
-
events: [],
|
| 467 |
-
modifiers: [],
|
| 468 |
-
functions: [],
|
| 469 |
-
};
|
| 470 |
-
|
| 471 |
-
const stateVarNames = new Set<string>();
|
| 472 |
-
|
| 473 |
-
for (const member of node.subNodes ?? []) {
|
| 474 |
-
switch (member.type) {
|
| 475 |
-
case "StateVariableDeclaration":
|
| 476 |
-
for (const v of member.variables ?? []) {
|
| 477 |
-
stateVarNames.add(v.name as string);
|
| 478 |
-
contract.stateVars.push({
|
| 479 |
-
name: v.name as string,
|
| 480 |
-
type: typeToString(v.typeName),
|
| 481 |
-
visibility: (v.visibility as string) ?? "internal",
|
| 482 |
-
constant: (v.isDeclaredConst as boolean) ?? false,
|
| 483 |
-
immutable: (v.isImmutable as boolean) ?? false,
|
| 484 |
-
});
|
| 485 |
-
}
|
| 486 |
-
break;
|
| 487 |
-
|
| 488 |
-
case "EventDefinition": {
|
| 489 |
-
const params = (member.parameters ?? []).map((p: any) => {
|
| 490 |
-
const indexed = p.isIndexed ? "indexed " : "";
|
| 491 |
-
const name = p.name ? ` ${p.name as string}` : "";
|
| 492 |
-
return `${indexed}${typeToString(p.typeName)}${name}`;
|
| 493 |
-
});
|
| 494 |
-
contract.events.push({
|
| 495 |
-
name: member.name as string,
|
| 496 |
-
params,
|
| 497 |
-
anonymous: (member.isAnonymous as boolean) ?? false,
|
| 498 |
-
});
|
| 499 |
-
break;
|
| 500 |
-
}
|
| 501 |
-
|
| 502 |
-
case "ModifierDefinition":
|
| 503 |
-
contract.modifiers.push({
|
| 504 |
-
name: member.name as string,
|
| 505 |
-
params: (member.parameters ?? []).map(paramToString),
|
| 506 |
-
});
|
| 507 |
-
break;
|
| 508 |
-
|
| 509 |
-
case "FunctionDefinition": {
|
| 510 |
-
const { internalCalls, externalCalls, stateReads, stateWrites } = analyzeFunction(member, stateVarNames);
|
| 511 |
-
contract.functions.push({
|
| 512 |
-
name: (member.name as string) ?? "",
|
| 513 |
-
isConstructor: (member.isConstructor as boolean) ?? false,
|
| 514 |
-
isReceive: (member.isReceiveEther as boolean) ?? false,
|
| 515 |
-
isFallback: (member.isFallback as boolean) ?? false,
|
| 516 |
-
visibility: (member.visibility as string) ?? "internal",
|
| 517 |
-
mutability: (member.stateMutability as string) ?? "nonpayable",
|
| 518 |
-
params: (member.parameters ?? []).map(paramToString),
|
| 519 |
-
returns: (member.returnParameters ?? []).map(paramToString),
|
| 520 |
-
modifiers: (member.modifiers ?? []).map((m: any) => m.name as string),
|
| 521 |
-
internalCalls,
|
| 522 |
-
externalCalls,
|
| 523 |
-
stateReads,
|
| 524 |
-
stateWrites,
|
| 525 |
-
});
|
| 526 |
-
break;
|
| 527 |
-
}
|
| 528 |
-
|
| 529 |
-
case "UsingForDeclaration": {
|
| 530 |
-
const forType = member.typeName ? typeToString(member.typeName) : "*";
|
| 531 |
-
if (member.libraryName) {
|
| 532 |
-
contract.usingFor.push(`\`${member.libraryName as string}\` for \`${forType}\``);
|
| 533 |
-
} else {
|
| 534 |
-
// New-style: using {fn1, fn2, ...} for T
|
| 535 |
-
const fns = (member.functions ?? [])
|
| 536 |
-
.map((f: any) => (f.typeName?.namePath ?? f.typeName?.name ?? f.path ?? "?") as string)
|
| 537 |
-
.join(", ");
|
| 538 |
-
contract.usingFor.push(`{${fns}} for \`${forType}\``);
|
| 539 |
-
}
|
| 540 |
-
break;
|
| 541 |
-
}
|
| 542 |
-
}
|
| 543 |
-
}
|
| 544 |
-
|
| 545 |
-
contracts.push(contract);
|
| 546 |
-
}
|
| 547 |
-
|
| 548 |
-
return mode === "short" ? generateShortMarkdown(imports, contracts) : generateMarkdown(imports, contracts);
|
| 549 |
-
};
|
| 550 |
-
|
| 551 |
-
export const solidityAnalyzerTool = tool(
|
| 552 |
-
async ({ solidityFile, mode }) => {
|
| 553 |
-
return analyzeSolidityFile(solidityFile, mode);
|
| 554 |
-
},
|
| 555 |
-
{
|
| 556 |
-
name: "solidity_analyzer",
|
| 557 |
-
description:
|
| 558 |
-
"Parse a Solidity source file and generate a markdown report. Use mode='short' for a compact token-efficient summary (imports, state, modifiers, events, function signatures, external calls). Use mode='full' for the complete report including storage layout table, call graph, recursion detection, state variable touchpoints, and external dependencies.",
|
| 559 |
-
schema: z.object({
|
| 560 |
-
solidityFile: z.string().describe("The full Solidity source code to analyze."),
|
| 561 |
-
mode: z
|
| 562 |
-
.enum(["full", "short"])
|
| 563 |
-
.default("full")
|
| 564 |
-
.describe("Report verbosity. 'short' saves tokens; 'full' provides the complete analysis."),
|
| 565 |
-
}),
|
| 566 |
-
},
|
| 567 |
-
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/agents/auditor/tools/solidity-analyzer/tool.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { parse } from "@solidity-parser/parser";
|
| 2 |
+
import { tool } from "langchain";
|
| 3 |
+
import { z } from "zod";
|
| 4 |
+
|
| 5 |
+
import {
|
| 6 |
+
analyzeFunction,
|
| 7 |
+
buildCommentBlocks,
|
| 8 |
+
type ContractAnalysis,
|
| 9 |
+
extractSolcVersion,
|
| 10 |
+
findCommentFor,
|
| 11 |
+
generateBriefMarkdown,
|
| 12 |
+
generateFullMarkdown,
|
| 13 |
+
paramToString,
|
| 14 |
+
type RenderOptions,
|
| 15 |
+
typeToString,
|
| 16 |
+
} from "./utils.ts";
|
| 17 |
+
|
| 18 |
+
export const analyzeSolidityFile = async (
|
| 19 |
+
soliditySource: string,
|
| 20 |
+
mode: "full" | "short",
|
| 21 |
+
filePath?: string,
|
| 22 |
+
importance?: number,
|
| 23 |
+
): Promise<string> => {
|
| 24 |
+
let ast: any;
|
| 25 |
+
try {
|
| 26 |
+
ast = parse(soliditySource, { tolerant: true, loc: true, range: true });
|
| 27 |
+
} catch (e: any) {
|
| 28 |
+
return `# Parse Error\n\nFailed to parse Solidity source: ${e.message as string}`;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const comments = buildCommentBlocks(soliditySource);
|
| 32 |
+
const imports: string[] = [];
|
| 33 |
+
const contracts: ContractAnalysis[] = [];
|
| 34 |
+
|
| 35 |
+
for (const node of ast.children ?? []) {
|
| 36 |
+
if (node.type === "ImportDirective") imports.push(node.path as string);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
for (const node of ast.children ?? []) {
|
| 40 |
+
if (node.type !== "ContractDefinition") continue;
|
| 41 |
+
|
| 42 |
+
const contractComment = node.loc ? findCommentFor(node.loc.start.line, comments) : undefined;
|
| 43 |
+
|
| 44 |
+
const contract: ContractAnalysis = {
|
| 45 |
+
name: node.name as string,
|
| 46 |
+
kind: (node.kind as string) ?? "contract",
|
| 47 |
+
baseContracts: (node.baseContracts ?? []).map(
|
| 48 |
+
(bc: any) => (bc.baseName?.namePath ?? bc.baseName?.name ?? "?") as string,
|
| 49 |
+
),
|
| 50 |
+
usingFor: [],
|
| 51 |
+
stateVars: [],
|
| 52 |
+
events: [],
|
| 53 |
+
errors: [],
|
| 54 |
+
modifiers: [],
|
| 55 |
+
functions: [],
|
| 56 |
+
natspec: contractComment?.natspec,
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
const stateVarNames = new Set<string>();
|
| 60 |
+
|
| 61 |
+
for (const member of node.subNodes ?? []) {
|
| 62 |
+
const memberComment = member.loc ? findCommentFor(member.loc.start.line, comments) : undefined;
|
| 63 |
+
|
| 64 |
+
switch (member.type) {
|
| 65 |
+
case "StateVariableDeclaration":
|
| 66 |
+
for (const v of member.variables ?? []) {
|
| 67 |
+
stateVarNames.add(v.name as string);
|
| 68 |
+
contract.stateVars.push({
|
| 69 |
+
name: v.name as string,
|
| 70 |
+
type: typeToString(v.typeName),
|
| 71 |
+
visibility: (v.visibility as string) ?? "internal",
|
| 72 |
+
constant: (v.isDeclaredConst as boolean) ?? false,
|
| 73 |
+
immutable: (v.isImmutable as boolean) ?? false,
|
| 74 |
+
natspec: memberComment?.natspec,
|
| 75 |
+
});
|
| 76 |
+
}
|
| 77 |
+
break;
|
| 78 |
+
|
| 79 |
+
case "EventDefinition": {
|
| 80 |
+
const params = (member.parameters ?? []).map((p: any) => {
|
| 81 |
+
const indexed = p.isIndexed ? "indexed " : "";
|
| 82 |
+
const name = p.name ? ` ${p.name as string}` : "";
|
| 83 |
+
return `${indexed}${typeToString(p.typeName)}${name}`;
|
| 84 |
+
});
|
| 85 |
+
contract.events.push({
|
| 86 |
+
name: member.name as string,
|
| 87 |
+
params,
|
| 88 |
+
anonymous: (member.isAnonymous as boolean) ?? false,
|
| 89 |
+
natspec: memberComment?.natspec,
|
| 90 |
+
});
|
| 91 |
+
break;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
case "CustomErrorDefinition":
|
| 95 |
+
contract.errors.push({
|
| 96 |
+
name: member.name as string,
|
| 97 |
+
params: (member.parameters ?? []).map((p: any) => paramToString(p)),
|
| 98 |
+
natspec: memberComment?.natspec,
|
| 99 |
+
});
|
| 100 |
+
break;
|
| 101 |
+
|
| 102 |
+
case "ModifierDefinition":
|
| 103 |
+
contract.modifiers.push({
|
| 104 |
+
name: member.name as string,
|
| 105 |
+
params: (member.parameters ?? []).map(paramToString),
|
| 106 |
+
natspec: memberComment?.natspec,
|
| 107 |
+
});
|
| 108 |
+
break;
|
| 109 |
+
|
| 110 |
+
case "FunctionDefinition": {
|
| 111 |
+
const { internalCalls, externalCalls, stateReads, stateWrites } = analyzeFunction(member, stateVarNames);
|
| 112 |
+
contract.functions.push({
|
| 113 |
+
name: (member.name as string) ?? "",
|
| 114 |
+
isConstructor: (member.isConstructor as boolean) ?? false,
|
| 115 |
+
isReceive: (member.isReceiveEther as boolean) ?? false,
|
| 116 |
+
isFallback: (member.isFallback as boolean) ?? false,
|
| 117 |
+
visibility: (member.visibility as string) ?? "internal",
|
| 118 |
+
mutability: (member.stateMutability as string) ?? "nonpayable",
|
| 119 |
+
params: (member.parameters ?? []).map(paramToString),
|
| 120 |
+
returns: (member.returnParameters ?? []).map(paramToString),
|
| 121 |
+
modifiers: (member.modifiers ?? []).map((m: any) => m.name as string),
|
| 122 |
+
internalCalls,
|
| 123 |
+
externalCalls,
|
| 124 |
+
stateReads,
|
| 125 |
+
stateWrites,
|
| 126 |
+
natspec: memberComment?.natspec,
|
| 127 |
+
});
|
| 128 |
+
break;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
case "UsingForDeclaration": {
|
| 132 |
+
const forType = member.typeName ? typeToString(member.typeName) : "*";
|
| 133 |
+
if (member.libraryName) {
|
| 134 |
+
contract.usingFor.push(`\`${member.libraryName as string}\` for \`${forType}\``);
|
| 135 |
+
} else {
|
| 136 |
+
const fns = (member.functions ?? [])
|
| 137 |
+
.map((f: any) => (f.typeName?.namePath ?? f.typeName?.name ?? f.path ?? "?") as string)
|
| 138 |
+
.join(", ");
|
| 139 |
+
contract.usingFor.push(`{${fns}} for \`${forType}\``);
|
| 140 |
+
}
|
| 141 |
+
break;
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
contracts.push(contract);
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
const opts: RenderOptions = {
|
| 150 |
+
filePath,
|
| 151 |
+
importance,
|
| 152 |
+
lineCount: soliditySource.split("\n").length,
|
| 153 |
+
solcVersion: extractSolcVersion(soliditySource),
|
| 154 |
+
};
|
| 155 |
+
|
| 156 |
+
return mode === "short"
|
| 157 |
+
? generateBriefMarkdown(imports, contracts, opts)
|
| 158 |
+
: generateFullMarkdown(imports, contracts, opts);
|
| 159 |
+
};
|
| 160 |
+
|
| 161 |
+
export const solidityAnalyzerTool = tool(async ({ solidityFile, mode }) => analyzeSolidityFile(solidityFile, mode), {
|
| 162 |
+
name: "solidity_analyzer",
|
| 163 |
+
description:
|
| 164 |
+
"Parse a Solidity source file and generate a markdown report. Use mode='short' for a compact summary (meta, external calls, function table). Use mode='full' for the complete report including storage, events, errors, per-function call graph, recursion detection, and state variable touchpoints.",
|
| 165 |
+
schema: z.object({
|
| 166 |
+
solidityFile: z.string().describe("The full Solidity source code to analyze."),
|
| 167 |
+
mode: z
|
| 168 |
+
.enum(["full", "short"])
|
| 169 |
+
.default("full")
|
| 170 |
+
.describe("Report verbosity. 'short' saves tokens; 'full' provides the complete analysis."),
|
| 171 |
+
}),
|
| 172 |
+
});
|
src/agents/auditor/tools/solidity-analyzer/utils.ts
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { visit } from "@solidity-parser/parser";
|
| 2 |
+
|
| 3 |
+
const ASSIGNMENT_OPS = new Set(["=", "+=", "-=", "*=", "/=", "%=", "|=", "&=", "^=", "<<=", ">>=", "**="]);
|
| 4 |
+
const BUILTIN_NAMESPACES = new Set(["abi", "block", "msg", "tx", "bytes", "string", "type"]);
|
| 5 |
+
|
| 6 |
+
export interface NatSpec {
|
| 7 |
+
title?: string;
|
| 8 |
+
author?: string;
|
| 9 |
+
notice?: string;
|
| 10 |
+
dev?: string;
|
| 11 |
+
params: Record<string, string>;
|
| 12 |
+
returns: string[];
|
| 13 |
+
inheritdoc?: string;
|
| 14 |
+
custom: Record<string, string>;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export interface ParsedComment {
|
| 18 |
+
text: string;
|
| 19 |
+
startLine: number;
|
| 20 |
+
endLine: number;
|
| 21 |
+
isNatSpec: boolean;
|
| 22 |
+
natspec?: NatSpec;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export interface StateVar {
|
| 26 |
+
name: string;
|
| 27 |
+
type: string;
|
| 28 |
+
visibility: string;
|
| 29 |
+
constant: boolean;
|
| 30 |
+
immutable: boolean;
|
| 31 |
+
natspec?: NatSpec;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export interface EventDef {
|
| 35 |
+
name: string;
|
| 36 |
+
params: string[];
|
| 37 |
+
anonymous: boolean;
|
| 38 |
+
natspec?: NatSpec;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
export interface ErrorDef {
|
| 42 |
+
name: string;
|
| 43 |
+
params: string[];
|
| 44 |
+
natspec?: NatSpec;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
export interface ModifierDef {
|
| 48 |
+
name: string;
|
| 49 |
+
params: string[];
|
| 50 |
+
natspec?: NatSpec;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export interface FunctionDef {
|
| 54 |
+
name: string;
|
| 55 |
+
isConstructor: boolean;
|
| 56 |
+
isReceive: boolean;
|
| 57 |
+
isFallback: boolean;
|
| 58 |
+
visibility: string;
|
| 59 |
+
mutability: string;
|
| 60 |
+
params: string[];
|
| 61 |
+
returns: string[];
|
| 62 |
+
modifiers: string[];
|
| 63 |
+
internalCalls: string[];
|
| 64 |
+
externalCalls: string[];
|
| 65 |
+
stateReads: string[];
|
| 66 |
+
stateWrites: string[];
|
| 67 |
+
natspec?: NatSpec;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
export interface ContractAnalysis {
|
| 71 |
+
name: string;
|
| 72 |
+
kind: string;
|
| 73 |
+
baseContracts: string[];
|
| 74 |
+
usingFor: string[];
|
| 75 |
+
stateVars: StateVar[];
|
| 76 |
+
events: EventDef[];
|
| 77 |
+
errors: ErrorDef[];
|
| 78 |
+
modifiers: ModifierDef[];
|
| 79 |
+
functions: FunctionDef[];
|
| 80 |
+
natspec?: NatSpec;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
export interface RenderOptions {
|
| 84 |
+
filePath?: string;
|
| 85 |
+
importance?: number;
|
| 86 |
+
lineCount: number;
|
| 87 |
+
solcVersion: string;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
export const extractSolcVersion = (source: string): string => {
|
| 91 |
+
const match = source.match(/pragma\s+solidity\s+([^;]+);/);
|
| 92 |
+
return match ? match[1].trim() : "—";
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
const parseNatSpecTags = (text: string): NatSpec => {
|
| 96 |
+
const result: NatSpec = { params: {}, returns: [], custom: {} };
|
| 97 |
+
|
| 98 |
+
const firstTag = text.search(/@(?:title|author|notice|dev|param|return|inheritdoc|custom:)/);
|
| 99 |
+
if (firstTag > 0) {
|
| 100 |
+
const implicit = text.slice(0, firstTag).trim();
|
| 101 |
+
if (implicit) result.notice = implicit.replace(/\n\s*/g, " ");
|
| 102 |
+
} else if (firstTag === -1 && text.trim()) {
|
| 103 |
+
result.notice = text.trim().replace(/\n\s*/g, " ");
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
const tagRe = /@(custom:\S+|\w+)([^@]*)/g;
|
| 107 |
+
let m: RegExpExecArray | null;
|
| 108 |
+
while ((m = tagRe.exec(text)) !== null) {
|
| 109 |
+
const tag = m[1];
|
| 110 |
+
const value = m[2].trim().replace(/\n\s*/g, " ");
|
| 111 |
+
if (tag === "title") result.title = value;
|
| 112 |
+
else if (tag === "author") result.author = value;
|
| 113 |
+
else if (tag === "notice") result.notice = value;
|
| 114 |
+
else if (tag === "dev") result.dev = value;
|
| 115 |
+
else if (tag === "inheritdoc") result.inheritdoc = value;
|
| 116 |
+
else if (tag === "param") {
|
| 117 |
+
const sp = value.indexOf(" ");
|
| 118 |
+
if (sp > 0) result.params[value.slice(0, sp)] = value.slice(sp + 1);
|
| 119 |
+
else if (value) result.params[value] = "";
|
| 120 |
+
} else if (tag === "return") result.returns.push(value);
|
| 121 |
+
else if (tag.startsWith("custom:")) result.custom[tag.slice(7)] = value;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
return result;
|
| 125 |
+
};
|
| 126 |
+
|
| 127 |
+
export const buildCommentBlocks = (source: string): ParsedComment[] => {
|
| 128 |
+
const blocks: ParsedComment[] = [];
|
| 129 |
+
const lines = source.split("\n");
|
| 130 |
+
let i = 0;
|
| 131 |
+
|
| 132 |
+
while (i < lines.length) {
|
| 133 |
+
const raw = lines[i];
|
| 134 |
+
const trimmed = raw.trimStart();
|
| 135 |
+
|
| 136 |
+
if (trimmed.startsWith("///")) {
|
| 137 |
+
const startLine = i + 1;
|
| 138 |
+
const texts: string[] = [];
|
| 139 |
+
while (i < lines.length && lines[i].trimStart().startsWith("///")) {
|
| 140 |
+
texts.push(lines[i].trimStart().slice(3).replace(/^ /, ""));
|
| 141 |
+
i++;
|
| 142 |
+
}
|
| 143 |
+
const text = texts.join("\n");
|
| 144 |
+
blocks.push({ text, startLine, endLine: i, isNatSpec: true, natspec: parseNatSpecTags(text) });
|
| 145 |
+
continue;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
const mlStart = raw.indexOf("/*");
|
| 149 |
+
if (mlStart !== -1) {
|
| 150 |
+
const isNatSpec = raw[mlStart + 2] === "*" && raw[mlStart + 3] !== "/";
|
| 151 |
+
const startLine = i + 1;
|
| 152 |
+
const closeOnSame = raw.indexOf("*/", mlStart + 2);
|
| 153 |
+
|
| 154 |
+
if (closeOnSame !== -1) {
|
| 155 |
+
const inner = raw.slice(mlStart + (isNatSpec ? 3 : 2), closeOnSame).trim();
|
| 156 |
+
blocks.push({
|
| 157 |
+
text: inner,
|
| 158 |
+
startLine,
|
| 159 |
+
endLine: startLine,
|
| 160 |
+
isNatSpec,
|
| 161 |
+
natspec: isNatSpec ? parseNatSpecTags(inner) : undefined,
|
| 162 |
+
});
|
| 163 |
+
i++;
|
| 164 |
+
continue;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
const rawLines: string[] = [raw.slice(mlStart + (isNatSpec ? 3 : 2))];
|
| 168 |
+
i++;
|
| 169 |
+
while (i < lines.length) {
|
| 170 |
+
const closeIdx = lines[i].indexOf("*/");
|
| 171 |
+
if (closeIdx !== -1) {
|
| 172 |
+
rawLines.push(lines[i].slice(0, closeIdx));
|
| 173 |
+
i++;
|
| 174 |
+
break;
|
| 175 |
+
}
|
| 176 |
+
rawLines.push(lines[i]);
|
| 177 |
+
i++;
|
| 178 |
+
}
|
| 179 |
+
const text = rawLines
|
| 180 |
+
.map((l) => l.replace(/^\s*\*\s?/, ""))
|
| 181 |
+
.join("\n")
|
| 182 |
+
.trim();
|
| 183 |
+
blocks.push({ text, startLine, endLine: i, isNatSpec, natspec: isNatSpec ? parseNatSpecTags(text) : undefined });
|
| 184 |
+
continue;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
if (trimmed.startsWith("//")) {
|
| 188 |
+
blocks.push({ text: trimmed.slice(2).trim(), startLine: i + 1, endLine: i + 1, isNatSpec: false });
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
i++;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
return blocks;
|
| 195 |
+
};
|
| 196 |
+
|
| 197 |
+
export const findCommentFor = (line: number, comments: ParsedComment[]): ParsedComment | undefined =>
|
| 198 |
+
comments.find((c) => c.endLine === line - 1) ?? comments.find((c) => c.startLine === line && !c.isNatSpec);
|
| 199 |
+
|
| 200 |
+
export const typeToString = (node: any): string => {
|
| 201 |
+
if (!node) return "unknown";
|
| 202 |
+
switch (node.type) {
|
| 203 |
+
case "ElementaryTypeName":
|
| 204 |
+
return node.name as string;
|
| 205 |
+
case "UserDefinedTypeName":
|
| 206 |
+
return (node.namePath ?? node.name) as string;
|
| 207 |
+
case "ArrayTypeName":
|
| 208 |
+
return `${typeToString(node.baseTypeName)}[${node.length ?? ""}]`;
|
| 209 |
+
case "Mapping":
|
| 210 |
+
return `mapping(${typeToString(node.keyType)} => ${typeToString(node.valueType)})`;
|
| 211 |
+
case "FunctionTypeName":
|
| 212 |
+
return "function";
|
| 213 |
+
default:
|
| 214 |
+
return "unknown";
|
| 215 |
+
}
|
| 216 |
+
};
|
| 217 |
+
|
| 218 |
+
export const paramToString = (p: any): string => {
|
| 219 |
+
if (!p) return "?";
|
| 220 |
+
const type = typeToString(p.typeName);
|
| 221 |
+
return p.name ? `${type} ${p.name}` : type;
|
| 222 |
+
};
|
| 223 |
+
|
| 224 |
+
const collectLHSRoots = (node: any, targets: Set<string>) => {
|
| 225 |
+
if (!node) return;
|
| 226 |
+
switch (node.type) {
|
| 227 |
+
case "Identifier":
|
| 228 |
+
targets.add(node.name as string);
|
| 229 |
+
break;
|
| 230 |
+
case "MemberAccess":
|
| 231 |
+
collectLHSRoots(node.expression, targets);
|
| 232 |
+
break;
|
| 233 |
+
case "IndexAccess":
|
| 234 |
+
collectLHSRoots(node.base, targets);
|
| 235 |
+
break;
|
| 236 |
+
case "TupleExpression":
|
| 237 |
+
for (const c of node.components ?? []) collectLHSRoots(c, targets);
|
| 238 |
+
break;
|
| 239 |
+
}
|
| 240 |
+
};
|
| 241 |
+
|
| 242 |
+
export const analyzeFunction = (funcNode: any, stateVarNames: Set<string>) => {
|
| 243 |
+
const internalCalls = new Set<string>();
|
| 244 |
+
const externalCalls = new Set<string>();
|
| 245 |
+
const writeTargets = new Set<string>();
|
| 246 |
+
const allStateAccesses = new Set<string>();
|
| 247 |
+
const localVars = new Set<string>();
|
| 248 |
+
|
| 249 |
+
if (!funcNode.body) {
|
| 250 |
+
return { internalCalls: [], externalCalls: [], stateReads: [], stateWrites: [] };
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
for (const p of funcNode.parameters ?? []) {
|
| 254 |
+
if (p?.name) localVars.add(p.name as string);
|
| 255 |
+
}
|
| 256 |
+
for (const p of funcNode.returnParameters ?? []) {
|
| 257 |
+
if (p?.name) localVars.add(p.name as string);
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
visit(funcNode.body, {
|
| 261 |
+
VariableDeclarationStatement: (node: any) => {
|
| 262 |
+
for (const v of node.variables ?? []) {
|
| 263 |
+
if (v?.name) localVars.add(v.name as string);
|
| 264 |
+
}
|
| 265 |
+
},
|
| 266 |
+
});
|
| 267 |
+
|
| 268 |
+
const effectiveStateVars = new Set([...stateVarNames].filter((v) => !localVars.has(v)));
|
| 269 |
+
|
| 270 |
+
visit(funcNode.body, {
|
| 271 |
+
ExpressionStatement: (node: any) => {
|
| 272 |
+
const expr = node.expression;
|
| 273 |
+
if (expr?.type === "BinaryOperation" && ASSIGNMENT_OPS.has(expr.operator as string)) {
|
| 274 |
+
collectLHSRoots(expr.left, writeTargets);
|
| 275 |
+
}
|
| 276 |
+
if (
|
| 277 |
+
expr?.type === "UnaryOperation" &&
|
| 278 |
+
(expr.operator === "++" || expr.operator === "--" || expr.operator === "delete")
|
| 279 |
+
) {
|
| 280 |
+
collectLHSRoots(expr.subExpression, writeTargets);
|
| 281 |
+
}
|
| 282 |
+
},
|
| 283 |
+
});
|
| 284 |
+
|
| 285 |
+
visit(funcNode.body, {
|
| 286 |
+
FunctionCall: (node: any) => {
|
| 287 |
+
const expr = node.expression;
|
| 288 |
+
if (expr?.type === "Identifier") {
|
| 289 |
+
internalCalls.add(expr.name as string);
|
| 290 |
+
} else if (expr?.type === "MemberAccess") {
|
| 291 |
+
const base = expr.expression;
|
| 292 |
+
if (base?.type === "Identifier" && (base.name === "this" || base.name === "super")) {
|
| 293 |
+
internalCalls.add(expr.memberName as string);
|
| 294 |
+
} else if (base?.type === "Identifier" && BUILTIN_NAMESPACES.has(base.name as string)) {
|
| 295 |
+
// builtin namespace — skip
|
| 296 |
+
} else {
|
| 297 |
+
const baseStr = base?.type === "Identifier" ? (base.name as string) : "<expr>";
|
| 298 |
+
externalCalls.add(`${baseStr}.${expr.memberName as string}`);
|
| 299 |
+
}
|
| 300 |
+
}
|
| 301 |
+
},
|
| 302 |
+
Identifier: (node: any) => {
|
| 303 |
+
if (effectiveStateVars.has(node.name as string)) {
|
| 304 |
+
allStateAccesses.add(node.name as string);
|
| 305 |
+
}
|
| 306 |
+
},
|
| 307 |
+
});
|
| 308 |
+
|
| 309 |
+
const stateWrites = [...allStateAccesses].filter((v) => writeTargets.has(v));
|
| 310 |
+
|
| 311 |
+
return {
|
| 312 |
+
internalCalls: [...internalCalls],
|
| 313 |
+
externalCalls: [...externalCalls],
|
| 314 |
+
stateReads: [...allStateAccesses],
|
| 315 |
+
stateWrites,
|
| 316 |
+
};
|
| 317 |
+
};
|
| 318 |
+
|
| 319 |
+
export const hasCycle = (
|
| 320 |
+
start: string,
|
| 321 |
+
current: string,
|
| 322 |
+
callMap: Map<string, string[]>,
|
| 323 |
+
visited: Set<string>,
|
| 324 |
+
): boolean => {
|
| 325 |
+
for (const callee of callMap.get(current) ?? []) {
|
| 326 |
+
if (callee === start) return true;
|
| 327 |
+
if (!visited.has(callee)) {
|
| 328 |
+
visited.add(callee);
|
| 329 |
+
if (hasCycle(start, callee, callMap, visited)) return true;
|
| 330 |
+
}
|
| 331 |
+
}
|
| 332 |
+
return false;
|
| 333 |
+
};
|
| 334 |
+
|
| 335 |
+
export const fnLabel = (fn: FunctionDef): string => {
|
| 336 |
+
if (fn.isConstructor) return "constructor";
|
| 337 |
+
if (fn.isReceive) return "receive";
|
| 338 |
+
if (fn.isFallback) return "fallback";
|
| 339 |
+
return fn.name;
|
| 340 |
+
};
|
| 341 |
+
|
| 342 |
+
const renderNatSpec = (ns: NatSpec | undefined): string => {
|
| 343 |
+
if (!ns) return "—";
|
| 344 |
+
const parts: string[] = [];
|
| 345 |
+
if (ns.title) parts.push(`@title "${ns.title}"`);
|
| 346 |
+
if (ns.author) parts.push(`@author "${ns.author}"`);
|
| 347 |
+
if (ns.notice) parts.push(`@notice "${ns.notice}"`);
|
| 348 |
+
if (ns.dev) parts.push(`@dev "${ns.dev}"`);
|
| 349 |
+
for (const [k, v] of Object.entries(ns.params)) {
|
| 350 |
+
parts.push(v ? `@param ${k}: "${v}"` : `@param ${k}`);
|
| 351 |
+
}
|
| 352 |
+
for (const r of ns.returns) {
|
| 353 |
+
if (r) parts.push(`@return "${r}"`);
|
| 354 |
+
}
|
| 355 |
+
return parts.length > 0 ? parts.join(" · ") : "—";
|
| 356 |
+
};
|
| 357 |
+
|
| 358 |
+
const renderContractFull = (contract: ContractAnalysis, imports: string[], lines: string[]) => {
|
| 359 |
+
lines.push("## Meta");
|
| 360 |
+
const inherits = contract.baseContracts.length > 0 ? `[${contract.baseContracts.join(", ")}]` : "—";
|
| 361 |
+
lines.push(`- kind: ${contract.kind} · inherits: ${inherits}`);
|
| 362 |
+
if (contract.usingFor.length > 0) lines.push(`- uses: [${contract.usingFor.join(", ")}]`);
|
| 363 |
+
lines.push(`- imports: ${imports.length > 0 ? imports.map((i) => `\`${i}\``).join(", ") : "—"}`);
|
| 364 |
+
lines.push(`- docs: ${renderNatSpec(contract.natspec)}`);
|
| 365 |
+
lines.push("");
|
| 366 |
+
|
| 367 |
+
if (contract.stateVars.length > 0) {
|
| 368 |
+
lines.push("## Storage");
|
| 369 |
+
lines.push("| name | type | vis | flags | desc |");
|
| 370 |
+
lines.push("|------|------|-----|-------|------|");
|
| 371 |
+
for (const v of contract.stateVars) {
|
| 372 |
+
const flags = [v.constant && "constant", v.immutable && "immutable"].filter(Boolean).join(", ") || "—";
|
| 373 |
+
const desc = v.natspec?.notice ?? v.natspec?.dev ?? "—";
|
| 374 |
+
lines.push(`| \`${v.name}\` | \`${v.type}\` | ${v.visibility} | ${flags} | ${desc} |`);
|
| 375 |
+
}
|
| 376 |
+
lines.push("");
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
if (contract.events.length > 0) {
|
| 380 |
+
lines.push("## Events");
|
| 381 |
+
for (const e of contract.events) {
|
| 382 |
+
const notice = e.natspec?.notice ? ` — ${e.natspec.notice}` : "";
|
| 383 |
+
lines.push(`- \`${e.name}(${e.params.join(", ")})\`${e.anonymous ? " _(anon)_" : ""}${notice}`);
|
| 384 |
+
}
|
| 385 |
+
lines.push("");
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
if (contract.errors.length > 0) {
|
| 389 |
+
lines.push("## Errors");
|
| 390 |
+
for (const e of contract.errors) lines.push(`- \`${e.name}(${e.params.join(", ")})\``);
|
| 391 |
+
lines.push("");
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
if (contract.modifiers.length > 0) {
|
| 395 |
+
lines.push("## Modifiers");
|
| 396 |
+
for (const m of contract.modifiers) {
|
| 397 |
+
const notice = m.natspec?.notice ? ` — ${m.natspec.notice}` : "";
|
| 398 |
+
lines.push(`- \`${m.name}(${m.params.join(", ")})\`${notice}`);
|
| 399 |
+
}
|
| 400 |
+
lines.push("");
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
const allExternalCalls = new Set(contract.functions.flatMap((f) => f.externalCalls));
|
| 404 |
+
if (allExternalCalls.size > 0) {
|
| 405 |
+
lines.push("## External Calls");
|
| 406 |
+
for (const call of allExternalCalls) lines.push(`- \`${call}\``);
|
| 407 |
+
lines.push("");
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
if (contract.functions.length > 0) {
|
| 411 |
+
lines.push("## Functions");
|
| 412 |
+
lines.push("");
|
| 413 |
+
|
| 414 |
+
const callMap = new Map(contract.functions.map((f) => [fnLabel(f), f.internalCalls]));
|
| 415 |
+
|
| 416 |
+
for (const fn of contract.functions) {
|
| 417 |
+
const label = fnLabel(fn);
|
| 418 |
+
lines.push(`### ${label}`);
|
| 419 |
+
|
| 420 |
+
const modsStr = fn.modifiers.length > 0 ? ` · modifiers: [${fn.modifiers.join(", ")}]` : "";
|
| 421 |
+
lines.push(`- visibility: ${fn.visibility} · mutability: ${fn.mutability}${modsStr}`);
|
| 422 |
+
|
| 423 |
+
const paramsStr = fn.params.length > 0 ? fn.params.join(", ") : "—";
|
| 424 |
+
const returnsStr = fn.returns.length > 0 ? `\`${fn.returns.join(", ")}\`` : "—";
|
| 425 |
+
lines.push(`- parameters: \`(${paramsStr})\` · returns: ${returnsStr}`);
|
| 426 |
+
|
| 427 |
+
if (fn.externalCalls.length > 0) {
|
| 428 |
+
lines.push(`- calls: [${fn.externalCalls.map((c) => `\`${c}\``).join(", ")}]`);
|
| 429 |
+
}
|
| 430 |
+
if (fn.internalCalls.length > 0) {
|
| 431 |
+
lines.push(`- graph: \`${fn.internalCalls.map((c) => `${label} → ${c}`).join(", ")}\``);
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
lines.push(`- recurse: ${hasCycle(label, label, callMap, new Set()) ? "yes ⚠" : "no"}`);
|
| 435 |
+
|
| 436 |
+
if (fn.stateReads.length > 0 || fn.stateWrites.length > 0) {
|
| 437 |
+
const reads = fn.stateReads.length > 0 ? fn.stateReads.map((r) => `\`${r}\``).join(", ") : "—";
|
| 438 |
+
const writes = fn.stateWrites.length > 0 ? fn.stateWrites.map((w) => `\`${w}\``).join(", ") : "—";
|
| 439 |
+
lines.push(`- state: reads [${reads}] · writes [${writes}]`);
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
lines.push(`- docs: ${renderNatSpec(fn.natspec)}`);
|
| 443 |
+
lines.push("");
|
| 444 |
+
}
|
| 445 |
+
}
|
| 446 |
+
};
|
| 447 |
+
|
| 448 |
+
const renderContractBrief = (contract: ContractAnalysis, imports: string[], lines: string[]) => {
|
| 449 |
+
lines.push("## Meta");
|
| 450 |
+
const inherits = contract.baseContracts.length > 0 ? `[${contract.baseContracts.join(", ")}]` : "—";
|
| 451 |
+
const importsList = imports.length > 0 ? imports.map((i) => `\`${i}\``).join(", ") : "—";
|
| 452 |
+
lines.push(`- kind: ${contract.kind} · inherits: ${inherits}`);
|
| 453 |
+
lines.push(`- imports: ${importsList}`);
|
| 454 |
+
lines.push(`- docs: ${renderNatSpec(contract.natspec)}`);
|
| 455 |
+
lines.push("");
|
| 456 |
+
|
| 457 |
+
const allExternalCalls = new Set(contract.functions.flatMap((f) => f.externalCalls));
|
| 458 |
+
if (allExternalCalls.size > 0) {
|
| 459 |
+
lines.push("## External Calls");
|
| 460 |
+
lines.push([...allExternalCalls].map((c) => `\`${c}\``).join(" · "));
|
| 461 |
+
lines.push("");
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
if (contract.functions.length > 0) {
|
| 465 |
+
lines.push("## Functions");
|
| 466 |
+
lines.push("| function | visibility | mutability | parameters | returns | modifiers |");
|
| 467 |
+
lines.push("|----------|------------|------------|------------|---------|-----------|");
|
| 468 |
+
for (const fn of contract.functions) {
|
| 469 |
+
const label = fnLabel(fn);
|
| 470 |
+
const params = fn.params.length > 0 ? fn.params.join(", ") : "—";
|
| 471 |
+
const returns = fn.returns.length > 0 ? fn.returns.join(", ") : "—";
|
| 472 |
+
const mods = fn.modifiers.length > 0 ? fn.modifiers.join(", ") : "—";
|
| 473 |
+
lines.push(`| \`${label}\` | ${fn.visibility} | ${fn.mutability} | ${params} | ${returns} | ${mods} |`);
|
| 474 |
+
}
|
| 475 |
+
lines.push("");
|
| 476 |
+
}
|
| 477 |
+
};
|
| 478 |
+
|
| 479 |
+
const fileHeader = (contracts: ContractAnalysis[], mode: "full" | "short", opts: RenderOptions): string[] => {
|
| 480 |
+
const names = contracts.map((c) => c.name).join(", ");
|
| 481 |
+
const label = mode === "full" ? "FULL" : "BRIEF";
|
| 482 |
+
const rankStr = opts.importance !== undefined ? ` | importance: ${opts.importance}/5` : "";
|
| 483 |
+
return [
|
| 484 |
+
`# ${names} · ${label}`,
|
| 485 |
+
`> path: \`${opts.filePath ?? "—"}\` | lines: ${opts.lineCount} | solc: ${opts.solcVersion}${rankStr}`,
|
| 486 |
+
"",
|
| 487 |
+
];
|
| 488 |
+
};
|
| 489 |
+
|
| 490 |
+
export const generateFullMarkdown = (imports: string[], contracts: ContractAnalysis[], opts: RenderOptions): string => {
|
| 491 |
+
const lines: string[] = fileHeader(contracts, "full", opts);
|
| 492 |
+
|
| 493 |
+
for (let i = 0; i < contracts.length; i++) {
|
| 494 |
+
if (contracts.length > 1) {
|
| 495 |
+
if (i > 0) lines.push("---", "");
|
| 496 |
+
lines.push(`## ◆ ${contracts[i].name}`, "");
|
| 497 |
+
}
|
| 498 |
+
renderContractFull(contracts[i], imports, lines);
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
return lines.join("\n");
|
| 502 |
+
};
|
| 503 |
+
|
| 504 |
+
export const generateBriefMarkdown = (
|
| 505 |
+
imports: string[],
|
| 506 |
+
contracts: ContractAnalysis[],
|
| 507 |
+
opts: RenderOptions,
|
| 508 |
+
): string => {
|
| 509 |
+
const lines: string[] = fileHeader(contracts, "short", opts);
|
| 510 |
+
|
| 511 |
+
for (let i = 0; i < contracts.length; i++) {
|
| 512 |
+
if (contracts.length > 1) {
|
| 513 |
+
if (i > 0) lines.push("---", "");
|
| 514 |
+
lines.push(`## ◆ ${contracts[i].name}`, "");
|
| 515 |
+
}
|
| 516 |
+
renderContractBrief(contracts[i], imports, lines);
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
return lines.join("\n");
|
| 520 |
+
};
|
src/agents/auditor/utils.ts
CHANGED
|
@@ -1,3 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
export const matchLines = (fileContent: string, codeSnippet: string): string | null => {
|
| 2 |
const fileLines = fileContent.split("\n");
|
| 3 |
const snippetLines = codeSnippet.split("\n").map((line) => line.trim());
|
|
@@ -34,3 +69,49 @@ export const matchLines = (fileContent: string, codeSnippet: string): string | n
|
|
| 34 |
|
| 35 |
return null;
|
| 36 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from "node:fs";
|
| 2 |
+
import path from "node:path";
|
| 3 |
+
|
| 4 |
+
import { DOC_BASENAMES, DOC_EXTS, MAX_DEPTH, SKIP_DIRS, SOL_EXT, SOL_TEST_SUFFIXES } from "./config.ts";
|
| 5 |
+
|
| 6 |
+
export const walkDirectory = (dir: string, depth: number, solFiles: string[], docFiles: string[]) => {
|
| 7 |
+
if (depth > MAX_DEPTH) return;
|
| 8 |
+
|
| 9 |
+
let entries: fs.Dirent[];
|
| 10 |
+
try {
|
| 11 |
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
| 12 |
+
} catch {
|
| 13 |
+
return;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
for (const entry of entries) {
|
| 17 |
+
if (entry.isDirectory()) {
|
| 18 |
+
if (!SKIP_DIRS.has(entry.name)) {
|
| 19 |
+
walkDirectory(path.join(dir, entry.name), depth + 1, solFiles, docFiles);
|
| 20 |
+
}
|
| 21 |
+
} else if (entry.isFile()) {
|
| 22 |
+
const fullPath = path.join(dir, entry.name);
|
| 23 |
+
const ext = path.extname(entry.name).toLowerCase();
|
| 24 |
+
const base = path.basename(entry.name, ext).toLowerCase();
|
| 25 |
+
|
| 26 |
+
if (ext === SOL_EXT) {
|
| 27 |
+
const isTest = SOL_TEST_SUFFIXES.some((suffix) => entry.name.endsWith(suffix));
|
| 28 |
+
if (!isTest) solFiles.push(fullPath);
|
| 29 |
+
} else if (DOC_EXTS.has(ext) || DOC_BASENAMES.has(base)) {
|
| 30 |
+
docFiles.push(fullPath);
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
export const matchLines = (fileContent: string, codeSnippet: string): string | null => {
|
| 37 |
const fileLines = fileContent.split("\n");
|
| 38 |
const snippetLines = codeSnippet.split("\n").map((line) => line.trim());
|
|
|
|
| 69 |
|
| 70 |
return null;
|
| 71 |
};
|
| 72 |
+
|
| 73 |
+
type ReviewEntry = {
|
| 74 |
+
finding: {
|
| 75 |
+
title: string;
|
| 76 |
+
severity: string;
|
| 77 |
+
location: string;
|
| 78 |
+
description: string;
|
| 79 |
+
codeSnippet: string;
|
| 80 |
+
};
|
| 81 |
+
review: {
|
| 82 |
+
isFalsePositive: boolean;
|
| 83 |
+
confidence: number;
|
| 84 |
+
review: string;
|
| 85 |
+
exploitablePaths: string[];
|
| 86 |
+
};
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
export const buildReviewBlocks = (fileEntries: ReviewEntry[], iterationCount: number): string => {
|
| 90 |
+
const blocks = fileEntries.map(({ finding, review }, i) => {
|
| 91 |
+
const verdict = review.isFalsePositive
|
| 92 |
+
? `FALSO POSITIVO (confiança: ${review.confidence}/100)`
|
| 93 |
+
: `VERDADEIRO POSITIVO (confiança: ${review.confidence}/100)`;
|
| 94 |
+
const pathsLabel = review.isFalsePositive ? "Razão de Bloqueio" : "Caminhos de Exploração";
|
| 95 |
+
const pathsContent =
|
| 96 |
+
review.exploitablePaths.length > 0
|
| 97 |
+
? review.exploitablePaths.map((p) => ` - ${p}`).join("\n")
|
| 98 |
+
: " (nenhum fornecido)";
|
| 99 |
+
|
| 100 |
+
return `[Achado ${i + 1}/${fileEntries.length}] ${finding.title}
|
| 101 |
+
Severidade: ${finding.severity}
|
| 102 |
+
Localização: linhas ${finding.location}
|
| 103 |
+
Descrição: ${finding.description}
|
| 104 |
+
|
| 105 |
+
Código:
|
| 106 |
+
\`\`\`solidity
|
| 107 |
+
${finding.codeSnippet}
|
| 108 |
+
\`\`\`
|
| 109 |
+
|
| 110 |
+
Veredito do Revisor: ${verdict}
|
| 111 |
+
Análise do Revisor: ${review.review}
|
| 112 |
+
${pathsLabel}:
|
| 113 |
+
${pathsContent}`;
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
return `=== Iteração ${iterationCount} — Achados e Revisões do Especialista (${fileEntries.length} achado(s)) ===\n\n${blocks.join("\n\n---\n\n")}`;
|
| 117 |
+
};
|
src/config/llm.ts
CHANGED
|
@@ -5,32 +5,39 @@ import type { BaseChatModel } from "@langchain/core/language_models/chat_models"
|
|
| 5 |
|
| 6 |
export type LLMProvider = "google" | "openrouter" | "anthropic";
|
| 7 |
|
| 8 |
-
export
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
const provider = overrideProvider || (process.env.LLM_PROVIDER as LLMProvider) || "openrouter";
|
| 10 |
|
| 11 |
switch (provider) {
|
| 12 |
case "openrouter":
|
| 13 |
return new ChatOpenRouter({
|
| 14 |
-
model: process.env.OPENROUTER_MODEL || "google/gemini-3.1-flash-lite",
|
| 15 |
-
temperature: 0.2,
|
| 16 |
apiKey: process.env.OPENROUTER_API_KEY,
|
| 17 |
-
maxTokens: 4096,
|
| 18 |
});
|
| 19 |
|
| 20 |
-
|
| 21 |
case "anthropic":
|
| 22 |
return new ChatAnthropic({
|
| 23 |
-
model: process.env.ANTHROPIC_MODEL || "claude-sonnet-4-6",
|
| 24 |
-
temperature: 0.2,
|
| 25 |
-
|
|
|
|
| 26 |
});
|
|
|
|
| 27 |
case "google":
|
| 28 |
default:
|
| 29 |
return new ChatGoogleGenerativeAI({
|
| 30 |
apiKey: process.env.GOOGLE_API_KEY || "",
|
| 31 |
-
model: process.env.MODEL_NAME || "gemini-2.5-flash",
|
| 32 |
-
temperature: 0.2,
|
| 33 |
-
maxOutputTokens: 4096,
|
| 34 |
});
|
| 35 |
}
|
| 36 |
}
|
|
|
|
| 5 |
|
| 6 |
export type LLMProvider = "google" | "openrouter" | "anthropic";
|
| 7 |
|
| 8 |
+
export interface LLMOptions {
|
| 9 |
+
model?: string;
|
| 10 |
+
temperature?: number | null;
|
| 11 |
+
maxTokens?: number;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export function createLLM(overrideProvider?: LLMProvider, options?: LLMOptions): BaseChatModel {
|
| 15 |
const provider = overrideProvider || (process.env.LLM_PROVIDER as LLMProvider) || "openrouter";
|
| 16 |
|
| 17 |
switch (provider) {
|
| 18 |
case "openrouter":
|
| 19 |
return new ChatOpenRouter({
|
| 20 |
+
model: options?.model || process.env.OPENROUTER_MODEL || "google/gemini-3.1-flash-lite",
|
| 21 |
+
...(options?.temperature !== null && { temperature: options?.temperature ?? 0.2 }),
|
| 22 |
apiKey: process.env.OPENROUTER_API_KEY,
|
| 23 |
+
maxTokens: options?.maxTokens ?? 4096,
|
| 24 |
});
|
| 25 |
|
|
|
|
| 26 |
case "anthropic":
|
| 27 |
return new ChatAnthropic({
|
| 28 |
+
model: options?.model || process.env.ANTHROPIC_MODEL || "claude-sonnet-4-6",
|
| 29 |
+
...(options?.temperature !== null && { temperature: options?.temperature ?? 0.2 }),
|
| 30 |
+
apiKey: process.env.ANTHROPIC_API_KEY,
|
| 31 |
+
maxTokens: options?.maxTokens ?? 4096,
|
| 32 |
});
|
| 33 |
+
|
| 34 |
case "google":
|
| 35 |
default:
|
| 36 |
return new ChatGoogleGenerativeAI({
|
| 37 |
apiKey: process.env.GOOGLE_API_KEY || "",
|
| 38 |
+
model: options?.model || process.env.MODEL_NAME || "gemini-2.5-flash",
|
| 39 |
+
...(options?.temperature !== null && { temperature: options?.temperature ?? 0.2 }),
|
| 40 |
+
maxOutputTokens: options?.maxTokens ?? 4096,
|
| 41 |
});
|
| 42 |
}
|
| 43 |
}
|