solidity-argus 0.1.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/AGENTS.md +37 -0
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/package.json +43 -0
- package/skills/INVENTORY.md +79 -0
- package/skills/README.md +56 -0
- package/skills/checklists/cyfrin-best-practices-runtime/SKILL.md +424 -0
- package/skills/checklists/cyfrin-best-practices-upgrades/SKILL.md +157 -0
- package/skills/checklists/cyfrin-defi-core/SKILL.md +373 -0
- package/skills/checklists/cyfrin-defi-integrations/SKILL.md +412 -0
- package/skills/checklists/cyfrin-gas/SKILL.md +55 -0
- package/skills/checklists/general-audit/SKILL.md +433 -0
- package/skills/methodology/audit-workflow/SKILL.md +129 -0
- package/skills/methodology/report-template/SKILL.md +190 -0
- package/skills/methodology/severity-classification/SKILL.md +179 -0
- package/skills/protocol-patterns/amm-dex/SKILL.md +229 -0
- package/skills/protocol-patterns/bridges-cross-chain/SKILL.md +317 -0
- package/skills/protocol-patterns/dao-governance/SKILL.md +281 -0
- package/skills/protocol-patterns/lending-borrowing/SKILL.md +221 -0
- package/skills/protocol-patterns/staking-vesting/SKILL.md +247 -0
- package/skills/references/exploit-reference/SKILL.md +259 -0
- package/skills/references/smartbugs-examples/SKILL.md +296 -0
- package/skills/vulnerability-patterns/access-control/SKILL.md +298 -0
- package/skills/vulnerability-patterns/arbitrary-storage-location/SKILL.md +59 -0
- package/skills/vulnerability-patterns/assert-violation/SKILL.md +59 -0
- package/skills/vulnerability-patterns/asserting-contract-from-code-size/SKILL.md +61 -0
- package/skills/vulnerability-patterns/authorization-txorigin/SKILL.md +55 -0
- package/skills/vulnerability-patterns/default-visibility/SKILL.md +62 -0
- package/skills/vulnerability-patterns/delegatecall-untrusted-callee/SKILL.md +60 -0
- package/skills/vulnerability-patterns/dos-gas-limit/SKILL.md +59 -0
- package/skills/vulnerability-patterns/dos-revert/SKILL.md +72 -0
- package/skills/vulnerability-patterns/flash-loan-attacks/SKILL.md +249 -0
- package/skills/vulnerability-patterns/floating-pragma/SKILL.md +51 -0
- package/skills/vulnerability-patterns/hash-collision/SKILL.md +52 -0
- package/skills/vulnerability-patterns/inadherence-to-standards/SKILL.md +61 -0
- package/skills/vulnerability-patterns/incorrect-constructor/SKILL.md +60 -0
- package/skills/vulnerability-patterns/incorrect-inheritance-order/SKILL.md +59 -0
- package/skills/vulnerability-patterns/insufficient-gas-griefing/SKILL.md +61 -0
- package/skills/vulnerability-patterns/lack-of-precision/SKILL.md +61 -0
- package/skills/vulnerability-patterns/logic-errors/SKILL.md +333 -0
- package/skills/vulnerability-patterns/missing-protection-signature-replay/SKILL.md +60 -0
- package/skills/vulnerability-patterns/msgvalue-loop/SKILL.md +66 -0
- package/skills/vulnerability-patterns/off-by-one/SKILL.md +67 -0
- package/skills/vulnerability-patterns/oracle-manipulation/SKILL.md +252 -0
- package/skills/vulnerability-patterns/outdated-compiler-version/SKILL.md +65 -0
- package/skills/vulnerability-patterns/overflow-underflow/SKILL.md +61 -0
- package/skills/vulnerability-patterns/reentrancy/SKILL.md +266 -0
- package/skills/vulnerability-patterns/shadowing-state-variables/SKILL.md +72 -0
- package/skills/vulnerability-patterns/signature-malleability/SKILL.md +59 -0
- package/skills/vulnerability-patterns/unbounded-return-data/SKILL.md +63 -0
- package/skills/vulnerability-patterns/unchecked-return-values/SKILL.md +52 -0
- package/skills/vulnerability-patterns/unencrypted-private-data-on-chain/SKILL.md +65 -0
- package/skills/vulnerability-patterns/unexpected-ecrecover-null-address/SKILL.md +61 -0
- package/skills/vulnerability-patterns/uninitialized-storage-pointer/SKILL.md +63 -0
- package/skills/vulnerability-patterns/unsafe-low-level-call/SKILL.md +56 -0
- package/skills/vulnerability-patterns/unsecure-signatures/SKILL.md +80 -0
- package/skills/vulnerability-patterns/unsupported-opcodes/SKILL.md +69 -0
- package/skills/vulnerability-patterns/unused-variables/SKILL.md +70 -0
- package/skills/vulnerability-patterns/use-of-deprecated-functions/SKILL.md +81 -0
- package/skills/vulnerability-patterns/weak-sources-randomness/SKILL.md +77 -0
- package/skills/vulnerability-patterns/weird-tokens/SKILL.md +294 -0
- package/src/agents/argus-prompt.ts +407 -0
- package/src/agents/pythia-prompt.ts +134 -0
- package/src/agents/scribe-prompt.ts +87 -0
- package/src/agents/sentinel-prompt.ts +133 -0
- package/src/cli/cli-program.ts +67 -0
- package/src/cli/commands/doctor.ts +83 -0
- package/src/cli/commands/init.ts +46 -0
- package/src/cli/commands/install.ts +55 -0
- package/src/cli/index.ts +13 -0
- package/src/cli/tui-prompts.ts +75 -0
- package/src/cli/types.ts +9 -0
- package/src/config/index.ts +3 -0
- package/src/config/loader.ts +36 -0
- package/src/config/schema.ts +82 -0
- package/src/config/types.ts +4 -0
- package/src/constants/defaults.ts +6 -0
- package/src/create-hooks.ts +84 -0
- package/src/create-managers.ts +26 -0
- package/src/create-tools.ts +30 -0
- package/src/features/audit-enforcer/audit-enforcer.ts +34 -0
- package/src/features/audit-enforcer/index.ts +1 -0
- package/src/features/background-agent/background-manager.ts +200 -0
- package/src/features/background-agent/index.ts +1 -0
- package/src/features/context-monitor/context-monitor.ts +48 -0
- package/src/features/context-monitor/index.ts +4 -0
- package/src/features/context-monitor/tool-output-truncator.ts +17 -0
- package/src/features/error-recovery/index.ts +2 -0
- package/src/features/error-recovery/session-recovery.ts +27 -0
- package/src/features/error-recovery/tool-error-recovery.ts +35 -0
- package/src/features/index.ts +5 -0
- package/src/features/persistent-state/audit-state-manager.ts +121 -0
- package/src/features/persistent-state/index.ts +1 -0
- package/src/hooks/compaction-hook.ts +50 -0
- package/src/hooks/config-handler.ts +116 -0
- package/src/hooks/event-hook-v2.ts +93 -0
- package/src/hooks/event-hook.ts +74 -0
- package/src/hooks/hook-system.ts +9 -0
- package/src/hooks/index.ts +5 -0
- package/src/hooks/knowledge-sync-hook.ts +57 -0
- package/src/hooks/safe-create-hook.ts +15 -0
- package/src/hooks/system-prompt-hook.ts +126 -0
- package/src/hooks/tool-tracking-hook.ts +234 -0
- package/src/hooks/types.ts +16 -0
- package/src/index.ts +36 -0
- package/src/knowledge/scvd-client.ts +242 -0
- package/src/knowledge/scvd-index.ts +183 -0
- package/src/knowledge/scvd-sync.ts +85 -0
- package/src/managers/index.ts +1 -0
- package/src/managers/types.ts +85 -0
- package/src/plugin-interface.ts +38 -0
- package/src/shared/binary-utils.ts +63 -0
- package/src/shared/deep-merge.ts +71 -0
- package/src/shared/file-utils.ts +56 -0
- package/src/shared/index.ts +5 -0
- package/src/shared/jsonc-parser.ts +39 -0
- package/src/shared/logger.ts +36 -0
- package/src/state/audit-state.ts +27 -0
- package/src/state/finding-store.ts +126 -0
- package/src/state/plugin-state.ts +14 -0
- package/src/state/types.ts +61 -0
- package/src/tools/contract-analyzer-tool.ts +184 -0
- package/src/tools/forge-fuzz-tool.ts +311 -0
- package/src/tools/forge-test-tool.ts +397 -0
- package/src/tools/pattern-checker-tool.ts +337 -0
- package/src/tools/report-generator-tool.ts +308 -0
- package/src/tools/slither-tool.ts +465 -0
- package/src/tools/solodit-search-tool.ts +131 -0
- package/src/tools/sync-knowledge-tool.ts +116 -0
- package/src/utils/project-detector.ts +133 -0
- package/src/utils/solidity-parser.ts +174 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export type FindingSeverity = "Critical" | "High" | "Medium" | "Low" | "Informational";
|
|
2
|
+
export type AuditPhase = "reconnaissance" | "scanning" | "manual-review" | "attack-surface" | "research" | "testing" | "reporting" | "complete";
|
|
3
|
+
|
|
4
|
+
export interface Finding {
|
|
5
|
+
id: string; // unique hash: check+file+lines
|
|
6
|
+
check: string; // detector name e.g. "reentrancy-eth"
|
|
7
|
+
severity: FindingSeverity;
|
|
8
|
+
confidence: "High" | "Medium" | "Low";
|
|
9
|
+
description: string;
|
|
10
|
+
file: string; // relative file path
|
|
11
|
+
lines: [number, number]; // [start, end]
|
|
12
|
+
source: "slither" | "manual" | "pattern" | "scvd";
|
|
13
|
+
remediation?: string;
|
|
14
|
+
exploitReference?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ContractProfile {
|
|
18
|
+
name: string;
|
|
19
|
+
filePath: string;
|
|
20
|
+
functions: Array<{
|
|
21
|
+
name: string;
|
|
22
|
+
visibility: string;
|
|
23
|
+
mutability: string;
|
|
24
|
+
modifiers: string[];
|
|
25
|
+
}>;
|
|
26
|
+
stateVars: Array<{
|
|
27
|
+
name: string;
|
|
28
|
+
type: string;
|
|
29
|
+
visibility: string;
|
|
30
|
+
}>;
|
|
31
|
+
inheritance: string[];
|
|
32
|
+
accessControlPattern?: "ownable" | "access-control" | "custom" | "none";
|
|
33
|
+
externalCalls: string[];
|
|
34
|
+
riskIndicators: string[];
|
|
35
|
+
error?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ToolExecution {
|
|
39
|
+
tool: string;
|
|
40
|
+
startTime: number;
|
|
41
|
+
endTime?: number;
|
|
42
|
+
success: boolean;
|
|
43
|
+
findingsCount: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AuditState {
|
|
47
|
+
sessionId: string;
|
|
48
|
+
projectDir: string;
|
|
49
|
+
contractsReviewed: string[];
|
|
50
|
+
findings: Finding[];
|
|
51
|
+
toolsExecuted: ToolExecution[];
|
|
52
|
+
currentPhase: AuditPhase;
|
|
53
|
+
scope: string[];
|
|
54
|
+
startTime: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface PersistentAuditState extends AuditState {
|
|
58
|
+
savedAt: number;
|
|
59
|
+
version: string;
|
|
60
|
+
filePath: string;
|
|
61
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { dirname, basename, join } from "node:path";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { tool, type ToolContext } from "@opencode-ai/plugin";
|
|
4
|
+
import { extractContractInfo } from "../utils/solidity-parser";
|
|
5
|
+
import type { ContractProfile } from "../state/types";
|
|
6
|
+
|
|
7
|
+
type ContractAnalyzerArgs = {
|
|
8
|
+
file_path: string;
|
|
9
|
+
project_dir?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type ExtractContractInfoFn = (
|
|
13
|
+
contractName: string,
|
|
14
|
+
projectDir: string
|
|
15
|
+
) => Promise<ContractProfile>;
|
|
16
|
+
|
|
17
|
+
type ContractAnalyzerDependencies = {
|
|
18
|
+
extractInfo: ExtractContractInfoFn;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const DEFAULT_DEPENDENCIES: ContractAnalyzerDependencies = {
|
|
22
|
+
extractInfo: extractContractInfo,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function createFailureProfile(contractName: string, filePath: string, error: string): ContractProfile {
|
|
26
|
+
return {
|
|
27
|
+
name: contractName,
|
|
28
|
+
filePath,
|
|
29
|
+
functions: [],
|
|
30
|
+
stateVars: [],
|
|
31
|
+
inheritance: [],
|
|
32
|
+
accessControlPattern: "none",
|
|
33
|
+
externalCalls: [],
|
|
34
|
+
riskIndicators: [],
|
|
35
|
+
error,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function findFoundryProjectDir(fromPath: string): string {
|
|
40
|
+
let current = dirname(fromPath);
|
|
41
|
+
|
|
42
|
+
while (true) {
|
|
43
|
+
if (existsSync(join(current, "foundry.toml"))) {
|
|
44
|
+
return current;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const parent = dirname(current);
|
|
48
|
+
if (parent === current) {
|
|
49
|
+
return dirname(fromPath);
|
|
50
|
+
}
|
|
51
|
+
current = parent;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function addIndicator(indicators: Set<string>, source: string, indicator: string): void {
|
|
56
|
+
if (source.includes(indicator.split("uses-")[1] ?? "")) {
|
|
57
|
+
indicators.add(indicator);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function collectRiskIndicators(source: string, existing: string[]): string[] {
|
|
62
|
+
const indicators = new Set(existing);
|
|
63
|
+
const normalized = source.toLowerCase();
|
|
64
|
+
|
|
65
|
+
addIndicator(indicators, normalized, "uses-delegatecall");
|
|
66
|
+
addIndicator(indicators, normalized, "uses-selfdestruct");
|
|
67
|
+
if (/\bassembly\b/.test(normalized)) {
|
|
68
|
+
indicators.add("uses-assembly");
|
|
69
|
+
}
|
|
70
|
+
if (/\btx\.origin\b/.test(normalized)) {
|
|
71
|
+
indicators.add("uses-tx-origin");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const importLines = source
|
|
75
|
+
.split("\n")
|
|
76
|
+
.map((line) => line.trim())
|
|
77
|
+
.filter((line) => line.startsWith("import "));
|
|
78
|
+
const importText = importLines.join("\n");
|
|
79
|
+
|
|
80
|
+
const ozChecks: Array<{ pattern: RegExp; indicator: string }> = [
|
|
81
|
+
{ pattern: /\bReentrancyGuard\b/, indicator: "uses-oz-reentrancy-guard" },
|
|
82
|
+
{ pattern: /\bAccessControl\b/, indicator: "uses-oz-access-control" },
|
|
83
|
+
{ pattern: /\bOwnable\b/, indicator: "uses-oz-ownable" },
|
|
84
|
+
{ pattern: /\bPausable\b/, indicator: "uses-oz-pausable" },
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
for (const check of ozChecks) {
|
|
88
|
+
if (check.pattern.test(importText)) {
|
|
89
|
+
indicators.add(check.indicator);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return [...indicators];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function withAbort<T>(signal: AbortSignal, operation: Promise<T>): Promise<T> {
|
|
97
|
+
if (signal.aborted) {
|
|
98
|
+
return Promise.reject(new DOMException("Aborted", "AbortError"));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return new Promise<T>((resolve, reject) => {
|
|
102
|
+
const onAbort = () => {
|
|
103
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
107
|
+
operation.then(
|
|
108
|
+
(value) => {
|
|
109
|
+
signal.removeEventListener("abort", onAbort);
|
|
110
|
+
resolve(value);
|
|
111
|
+
},
|
|
112
|
+
(error) => {
|
|
113
|
+
signal.removeEventListener("abort", onAbort);
|
|
114
|
+
reject(error);
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function executeContractAnalyzer(
|
|
121
|
+
args: ContractAnalyzerArgs,
|
|
122
|
+
context: ToolContext,
|
|
123
|
+
dependencies: ContractAnalyzerDependencies = DEFAULT_DEPENDENCIES
|
|
124
|
+
): Promise<ContractProfile> {
|
|
125
|
+
const filePath = args.file_path;
|
|
126
|
+
const contractName = basename(filePath, ".sol");
|
|
127
|
+
|
|
128
|
+
context.metadata({ title: `Analyze contract: ${contractName}` });
|
|
129
|
+
|
|
130
|
+
if (!existsSync(filePath)) {
|
|
131
|
+
return createFailureProfile(contractName, filePath, `Contract file not found: ${filePath}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const projectDir = args.project_dir ?? findFoundryProjectDir(filePath);
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const [contractProfile, sourceText] = await withAbort(
|
|
138
|
+
context.abort,
|
|
139
|
+
Promise.all([
|
|
140
|
+
dependencies.extractInfo(contractName, projectDir),
|
|
141
|
+
Bun.file(filePath).text(),
|
|
142
|
+
])
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
if (context.abort.aborted) {
|
|
146
|
+
return createFailureProfile(contractName, filePath, "contract analysis aborted");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
...contractProfile,
|
|
151
|
+
name: contractProfile.name || contractName,
|
|
152
|
+
filePath,
|
|
153
|
+
riskIndicators: collectRiskIndicators(sourceText, contractProfile.riskIndicators),
|
|
154
|
+
};
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (context.abort.aborted || (error instanceof DOMException && error.name === "AbortError")) {
|
|
157
|
+
return createFailureProfile(contractName, filePath, "contract analysis aborted");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const maybeError = error as Error & { code?: string };
|
|
161
|
+
if (maybeError.code === "ENOENT") {
|
|
162
|
+
return createFailureProfile(
|
|
163
|
+
contractName,
|
|
164
|
+
filePath,
|
|
165
|
+
"Foundry not found. Install: curl -L https://foundry.paradigm.xyz | bash"
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const message = maybeError.message || "contract analysis failed";
|
|
170
|
+
return createFailureProfile(contractName, filePath, message);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const contractAnalyzerTool = tool({
|
|
175
|
+
description: "Analyze a Solidity contract and return a normalized ContractProfile.",
|
|
176
|
+
args: {
|
|
177
|
+
file_path: tool.schema.string(),
|
|
178
|
+
project_dir: tool.schema.string().optional(),
|
|
179
|
+
},
|
|
180
|
+
async execute(args, context) {
|
|
181
|
+
const contractProfile = await executeContractAnalyzer(args, context);
|
|
182
|
+
return JSON.stringify(contractProfile);
|
|
183
|
+
},
|
|
184
|
+
});
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { tool, type ToolContext } from "@opencode-ai/plugin";
|
|
2
|
+
|
|
3
|
+
type ForgeFuzzArgs = {
|
|
4
|
+
target?: string;
|
|
5
|
+
match_test?: string;
|
|
6
|
+
runs?: number;
|
|
7
|
+
seed?: number;
|
|
8
|
+
fork_url?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type NormalizedForgeFuzzArgs = {
|
|
12
|
+
target: string;
|
|
13
|
+
match_test?: string;
|
|
14
|
+
runs: number;
|
|
15
|
+
seed?: number;
|
|
16
|
+
fork_url?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type ForgeFuzzResultItem = {
|
|
20
|
+
testName: string;
|
|
21
|
+
status: "pass" | "fail";
|
|
22
|
+
runs: number;
|
|
23
|
+
gas: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type ForgeFuzzCounterexample = {
|
|
27
|
+
testName: string;
|
|
28
|
+
inputs: Record<string, string>;
|
|
29
|
+
revertReason?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type ForgeFuzzResult = {
|
|
33
|
+
success: boolean;
|
|
34
|
+
results: ForgeFuzzResultItem[];
|
|
35
|
+
counterexamples: ForgeFuzzCounterexample[];
|
|
36
|
+
totalRuns: number;
|
|
37
|
+
executionTime: number;
|
|
38
|
+
error?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type ForgeFuzzCommandResult = {
|
|
42
|
+
stdout: string;
|
|
43
|
+
stderr: string;
|
|
44
|
+
exitCode: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type RunForgeFuzzCommand = (
|
|
48
|
+
command: string[],
|
|
49
|
+
signal: AbortSignal,
|
|
50
|
+
cwd: string,
|
|
51
|
+
env: Record<string, string>
|
|
52
|
+
) => Promise<ForgeFuzzCommandResult>;
|
|
53
|
+
|
|
54
|
+
function normalizeArgs(args: ForgeFuzzArgs): NormalizedForgeFuzzArgs {
|
|
55
|
+
const requestedRuns = typeof args.runs === "number" && Number.isFinite(args.runs) ? args.runs : 256;
|
|
56
|
+
const clampedRuns = Math.max(1, Math.min(10000, Math.floor(requestedRuns)));
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
target: args.target ?? ".",
|
|
60
|
+
match_test: args.match_test,
|
|
61
|
+
runs: clampedRuns,
|
|
62
|
+
seed: args.seed,
|
|
63
|
+
fork_url: args.fork_url,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildForgeFuzzCommand(args: NormalizedForgeFuzzArgs): string[] {
|
|
68
|
+
const command = ["forge", "test", "--fuzz-runs", String(args.runs)];
|
|
69
|
+
|
|
70
|
+
if (args.match_test) {
|
|
71
|
+
command.push("--match-test", args.match_test);
|
|
72
|
+
}
|
|
73
|
+
if (typeof args.seed === "number" && Number.isFinite(args.seed)) {
|
|
74
|
+
command.push("--fuzz-seed", String(Math.floor(args.seed)));
|
|
75
|
+
}
|
|
76
|
+
if (args.fork_url) {
|
|
77
|
+
command.push("--fork-url", args.fork_url);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
command.push("-v");
|
|
81
|
+
return command;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseNumber(input?: string): number {
|
|
85
|
+
if (!input) {
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
const normalized = input.replaceAll("_", "").trim();
|
|
89
|
+
const value = Number.parseInt(normalized, 10);
|
|
90
|
+
return Number.isFinite(value) ? value : 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function splitArgsList(input: string): string[] {
|
|
94
|
+
const values: string[] = [];
|
|
95
|
+
let current = "";
|
|
96
|
+
let depth = 0;
|
|
97
|
+
|
|
98
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
99
|
+
const ch = input[i] ?? "";
|
|
100
|
+
if (ch === "(" || ch === "[" || ch === "{") {
|
|
101
|
+
depth += 1;
|
|
102
|
+
current += ch;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (ch === ")" || ch === "]" || ch === "}") {
|
|
106
|
+
depth = Math.max(0, depth - 1);
|
|
107
|
+
current += ch;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (ch === "," && depth === 0) {
|
|
111
|
+
values.push(current.trim());
|
|
112
|
+
current = "";
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
current += ch;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (current.trim().length > 0) {
|
|
119
|
+
values.push(current.trim());
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return values.filter((value) => value.length > 0);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseInputsFromArgs(argsBlob: string): Record<string, string> {
|
|
126
|
+
const values = splitArgsList(argsBlob.trim());
|
|
127
|
+
const inputs: Record<string, string> = {};
|
|
128
|
+
|
|
129
|
+
values.forEach((value, index) => {
|
|
130
|
+
inputs[`arg${index}`] = value;
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return inputs;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function parseResultLine(line: string): ForgeFuzzResultItem | undefined {
|
|
137
|
+
const match = line.match(
|
|
138
|
+
/^\[(PASS|FAIL)[^\]]*\]\s*(.+?)\s*\(runs:\s*([\d_]+)(?:,\s*(?:\u03bc|mean):\s*([\d_]+))?/i
|
|
139
|
+
);
|
|
140
|
+
if (!match) {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const status = match[1]?.toUpperCase() === "PASS" ? "pass" : "fail";
|
|
145
|
+
return {
|
|
146
|
+
testName: (match[2] ?? "unknown-test").trim(),
|
|
147
|
+
status,
|
|
148
|
+
runs: parseNumber(match[3]),
|
|
149
|
+
gas: parseNumber(match[4]),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseCounterexampleLine(line: string):
|
|
154
|
+
| {
|
|
155
|
+
testName?: string;
|
|
156
|
+
inputs: Record<string, string>;
|
|
157
|
+
}
|
|
158
|
+
| undefined {
|
|
159
|
+
if (!line.includes("Counterexample:")) {
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const argsMatch = line.match(/Counterexample:\s*.*?args=\((.*?)\)\]/);
|
|
164
|
+
if (!argsMatch) {
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const trailing = line.match(/\]\s*(.+)$/);
|
|
169
|
+
const possibleTest = trailing?.[1]?.replace(/\s*\(runs:.*$/, "").trim();
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
testName: possibleTest && possibleTest.length > 0 ? possibleTest : undefined,
|
|
173
|
+
inputs: parseInputsFromArgs(argsMatch[1] ?? ""),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const runForgeFuzzCommand: RunForgeFuzzCommand = async (command, signal, cwd, env) => {
|
|
178
|
+
const child = Bun.spawn(command, {
|
|
179
|
+
cwd,
|
|
180
|
+
stdout: "pipe",
|
|
181
|
+
stderr: "pipe",
|
|
182
|
+
signal,
|
|
183
|
+
env,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
187
|
+
child.exited,
|
|
188
|
+
new Response(child.stdout).text(),
|
|
189
|
+
new Response(child.stderr).text(),
|
|
190
|
+
]);
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
stdout,
|
|
194
|
+
stderr,
|
|
195
|
+
exitCode,
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export async function executeForgeFuzz(
|
|
200
|
+
args: ForgeFuzzArgs,
|
|
201
|
+
context: ToolContext,
|
|
202
|
+
runCommand: RunForgeFuzzCommand = runForgeFuzzCommand
|
|
203
|
+
): Promise<ForgeFuzzResult> {
|
|
204
|
+
const startedAt = Date.now();
|
|
205
|
+
const normalized = normalizeArgs(args);
|
|
206
|
+
context.metadata({ title: `Run forge fuzz: ${normalized.target}` });
|
|
207
|
+
|
|
208
|
+
const fail = (error: string): ForgeFuzzResult => ({
|
|
209
|
+
success: false,
|
|
210
|
+
results: [],
|
|
211
|
+
counterexamples: [],
|
|
212
|
+
totalRuns: 0,
|
|
213
|
+
executionTime: Date.now() - startedAt,
|
|
214
|
+
error,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const env = {
|
|
219
|
+
...Bun.env,
|
|
220
|
+
FOUNDRY_FUZZ_RUNS: String(normalized.runs),
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const runResult = await runCommand(
|
|
224
|
+
buildForgeFuzzCommand(normalized),
|
|
225
|
+
context.abort,
|
|
226
|
+
normalized.target,
|
|
227
|
+
env
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const lines = `${runResult.stdout}\n${runResult.stderr}`
|
|
231
|
+
.split(/\r?\n/)
|
|
232
|
+
.map((line) => line.trim())
|
|
233
|
+
.filter((line) => line.length > 0);
|
|
234
|
+
|
|
235
|
+
const results: ForgeFuzzResultItem[] = [];
|
|
236
|
+
const counterexamples: ForgeFuzzCounterexample[] = [];
|
|
237
|
+
let lastTestName: string | undefined;
|
|
238
|
+
|
|
239
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
240
|
+
const line = lines[i] ?? "";
|
|
241
|
+
const parsedResult = parseResultLine(line);
|
|
242
|
+
if (parsedResult) {
|
|
243
|
+
results.push(parsedResult);
|
|
244
|
+
lastTestName = parsedResult.testName;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const parsedCounterexample = parseCounterexampleLine(line);
|
|
248
|
+
if (!parsedCounterexample) {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const fallbackName = parsedCounterexample.testName ?? lastTestName ?? "unknown-test";
|
|
253
|
+
const nextLine = lines[i + 1] ?? "";
|
|
254
|
+
const reasonMatch = nextLine.match(/^(?:Reason|Error):\s*(.+)$/i);
|
|
255
|
+
counterexamples.push({
|
|
256
|
+
testName: fallbackName,
|
|
257
|
+
inputs: parsedCounterexample.inputs,
|
|
258
|
+
...(reasonMatch?.[1] ? { revertReason: reasonMatch[1].trim() } : {}),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const totalRuns = results.reduce((sum, item) => sum + item.runs, 0);
|
|
263
|
+
const failedCount = results.filter((item) => item.status === "fail").length;
|
|
264
|
+
const output: ForgeFuzzResult = {
|
|
265
|
+
success: runResult.exitCode === 0 && failedCount === 0,
|
|
266
|
+
results,
|
|
267
|
+
counterexamples,
|
|
268
|
+
totalRuns,
|
|
269
|
+
executionTime: Date.now() - startedAt,
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
if (runResult.exitCode !== 0 && failedCount === 0) {
|
|
273
|
+
output.error = runResult.stderr.trim() || `forge fuzz exited with code ${runResult.exitCode}`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return output;
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if (context.abort.aborted || (error instanceof DOMException && error.name === "AbortError")) {
|
|
279
|
+
return fail("forge fuzz aborted");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const maybeError = error as Error & { code?: string };
|
|
283
|
+
if (maybeError.code === "ENOENT") {
|
|
284
|
+
return fail("Foundry not found. Install: curl -L https://foundry.paradigm.xyz | bash");
|
|
285
|
+
}
|
|
286
|
+
if (
|
|
287
|
+
maybeError.code === "ETIMEDOUT" ||
|
|
288
|
+
maybeError.message.toLowerCase().includes("timed out")
|
|
289
|
+
) {
|
|
290
|
+
return fail("forge fuzz timed out");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return fail(maybeError.message || "forge fuzz failed");
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export const forgeFuzzTool = tool({
|
|
298
|
+
description:
|
|
299
|
+
"Run Foundry fuzz tests, parse test runs, and extract counterexamples from verbose output.",
|
|
300
|
+
args: {
|
|
301
|
+
target: tool.schema.string().default("."),
|
|
302
|
+
match_test: tool.schema.string().optional(),
|
|
303
|
+
runs: tool.schema.number().min(1).max(10000).default(256),
|
|
304
|
+
seed: tool.schema.number().optional(),
|
|
305
|
+
fork_url: tool.schema.string().optional(),
|
|
306
|
+
},
|
|
307
|
+
async execute(args, context) {
|
|
308
|
+
const result = await executeForgeFuzz(args, context);
|
|
309
|
+
return JSON.stringify(result);
|
|
310
|
+
},
|
|
311
|
+
});
|