solidity-argus 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +161 -1
- package/package.json +5 -2
- package/skills/README.md +63 -0
- package/skills/checklists/cyfrin-defi-core/SKILL.md +3 -0
- package/skills/manifests/cyfrin.json +16 -0
- package/skills/manifests/defifofum.json +25 -0
- package/skills/manifests/kadenzipfel.json +48 -0
- package/skills/manifests/scvd.json +9 -0
- package/skills/manifests/smartbugs.json +11 -0
- package/skills/manifests/solodit.json +9 -0
- package/skills/manifests/sunweb3sec.json +11 -0
- package/skills/manifests/trailofbits.json +9 -0
- package/skills/methodology/audit-workflow/SKILL.md +3 -0
- package/skills/patterns/access-control.yaml +31 -0
- package/skills/patterns/erc4626.yaml +29 -0
- package/skills/patterns/flash-loan.yaml +20 -0
- package/skills/patterns/oracle.yaml +30 -0
- package/skills/patterns/proxy.yaml +30 -0
- package/skills/patterns/reentrancy.yaml +30 -0
- package/skills/patterns/signature.yaml +31 -0
- package/skills/protocol-patterns/amm-dex/SKILL.md +3 -0
- package/skills/references/exploit-reference/SKILL.md +3 -0
- package/skills/vulnerability-patterns/access-control/SKILL.md +13 -0
- package/skills/vulnerability-patterns/authorization-txorigin/SKILL.md +6 -0
- package/skills/vulnerability-patterns/delegatecall-untrusted-callee/SKILL.md +6 -0
- package/skills/vulnerability-patterns/dos-revert/SKILL.md +13 -1
- package/skills/vulnerability-patterns/flash-loan-attacks/SKILL.md +12 -0
- package/skills/vulnerability-patterns/oracle-manipulation/SKILL.md +13 -0
- package/skills/vulnerability-patterns/overflow-underflow/SKILL.md +10 -1
- package/skills/vulnerability-patterns/reentrancy/SKILL.md +13 -0
- package/skills/vulnerability-patterns/signature-malleability/SKILL.md +9 -0
- package/skills/vulnerability-patterns/unchecked-return-values/SKILL.md +11 -0
- package/src/agents/argus-prompt.ts +4 -4
- package/src/agents/pythia-prompt.ts +4 -4
- package/src/agents/scribe-prompt.ts +3 -3
- package/src/agents/sentinel-prompt.ts +4 -4
- package/src/cli/cli-output.ts +16 -0
- package/src/cli/cli-program.ts +9 -5
- package/src/cli/commands/doctor.ts +274 -16
- package/src/cli/commands/init.ts +5 -5
- package/src/cli/commands/install.ts +5 -5
- package/src/cli/commands/lint-skills.ts +114 -0
- package/src/cli/tui-prompts.ts +4 -2
- package/src/config/schema.ts +2 -0
- package/src/create-hooks.ts +99 -14
- package/src/create-tools.ts +2 -0
- package/src/features/error-recovery/tool-error-recovery.ts +74 -19
- package/src/features/persistent-state/audit-state-manager.ts +36 -13
- package/src/hooks/agent-tracker.ts +53 -0
- package/src/hooks/compaction-hook.ts +46 -37
- package/src/hooks/config-handler.ts +3 -0
- package/src/hooks/context-budget.ts +45 -0
- package/src/hooks/event-hook.ts +5 -4
- package/src/hooks/knowledge-sync-hook.ts +2 -1
- package/src/hooks/recon-context-builder.ts +66 -0
- package/src/hooks/safe-create-hook.ts +4 -5
- package/src/hooks/system-prompt-hook.ts +128 -0
- package/src/hooks/tool-tracking-hook.ts +86 -7
- package/src/index.ts +24 -1
- package/src/knowledge/retry.ts +53 -0
- package/src/knowledge/scvd-client.ts +37 -10
- package/src/knowledge/scvd-errors.ts +89 -0
- package/src/knowledge/scvd-index.ts +53 -3
- package/src/knowledge/scvd-sync.ts +205 -34
- package/src/knowledge/source-manifest.ts +102 -0
- package/src/plugin-interface.ts +14 -1
- package/src/shared/binary-utils.ts +1 -0
- package/src/shared/logger.ts +78 -17
- package/src/skills/argus-skill-resolver.ts +226 -0
- package/src/skills/skill-schema.ts +98 -0
- package/src/state/audit-state.ts +2 -0
- package/src/state/types.ts +32 -1
- package/src/tools/argus-skill-load-tool.ts +73 -0
- package/src/tools/pattern-checker-tool.ts +56 -12
- package/src/tools/pattern-loader.ts +183 -0
- package/src/tools/pattern-schema.ts +51 -0
- package/src/tools/report-generator-tool.ts +134 -11
- package/src/tools/slither-tool.ts +61 -19
- package/src/tools/solodit-search-tool.ts +92 -14
- package/src/utils/audit-artifact-detector.ts +119 -0
- package/src/utils/dependency-scanner.ts +93 -0
- package/src/utils/project-detector.ts +128 -26
- package/src/utils/solidity-parser.ts +20 -4
- package/src/utils/solodit-health.ts +29 -0
|
@@ -3,6 +3,8 @@ import { tool, type ToolContext } from "@opencode-ai/plugin";
|
|
|
3
3
|
const SOLODIT_MCP_SERVER = "solodit-mcp";
|
|
4
4
|
const SOLODIT_MCP_TOOL = "search_findings";
|
|
5
5
|
const DEFAULT_LIMIT = 10;
|
|
6
|
+
const DEFAULT_SOLODIT_PORT = 3000;
|
|
7
|
+
const SOLODIT_HTTP_TIMEOUT_MS = 10_000;
|
|
6
8
|
|
|
7
9
|
type SoloditSearchArgs = {
|
|
8
10
|
query: string;
|
|
@@ -68,6 +70,91 @@ function parseFindings(response: unknown): SoloditFinding[] {
|
|
|
68
70
|
return response.map(parseFinding);
|
|
69
71
|
}
|
|
70
72
|
|
|
73
|
+
function parseSseData(body: string): unknown {
|
|
74
|
+
for (const line of body.split("\n")) {
|
|
75
|
+
if (line.startsWith("data: ")) {
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(line.slice(6));
|
|
78
|
+
} catch {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(body);
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function extractFindingsFromMcpResponse(envelope: unknown): SoloditFinding[] {
|
|
91
|
+
if (typeof envelope !== "object" || envelope === null) return [];
|
|
92
|
+
const result = (envelope as Record<string, unknown>).result;
|
|
93
|
+
if (typeof result !== "object" || result === null) return [];
|
|
94
|
+
|
|
95
|
+
const structured = (result as Record<string, unknown>).structuredContent;
|
|
96
|
+
const reportsJson =
|
|
97
|
+
typeof structured === "object" && structured !== null
|
|
98
|
+
? (structured as Record<string, unknown>).reportsJSON
|
|
99
|
+
: undefined;
|
|
100
|
+
|
|
101
|
+
if (typeof reportsJson === "string") {
|
|
102
|
+
try {
|
|
103
|
+
const parsed = JSON.parse(reportsJson);
|
|
104
|
+
if (Array.isArray(parsed)) return parsed.map(parseFinding);
|
|
105
|
+
} catch { /* fall through */ }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const content = (result as Record<string, unknown>).content;
|
|
109
|
+
if (Array.isArray(content) && content.length > 0) {
|
|
110
|
+
const first = content[0] as Record<string, unknown> | undefined;
|
|
111
|
+
if (typeof first?.text === "string") {
|
|
112
|
+
try {
|
|
113
|
+
const parsed = JSON.parse(first.text);
|
|
114
|
+
if (Array.isArray(parsed)) return parsed.map(parseFinding);
|
|
115
|
+
} catch { /* fall through */ }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function callSoloditHttp(
|
|
123
|
+
query: string,
|
|
124
|
+
limit: number,
|
|
125
|
+
port: number = DEFAULT_SOLODIT_PORT,
|
|
126
|
+
): Promise<SoloditSearchResult> {
|
|
127
|
+
try {
|
|
128
|
+
const response = await fetch(`http://localhost:${port}/mcp`, {
|
|
129
|
+
method: "POST",
|
|
130
|
+
headers: {
|
|
131
|
+
"Content-Type": "application/json",
|
|
132
|
+
Accept: "application/json, text/event-stream",
|
|
133
|
+
},
|
|
134
|
+
body: JSON.stringify({
|
|
135
|
+
jsonrpc: "2.0",
|
|
136
|
+
method: "tools/call",
|
|
137
|
+
params: { name: "search", arguments: { keywords: query } },
|
|
138
|
+
id: 1,
|
|
139
|
+
}),
|
|
140
|
+
signal: AbortSignal.timeout(SOLODIT_HTTP_TIMEOUT_MS),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (!response.ok) {
|
|
144
|
+
return { results: [], totalFound: 0, query, error: `Solodit HTTP ${response.status}` };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const body = await response.text();
|
|
148
|
+
const envelope = parseSseData(body);
|
|
149
|
+
const findings = extractFindingsFromMcpResponse(envelope);
|
|
150
|
+
|
|
151
|
+
return { results: findings.slice(0, limit), totalFound: findings.length, query };
|
|
152
|
+
} catch (error) {
|
|
153
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
154
|
+
return { results: [], totalFound: 0, query, error: `Solodit MCP unreachable: ${message}` };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
71
158
|
export async function executeSoloditSearch(
|
|
72
159
|
args: SoloditSearchArgs,
|
|
73
160
|
context: ToolContext,
|
|
@@ -82,12 +169,7 @@ export async function executeSoloditSearch(
|
|
|
82
169
|
callMcpTool ?? (hasMcpCapability(context) ? context.callMcpTool : undefined);
|
|
83
170
|
|
|
84
171
|
if (!mcpCaller) {
|
|
85
|
-
return
|
|
86
|
-
results: [],
|
|
87
|
-
totalFound: 0,
|
|
88
|
-
query,
|
|
89
|
-
error: `Solodit MCP not available. Add to opencode.json mcp section or ensure solodit-mcp is running. Use @solodit-mcp directly: search_findings({query: '${query}', limit: ${limit}})`,
|
|
90
|
-
};
|
|
172
|
+
return callSoloditHttp(query, limit);
|
|
91
173
|
}
|
|
92
174
|
|
|
93
175
|
try {
|
|
@@ -105,14 +187,10 @@ export async function executeSoloditSearch(
|
|
|
105
187
|
totalFound: findings.length,
|
|
106
188
|
query,
|
|
107
189
|
};
|
|
108
|
-
} catch
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
totalFound: 0,
|
|
113
|
-
query,
|
|
114
|
-
error: `Solodit MCP error: ${message}`,
|
|
115
|
-
};
|
|
190
|
+
} catch {
|
|
191
|
+
// MCP bridge failed (upstream crash, connection error, etc.)
|
|
192
|
+
// Fall through to HTTP fallback before giving up
|
|
193
|
+
return callSoloditHttp(query, limit);
|
|
116
194
|
}
|
|
117
195
|
}
|
|
118
196
|
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
export interface AuditArtifact {
|
|
5
|
+
type: "audit-report" | "slither-output" | "deployment-artifact" | "security-tool-output";
|
|
6
|
+
path: string;
|
|
7
|
+
name: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Detects audit artifacts in a project directory (shallow scan, top-level only)
|
|
12
|
+
* @param projectDir Directory to scan for audit artifacts
|
|
13
|
+
* @returns Array of detected audit artifacts
|
|
14
|
+
*/
|
|
15
|
+
export function detectAuditArtifacts(projectDir: string): AuditArtifact[] {
|
|
16
|
+
const artifacts: AuditArtifact[] = [];
|
|
17
|
+
|
|
18
|
+
if (!existsSync(projectDir)) {
|
|
19
|
+
return artifacts;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const entries = readdirSync(projectDir, { withFileTypes: true });
|
|
24
|
+
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
const fullPath = join(projectDir, entry.name);
|
|
27
|
+
|
|
28
|
+
// Check directories
|
|
29
|
+
if (entry.isDirectory()) {
|
|
30
|
+
// Audit report directories
|
|
31
|
+
if (["audit", "audits", "security"].includes(entry.name)) {
|
|
32
|
+
artifacts.push({
|
|
33
|
+
type: "audit-report",
|
|
34
|
+
path: fullPath,
|
|
35
|
+
name: entry.name,
|
|
36
|
+
});
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Deployment artifact directories
|
|
41
|
+
if (entry.name === ".openzeppelin") {
|
|
42
|
+
artifacts.push({
|
|
43
|
+
type: "deployment-artifact",
|
|
44
|
+
path: fullPath,
|
|
45
|
+
name: entry.name,
|
|
46
|
+
});
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// docs/audit* directories
|
|
51
|
+
if (entry.name === "docs") {
|
|
52
|
+
try {
|
|
53
|
+
const docsEntries = readdirSync(fullPath, { withFileTypes: true });
|
|
54
|
+
for (const docsEntry of docsEntries) {
|
|
55
|
+
if (docsEntry.isDirectory() && docsEntry.name.startsWith("audit")) {
|
|
56
|
+
artifacts.push({
|
|
57
|
+
type: "audit-report",
|
|
58
|
+
path: join(fullPath, docsEntry.name),
|
|
59
|
+
name: docsEntry.name,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// Ignore errors reading docs directory
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Check files
|
|
71
|
+
if (entry.isFile()) {
|
|
72
|
+
// Audit report files
|
|
73
|
+
if (
|
|
74
|
+
/^.*audit.*\.(md|pdf)$/i.test(entry.name) ||
|
|
75
|
+
/^.*security-review.*\.(md|pdf)$/i.test(entry.name)
|
|
76
|
+
) {
|
|
77
|
+
artifacts.push({
|
|
78
|
+
type: "audit-report",
|
|
79
|
+
path: fullPath,
|
|
80
|
+
name: entry.name,
|
|
81
|
+
});
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Slither output files
|
|
86
|
+
if (
|
|
87
|
+
entry.name === "slither.json" ||
|
|
88
|
+
entry.name === "slither.sarif" ||
|
|
89
|
+
/^slither-report.*/.test(entry.name)
|
|
90
|
+
) {
|
|
91
|
+
artifacts.push({
|
|
92
|
+
type: "slither-output",
|
|
93
|
+
path: fullPath,
|
|
94
|
+
name: entry.name,
|
|
95
|
+
});
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Security tool output files
|
|
100
|
+
if (
|
|
101
|
+
/^mythril-report.*/.test(entry.name) ||
|
|
102
|
+
/^securify-report.*/.test(entry.name)
|
|
103
|
+
) {
|
|
104
|
+
artifacts.push({
|
|
105
|
+
type: "security-tool-output",
|
|
106
|
+
path: fullPath,
|
|
107
|
+
name: entry.name,
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
// Return empty array if directory cannot be read
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return artifacts;
|
|
119
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export interface DependencyRisk {
|
|
2
|
+
package: string;
|
|
3
|
+
version: string;
|
|
4
|
+
risk: "high" | "medium" | "low";
|
|
5
|
+
category: string;
|
|
6
|
+
recommendation: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface DependencyInput {
|
|
10
|
+
dependencies?: Record<string, string>;
|
|
11
|
+
devDependencies?: Record<string, string>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseVersion(raw: string): [number, number, number] {
|
|
15
|
+
const cleaned = raw.replace(/^[^0-9]*/, "");
|
|
16
|
+
const parts = cleaned.split(".");
|
|
17
|
+
return [
|
|
18
|
+
parseInt(parts[0] ?? "0", 10),
|
|
19
|
+
parseInt(parts[1] ?? "0", 10),
|
|
20
|
+
parseInt(parts[2] ?? "0", 10),
|
|
21
|
+
];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function versionLt(
|
|
25
|
+
raw: string,
|
|
26
|
+
major: number,
|
|
27
|
+
minor: number,
|
|
28
|
+
patch = 0
|
|
29
|
+
): boolean {
|
|
30
|
+
const [a, b, c] = parseVersion(raw);
|
|
31
|
+
if (a !== major) return a < major;
|
|
32
|
+
if (b !== minor) return b < minor;
|
|
33
|
+
return c < patch;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function scanDependencyRisks(input: DependencyInput): DependencyRisk[] {
|
|
37
|
+
const risks: DependencyRisk[] = [];
|
|
38
|
+
const deps = input.dependencies ?? {};
|
|
39
|
+
const devDeps = input.devDependencies ?? {};
|
|
40
|
+
const allDeps = { ...deps, ...devDeps };
|
|
41
|
+
|
|
42
|
+
const ozVersion = deps["@openzeppelin/contracts"];
|
|
43
|
+
if (ozVersion) {
|
|
44
|
+
if (versionLt(ozVersion, 4, 9)) {
|
|
45
|
+
risks.push({
|
|
46
|
+
package: "@openzeppelin/contracts",
|
|
47
|
+
version: ozVersion,
|
|
48
|
+
risk: "high",
|
|
49
|
+
category: "known-vulnerability",
|
|
50
|
+
recommendation:
|
|
51
|
+
"Upgrade to @openzeppelin/contracts >= 4.9.0 — known vulnerabilities in OZ < 4.9",
|
|
52
|
+
});
|
|
53
|
+
} else if (versionLt(ozVersion, 5, 0)) {
|
|
54
|
+
risks.push({
|
|
55
|
+
package: "@openzeppelin/contracts",
|
|
56
|
+
version: ozVersion,
|
|
57
|
+
risk: "low",
|
|
58
|
+
category: "upgrade-available",
|
|
59
|
+
recommendation:
|
|
60
|
+
"Consider upgrading to OZ v5 for latest patterns and Solidity 0.8.20+ support",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const ozUpgradeableVersion = deps["@openzeppelin/contracts-upgradeable"];
|
|
66
|
+
if (ozUpgradeableVersion) {
|
|
67
|
+
const hasUpgradeTooling =
|
|
68
|
+
"@openzeppelin/hardhat-upgrades" in allDeps;
|
|
69
|
+
if (!hasUpgradeTooling) {
|
|
70
|
+
risks.push({
|
|
71
|
+
package: "@openzeppelin/contracts-upgradeable",
|
|
72
|
+
version: ozUpgradeableVersion,
|
|
73
|
+
risk: "medium",
|
|
74
|
+
category: "missing-tooling",
|
|
75
|
+
recommendation:
|
|
76
|
+
"Add @openzeppelin/hardhat-upgrades to devDependencies for safe upgrade workflows",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const solmateVersion = deps["solmate"];
|
|
82
|
+
if (solmateVersion && versionLt(solmateVersion, 6, 0)) {
|
|
83
|
+
risks.push({
|
|
84
|
+
package: "solmate",
|
|
85
|
+
version: solmateVersion,
|
|
86
|
+
risk: "medium",
|
|
87
|
+
category: "outdated",
|
|
88
|
+
recommendation: "Upgrade solmate to >= 6.0.0 for latest fixes",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return risks;
|
|
93
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync } from "fs";
|
|
2
2
|
import { join, resolve } from "path";
|
|
3
|
+
import { scanDependencyRisks, type DependencyRisk } from "./dependency-scanner";
|
|
3
4
|
|
|
4
5
|
export interface ProjectConfig {
|
|
5
6
|
type: "foundry" | "hardhat" | "mixed" | "unknown";
|
|
@@ -9,6 +10,16 @@ export interface ProjectConfig {
|
|
|
9
10
|
remappings: string[];
|
|
10
11
|
viaIr: boolean;
|
|
11
12
|
rootDir: string;
|
|
13
|
+
optimizer?: { enabled: boolean; runs?: number };
|
|
14
|
+
evmVersion?: string;
|
|
15
|
+
profiles?: string[];
|
|
16
|
+
hasHardhat: boolean;
|
|
17
|
+
hasFoundry: boolean;
|
|
18
|
+
dependencies?: Record<string, string>;
|
|
19
|
+
devDependencies?: Record<string, string>;
|
|
20
|
+
isUpgradeable: boolean;
|
|
21
|
+
outDir?: string;
|
|
22
|
+
dependencyRisks: DependencyRisk[];
|
|
12
23
|
}
|
|
13
24
|
|
|
14
25
|
/**
|
|
@@ -39,14 +50,16 @@ export async function detectProject(dir: string): Promise<ProjectConfig> {
|
|
|
39
50
|
type = "unknown";
|
|
40
51
|
}
|
|
41
52
|
|
|
42
|
-
// Default values
|
|
43
53
|
let srcDir = "src";
|
|
44
54
|
let testDir = "test";
|
|
45
55
|
let solcVersion: string | undefined;
|
|
46
56
|
let remappings: string[] = [];
|
|
47
57
|
let viaIr = false;
|
|
58
|
+
let optimizer: { enabled: boolean; runs?: number } | undefined;
|
|
59
|
+
let evmVersion: string | undefined;
|
|
60
|
+
let profiles: string[] | undefined;
|
|
61
|
+
let outDir: string | undefined;
|
|
48
62
|
|
|
49
|
-
// Parse Foundry config if present
|
|
50
63
|
if (hasFoundry) {
|
|
51
64
|
const foundryConfig = await parseFoundryToml(foundryTomlPath);
|
|
52
65
|
srcDir = foundryConfig.srcDir || srcDir;
|
|
@@ -54,13 +67,25 @@ export async function detectProject(dir: string): Promise<ProjectConfig> {
|
|
|
54
67
|
solcVersion = foundryConfig.solcVersion;
|
|
55
68
|
remappings = foundryConfig.remappings;
|
|
56
69
|
viaIr = foundryConfig.viaIr;
|
|
70
|
+
optimizer = foundryConfig.optimizer;
|
|
71
|
+
evmVersion = foundryConfig.evmVersion;
|
|
72
|
+
profiles = foundryConfig.profiles;
|
|
73
|
+
outDir = foundryConfig.outDir;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const remappingsFromTxt = parseRemappingsTxt(rootDir);
|
|
77
|
+
if (remappingsFromTxt.length > 0 && remappings.length === 0) {
|
|
78
|
+
remappings = remappingsFromTxt;
|
|
57
79
|
}
|
|
58
80
|
|
|
59
|
-
// Set Hardhat defaults if it's a Hardhat project
|
|
60
81
|
if (hasHardhat && !hasFoundry) {
|
|
61
82
|
srcDir = "contracts";
|
|
62
83
|
}
|
|
63
84
|
|
|
85
|
+
const isUpgradeable = existsSync(join(rootDir, ".openzeppelin"));
|
|
86
|
+
|
|
87
|
+
const { dependencies, devDependencies } = await parsePackageJson(rootDir);
|
|
88
|
+
|
|
64
89
|
return {
|
|
65
90
|
type,
|
|
66
91
|
srcDir,
|
|
@@ -69,32 +94,53 @@ export async function detectProject(dir: string): Promise<ProjectConfig> {
|
|
|
69
94
|
remappings,
|
|
70
95
|
viaIr,
|
|
71
96
|
rootDir,
|
|
97
|
+
optimizer,
|
|
98
|
+
evmVersion,
|
|
99
|
+
profiles,
|
|
100
|
+
hasHardhat,
|
|
101
|
+
hasFoundry,
|
|
102
|
+
dependencies,
|
|
103
|
+
devDependencies,
|
|
104
|
+
isUpgradeable,
|
|
105
|
+
outDir,
|
|
106
|
+
dependencyRisks: scanDependencyRisks({ dependencies, devDependencies }),
|
|
72
107
|
};
|
|
73
108
|
}
|
|
74
109
|
|
|
75
110
|
/**
|
|
76
111
|
* Parses foundry.toml file using regex-based parsing
|
|
77
112
|
*/
|
|
78
|
-
|
|
79
|
-
filePath: string
|
|
80
|
-
): Promise<{
|
|
113
|
+
interface FoundryTomlResult {
|
|
81
114
|
srcDir?: string;
|
|
82
115
|
testDir?: string;
|
|
83
116
|
solcVersion?: string;
|
|
84
117
|
remappings: string[];
|
|
85
118
|
viaIr: boolean;
|
|
86
|
-
|
|
119
|
+
optimizer?: { enabled: boolean; runs?: number };
|
|
120
|
+
evmVersion?: string;
|
|
121
|
+
profiles?: string[];
|
|
122
|
+
outDir?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function parseFoundryToml(filePath: string): Promise<FoundryTomlResult> {
|
|
87
126
|
const content = await Bun.file(filePath).text();
|
|
88
127
|
|
|
89
|
-
const result = {
|
|
90
|
-
srcDir: undefined
|
|
91
|
-
testDir: undefined
|
|
92
|
-
solcVersion: undefined
|
|
93
|
-
remappings: []
|
|
128
|
+
const result: FoundryTomlResult = {
|
|
129
|
+
srcDir: undefined,
|
|
130
|
+
testDir: undefined,
|
|
131
|
+
solcVersion: undefined,
|
|
132
|
+
remappings: [],
|
|
94
133
|
viaIr: false,
|
|
95
134
|
};
|
|
96
135
|
|
|
97
|
-
|
|
136
|
+
const profileNames = Array.from(
|
|
137
|
+
content.matchAll(/\[profile\.(\w+)\]/g),
|
|
138
|
+
(m) => m[1]!
|
|
139
|
+
);
|
|
140
|
+
if (profileNames.length > 0) {
|
|
141
|
+
result.profiles = profileNames;
|
|
142
|
+
}
|
|
143
|
+
|
|
98
144
|
const profileDefaultMatch = content.match(
|
|
99
145
|
/\[profile\.default\]([\s\S]*?)(?:\n\[|$)/
|
|
100
146
|
);
|
|
@@ -104,38 +150,57 @@ async function parseFoundryToml(
|
|
|
104
150
|
|
|
105
151
|
const profileSection = profileDefaultMatch[1];
|
|
106
152
|
|
|
107
|
-
// Parse src = "..."
|
|
108
153
|
const srcMatch = profileSection.match(/^\s*src\s*=\s*["']([^"']+)["']/m);
|
|
109
|
-
if (srcMatch
|
|
154
|
+
if (srcMatch?.[1]) {
|
|
110
155
|
result.srcDir = srcMatch[1];
|
|
111
156
|
}
|
|
112
157
|
|
|
113
|
-
// Parse test = "..."
|
|
114
158
|
const testMatch = profileSection.match(/^\s*test\s*=\s*["']([^"']+)["']/m);
|
|
115
|
-
if (testMatch
|
|
159
|
+
if (testMatch?.[1]) {
|
|
116
160
|
result.testDir = testMatch[1];
|
|
117
161
|
}
|
|
118
162
|
|
|
119
|
-
// Parse solc = "..."
|
|
120
163
|
const solcMatch = profileSection.match(/^\s*solc\s*=\s*["']([^"']+)["']/m);
|
|
121
|
-
if (solcMatch
|
|
164
|
+
if (solcMatch?.[1]) {
|
|
122
165
|
result.solcVersion = solcMatch[1];
|
|
123
166
|
}
|
|
124
167
|
|
|
125
|
-
// Parse via_ir = true/false
|
|
126
168
|
const viaIrMatch = profileSection.match(/^\s*via[_-]ir\s*=\s*(true|false)/m);
|
|
127
|
-
if (viaIrMatch
|
|
169
|
+
if (viaIrMatch?.[1] === "true") {
|
|
128
170
|
result.viaIr = true;
|
|
129
171
|
}
|
|
130
172
|
|
|
131
|
-
|
|
173
|
+
const optimizerMatch = profileSection.match(
|
|
174
|
+
/^\s*optimizer\s*=\s*(true|false)/m
|
|
175
|
+
);
|
|
176
|
+
if (optimizerMatch?.[1]) {
|
|
177
|
+
const enabled = optimizerMatch[1] === "true";
|
|
178
|
+
const runsMatch = profileSection.match(
|
|
179
|
+
/^\s*optimizer_runs\s*=\s*(\d+)/m
|
|
180
|
+
);
|
|
181
|
+
result.optimizer = {
|
|
182
|
+
enabled,
|
|
183
|
+
runs: runsMatch?.[1] ? parseInt(runsMatch[1], 10) : undefined,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const evmMatch = profileSection.match(
|
|
188
|
+
/^\s*evm_version\s*=\s*["']([^"']+)["']/m
|
|
189
|
+
);
|
|
190
|
+
if (evmMatch?.[1]) {
|
|
191
|
+
result.evmVersion = evmMatch[1];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const outMatch = profileSection.match(/^\s*out\s*=\s*["']([^"']+)["']/m);
|
|
195
|
+
if (outMatch?.[1]) {
|
|
196
|
+
result.outDir = outMatch[1];
|
|
197
|
+
}
|
|
198
|
+
|
|
132
199
|
const remappingsMatch = profileSection.match(
|
|
133
200
|
/remappings\s*=\s*\[([\s\S]*?)\]/
|
|
134
201
|
);
|
|
135
|
-
if (remappingsMatch
|
|
136
|
-
const
|
|
137
|
-
// Extract quoted strings from the array
|
|
138
|
-
const remappingMatches = remappingsContent.match(/["']([^"']+)["']/g);
|
|
202
|
+
if (remappingsMatch?.[1]) {
|
|
203
|
+
const remappingMatches = remappingsMatch[1].match(/["']([^"']+)["']/g);
|
|
139
204
|
if (remappingMatches) {
|
|
140
205
|
result.remappings = remappingMatches.map((m) => m.slice(1, -1));
|
|
141
206
|
}
|
|
@@ -143,3 +208,40 @@ async function parseFoundryToml(
|
|
|
143
208
|
|
|
144
209
|
return result;
|
|
145
210
|
}
|
|
211
|
+
|
|
212
|
+
async function parsePackageJson(
|
|
213
|
+
rootDir: string
|
|
214
|
+
): Promise<{
|
|
215
|
+
dependencies?: Record<string, string>;
|
|
216
|
+
devDependencies?: Record<string, string>;
|
|
217
|
+
}> {
|
|
218
|
+
const pkgPath = join(rootDir, "package.json");
|
|
219
|
+
if (!existsSync(pkgPath)) {
|
|
220
|
+
return {};
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
const content = JSON.parse(await Bun.file(pkgPath).text());
|
|
224
|
+
return {
|
|
225
|
+
dependencies: content.dependencies,
|
|
226
|
+
devDependencies: content.devDependencies,
|
|
227
|
+
};
|
|
228
|
+
} catch {
|
|
229
|
+
return {};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function parseRemappingsTxt(rootDir: string): string[] {
|
|
234
|
+
const remappingsPath = join(rootDir, "remappings.txt");
|
|
235
|
+
if (!existsSync(remappingsPath)) {
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
const content = require("fs").readFileSync(remappingsPath, "utf-8");
|
|
240
|
+
return content
|
|
241
|
+
.split("\n")
|
|
242
|
+
.map((line: string) => line.trim())
|
|
243
|
+
.filter((line: string) => line.length > 0);
|
|
244
|
+
} catch {
|
|
245
|
+
return [];
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -19,6 +19,20 @@ interface StorageLayout {
|
|
|
19
19
|
types: Record<string, { label: string }>;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Extract the first JSON value from a string that may contain non-JSON
|
|
24
|
+
* prefix (e.g. forge table-format output, compilation progress).
|
|
25
|
+
* Falls back to the original string if no JSON delimiter is found.
|
|
26
|
+
*/
|
|
27
|
+
function extractJson(raw: string, opener: "[" | "{"): string {
|
|
28
|
+
const closer = opener === "[" ? "]" : "}";
|
|
29
|
+
const start = raw.indexOf(opener);
|
|
30
|
+
if (start === -1) return raw;
|
|
31
|
+
const end = raw.lastIndexOf(closer);
|
|
32
|
+
if (end === -1) return raw;
|
|
33
|
+
return raw.slice(start, end + 1);
|
|
34
|
+
}
|
|
35
|
+
|
|
22
36
|
/**
|
|
23
37
|
* Extract contract information using forge inspect
|
|
24
38
|
* Runs forge inspect <contractName> abi and storage-layout
|
|
@@ -43,7 +57,7 @@ export async function extractContractInfo(
|
|
|
43
57
|
try {
|
|
44
58
|
// Run forge inspect abi
|
|
45
59
|
const abiResult = Bun.spawnSync(
|
|
46
|
-
["forge", "inspect", contractName, "abi"],
|
|
60
|
+
["forge", "inspect", contractName, "abi", "--json"],
|
|
47
61
|
{
|
|
48
62
|
cwd: projectDir,
|
|
49
63
|
stdout: "pipe",
|
|
@@ -59,7 +73,7 @@ export async function extractContractInfo(
|
|
|
59
73
|
|
|
60
74
|
// Run forge inspect storage-layout
|
|
61
75
|
const storageResult = Bun.spawnSync(
|
|
62
|
-
["forge", "inspect", contractName, "storage-layout"],
|
|
76
|
+
["forge", "inspect", contractName, "storage-layout", "--json"],
|
|
63
77
|
{
|
|
64
78
|
cwd: projectDir,
|
|
65
79
|
stdout: "pipe",
|
|
@@ -74,7 +88,8 @@ export async function extractContractInfo(
|
|
|
74
88
|
}
|
|
75
89
|
|
|
76
90
|
// Parse ABI
|
|
77
|
-
const
|
|
91
|
+
const abiRaw = abiResult.stdout?.toString() || "[]";
|
|
92
|
+
const abiOutput = extractJson(abiRaw, "[");
|
|
78
93
|
let abi: ABIFunction[] = [];
|
|
79
94
|
try {
|
|
80
95
|
abi = JSON.parse(abiOutput);
|
|
@@ -84,7 +99,8 @@ export async function extractContractInfo(
|
|
|
84
99
|
}
|
|
85
100
|
|
|
86
101
|
// Parse storage layout
|
|
87
|
-
const
|
|
102
|
+
const storageRaw = storageResult.stdout?.toString() || "{}";
|
|
103
|
+
const storageOutput = extractJson(storageRaw, "{");
|
|
88
104
|
let storageLayout: StorageLayout = { storage: [], types: {} };
|
|
89
105
|
try {
|
|
90
106
|
storageLayout = JSON.parse(storageOutput);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface SoloditHealthStatus {
|
|
2
|
+
reachable: boolean
|
|
3
|
+
enabled: boolean
|
|
4
|
+
port: number
|
|
5
|
+
error?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function checkSoloditHealth(
|
|
9
|
+
port: number,
|
|
10
|
+
enabled: boolean,
|
|
11
|
+
): Promise<SoloditHealthStatus> {
|
|
12
|
+
if (!enabled) {
|
|
13
|
+
return { reachable: false, enabled: false, port }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const response = await fetch(`http://localhost:${port}/mcp`, {
|
|
18
|
+
signal: AbortSignal.timeout(2000),
|
|
19
|
+
})
|
|
20
|
+
return { reachable: response.ok, enabled: true, port }
|
|
21
|
+
} catch (error) {
|
|
22
|
+
return {
|
|
23
|
+
reachable: false,
|
|
24
|
+
enabled: true,
|
|
25
|
+
port,
|
|
26
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|