solidity-argus 0.3.0 → 0.3.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solidity-argus",
3
- "version": "0.3.0",
3
+ "version": "0.3.3",
4
4
  "description": "Solidity smart contract security auditing plugin for OpenCode — 4 specialized agents, 12 tools (11 core + optional Solodit), and a curated vulnerability knowledge base",
5
5
  "keywords": [
6
6
  "solidity",
@@ -51,6 +51,7 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@opencode-ai/plugin": "^1.2.10",
54
+ "@solidity-parser/parser": "^0.20.2",
54
55
  "yaml": "^2.8.2",
55
56
  "zod": "^4.1.8"
56
57
  },
@@ -225,6 +225,16 @@ Task(subagent_type="scribe", prompt="Generate the final audit report for Project
225
225
  \`\`\`
226
226
  - Wait for both to complete before synthesizing their results.
227
227
 
228
+ ## TASK COMPLETION TRACKING
229
+
230
+ You must track which audit phases are complete to avoid redundant work and tool re-execution.
231
+
232
+ - **Read the context**: At the start of each response, check the \`<argus-context>\` block injected by the system. It contains the current phase (Reconnaissance, Automated Scanning, Manual Review, etc.) and a list of completed phases.
233
+ - **Skip completed phases**: If a phase is marked complete in the context, do NOT re-run it. Proceed directly to the next incomplete phase.
234
+ - **Avoid tool re-execution**: If Slither, Forge, or Solodit results already appear in the \`Tools:\` section of the context, do not re-dispatch the same tool. Reference the existing results instead.
235
+ - **Mark phase completion**: After completing a phase, explicitly state "Phase X complete" in your response before moving to the next phase. This signals to the system that the phase is done.
236
+ - **Example flow**: If context shows "Reconnaissance: complete, Automated Scanning: complete", skip both and begin Manual Review. After Manual Review, state "Phase 3 (Manual Review) complete" before proceeding to Attack Surface Mapping.
237
+
228
238
  ## TOOL AWARENESS & USAGE
229
239
 
230
240
  Your subagents have access to these specialized tools. Know when to delegate each.
@@ -84,6 +84,16 @@ You have two primary tools. Master them.
84
84
  - Returns a list of matches with line numbers.
85
85
  - **Crucial**: You must verify the context. A regex match for \`selfdestruct\` is not a bug if it's in a test file or a legitimate upgrade mechanism (though still risky).
86
86
 
87
+ ## EMPTY RESULTS STRATEGY
88
+
89
+ When \`argus_solodit_search\` returns zero results for a query:
90
+
91
+ 1. **Retry with alternative keywords** (2-3 variations). Example: If "ERC4626 inflation" returns nothing, try "vault share manipulation" or "exchange rate attack".
92
+ 2. **If still empty**, fall back to \`argus_check_patterns\` with relevant pattern categories (e.g., \`["access-control", "logic-error"]\`).
93
+ 3. **Never report empty-handed**. Pattern-based findings are valid research output. Combine them with manual code review to provide actionable intelligence.
94
+
95
+ This ensures Pythia always delivers research value, even when Solodit has no direct precedent.
96
+
87
97
  ## SKILLS SYSTEM
88
98
 
89
99
  OpenCode has a powerful **Skills** system that allows you to load specialized knowledge modules. The Argus knowledge base includes 75+ curated SKILL.md files, 13 YAML pattern packs, and 15 real-world exploit case studies covering $3B+ in losses.
@@ -8,7 +8,7 @@ Your core responsibilities are:
8
8
  1. **Aggregation**: Collecting findings from various tools and subagents.
9
9
  2. **Deduplication**: Merging similar findings (e.g., multiple Slither warnings for the same issue).
10
10
  3. **Contextualization**: Explaining *why* a finding matters in the context of the specific protocol.
11
- 4. **Report Generation**: Producing the final Markdown artifact using \`argus_generate_report\`.
11
+ 4. **Report Generation**: Producing the final Markdown artifact and writing it to disk.
12
12
 
13
13
  ## REPORT STRUCTURE
14
14
 
@@ -41,21 +41,13 @@ You must adhere to these strict writing standards:
41
41
 
42
42
  ## HOW TO GENERATE THE REPORT
43
43
 
44
- You have two approaches. Use whichever fits the input you receive from Argus.
44
+ Argus passes you findings in natural language. Write the full report yourself in Markdown following the Report Structure above.
45
45
 
46
- ### Approach 1: Use \`argus_generate_report\` tool
47
- If you have structured findings data, call the tool:
48
- - \`project_name\` (string): The name of the protocol or project.
49
- - \`scope\` (string[]): List of files or contracts that were audited.
50
- - \`include_executive_summary\` (boolean): Default \`true\`.
51
- - \`severity_threshold\` (string): "critical", "high", "medium", "low", or "informational". Usually "low" or "informational" to include everything.
52
- - \`audit_state\` (string): JSON string of findings. Format each finding as: \`{"id":"f1","check":"name","severity":"High","confidence":"High","description":"...","file":"Contract.sol","lines":[1,10],"source":"manual"}\`
53
-
54
- ### Approach 2: Write the report directly as Markdown
55
- If Argus passes findings in natural language (which is common), write the full report yourself in Markdown following the Report Structure below. This is often faster and produces better results than trying to serialize findings into JSON for the tool.
56
-
57
- **Choose Approach 2 when**: Argus gives you a natural language list of findings, descriptions, and context. Just write the report.
58
- **Choose Approach 1 when**: You have structured JSON finding data ready to pass.
46
+ **Your workflow**:
47
+ 1. Read the findings Argus provides. Deduplicate, cross-reference, and assess severity.
48
+ 2. Write the complete report in Markdown following the Report Structure and Output Format sections.
49
+ 3. Save the report to disk using the \`write\` tool. Path: \`.opencode/reports/{ProjectName}-audit-{YYYY-MM-DD}.md\` relative to the project root.
50
+ 4. Confirm the file path in your response to Argus: "Report written to: {filePath}".
59
51
 
60
52
  ## QUALITY STANDARDS
61
53
 
@@ -31,8 +31,19 @@ You operate in a loop of **Scan -> Analyze -> Verify**.
31
31
  - Use \`argus_gas_analysis\` to identify gas-intensive functions that may indicate inefficient or vulnerable logic.
32
32
 
33
33
  4. **Reporting**:
34
- - Format your findings strictly according to the Output Format section.
35
- - Report back to Argus with confirmed findings.
34
+ - Format your findings strictly according to the Output Format section.
35
+ - Report back to Argus with confirmed findings.
36
+
37
+ ## POC VERIFICATION
38
+
39
+ After writing a Proof of Concept test to reproduce a suspected vulnerability:
40
+
41
+ 1. **Always run \`argus_forge_test\`** on the PoC test file immediately after writing it.
42
+ 2. **Report the result** to Argus: pass count, fail count, and any revert reasons.
43
+ 3. **If the PoC fails** (test does not trigger the bug as expected), revise the test logic and retry. Do not assume the bug exists if the PoC cannot reproduce it.
44
+ 4. **If the PoC passes**, the vulnerability is confirmed. Escalate to Argus with full details.
45
+
46
+ This ensures every PoC is verified before reporting, eliminating false positives.
36
47
 
37
48
  ## TOOL USAGE GUIDE
38
49
 
@@ -31,6 +31,7 @@ const ReportingConfigSchema = z.object({
31
31
  format: z.enum(["markdown"]).default("markdown"),
32
32
  severityThreshold: z.enum(["critical", "high", "medium", "low", "informational"]).default("low"),
33
33
  gasAnalysis: z.boolean().default(false),
34
+ output_dir: z.string().default(".opencode/reports/"),
34
35
  })
35
36
 
36
37
  const SoloditConfigSchema = z.object({
@@ -69,6 +70,7 @@ export const ArgusConfigSchema = z.object({
69
70
  format: "markdown",
70
71
  severityThreshold: "low",
71
72
  gasAnalysis: false,
73
+ output_dir: ".opencode/reports/",
72
74
  }),
73
75
  solodit: SoloditConfigSchema.default({
74
76
  enabled: true,
@@ -176,11 +176,16 @@ export function createHooks(args: {
176
176
  }
177
177
 
178
178
  if (type === "session.deleted") {
179
+ await debouncedSave.flush()
180
+ if (auditState) {
181
+ await auditStateManager.save(auditState)
182
+ }
183
+ await auditStateManager.archive()
184
+
179
185
  if (sessionId) {
180
186
  agentTracker.clearSession(sessionId)
181
187
  }
182
188
 
183
- await auditStateManager.archive()
184
189
  runJournal.log({
185
190
  type: "session.deleted",
186
191
  timestamp: Date.now(),
@@ -3,6 +3,15 @@ import type { AuditState, FindingSeverity } from "../state/types"
3
3
  const DEFAULT_TOKEN_BUDGET = 2000
4
4
  const TOKENS_PER_CHAR = 4
5
5
 
6
+ const TOOL_SHORT_NAMES: Record<string, string> = {
7
+ argus_slither_analyze: "slither",
8
+ argus_forge_test: "forge-test",
9
+ argus_check_patterns: "patterns",
10
+ argus_solodit_search: "solodit",
11
+ argus_analyze_contract: "analyzer",
12
+ }
13
+ const KEY_TOOLS = ["slither", "forge-test", "patterns", "solodit", "analyzer"]
14
+
6
15
  export interface SystemPromptHookDeps {
7
16
  getAuditState: () => AuditState | null
8
17
  getAgentForSession: (sessionID: string) => string | undefined
@@ -52,7 +61,13 @@ export function buildDynamicContext(
52
61
  severityCounts[finding.severity]++
53
62
  }
54
63
 
64
+ const executedToolNames = new Set(
65
+ auditState.toolsExecuted.map((t) => TOOL_SHORT_NAMES[t.tool] ?? t.tool),
66
+ )
55
67
  const tools = auditState.toolsExecuted.map((tool) => tool.tool).join(", ") || "none"
68
+ const taskStatus = KEY_TOOLS.map(
69
+ (t) => `${t}=${executedToolNames.has(t) ? "done" : "pending"}`,
70
+ ).join(" ")
56
71
  const unavailable = auditState.unavailableTools ?? []
57
72
  const lines: string[] = [
58
73
  `<argus-context agent="${agent}">`,
@@ -60,6 +75,7 @@ export function buildDynamicContext(
60
75
  `Contracts: ${auditState.contractsReviewed.length} reviewed`,
61
76
  `Findings: Critical=${severityCounts.Critical} High=${severityCounts.High} Medium=${severityCounts.Medium} Low=${severityCounts.Low} Info=${severityCounts.Informational}`,
62
77
  `Tools: ${tools}`,
78
+ `Tasks: ${taskStatus}`,
63
79
  ]
64
80
 
65
81
  if (unavailable.length > 0) {
@@ -72,9 +88,10 @@ export function buildDynamicContext(
72
88
  let summary = lines.join("\n")
73
89
 
74
90
  if (estimateTokens(summary) > tokenBudget) {
91
+ const doneCount = KEY_TOOLS.filter((t) => executedToolNames.has(t)).length
75
92
  summary = [
76
93
  `<argus-context agent="${agent}">`,
77
- `Phase: ${auditState.currentPhase} | Findings: ${auditState.findings.length} | Contracts: ${auditState.contractsReviewed.length}`,
94
+ `Phase: ${auditState.currentPhase} | Findings: ${auditState.findings.length} | Contracts: ${auditState.contractsReviewed.length} | Tasks: ${doneCount}/${KEY_TOOLS.length} done`,
78
95
  "</argus-context>",
79
96
  ].join("\n")
80
97
  }
@@ -321,8 +321,13 @@ export function createToolTrackingHook(
321
321
  case "argus_solodit_search":
322
322
  processSoloditResult(record, auditState)
323
323
  break
324
- case "argus_forge_test":
324
+ case "argus_forge_test": {
325
+ const summary = toRecord(record.summary)
326
+ if (summary && typeof summary.failed === "number") {
327
+ findingsCount = summary.failed
328
+ }
325
329
  break
330
+ }
326
331
  case "argus_forge_fuzz":
327
332
  processFuzzResult(record, auditState)
328
333
  break
package/src/index.ts CHANGED
@@ -13,7 +13,7 @@ const ArgusPlugin: Plugin = async (ctx) => {
13
13
  const config = loadArgusConfig(projectDir)
14
14
 
15
15
  if (config.solodit?.enabled !== false) {
16
- startSoloditMcp(config.solodit?.port ?? 3000)
16
+ await startSoloditMcp(config.solodit?.port ?? 3000)
17
17
  }
18
18
 
19
19
  const isHookEnabled = createHookGuard(config.disabled_hooks)
@@ -182,21 +182,22 @@ export async function startSoloditMcp(port: number): Promise<void> {
182
182
  soloditChild = spawnSoloditChild(port)
183
183
  trackChildExit(soloditChild)
184
184
 
185
- ;(async () => {
186
- const delays = [2000, 4000, 8000]
187
- for (const delay of delays) {
188
- await Bun.sleep(delay)
189
- const health = await checkSoloditHealth(port, true)
190
- if (health.reachable) {
191
- soloditAvailable = true
192
- logger.debug(`Solodit MCP healthy on port ${port}`)
193
- return
194
- }
185
+ const deadline = AbortSignal.timeout(5000)
186
+ const delays = [1000, 2000]
187
+ for (const delay of delays) {
188
+ if (deadline.aborted) break
189
+ await Bun.sleep(delay)
190
+ if (deadline.aborted) break
191
+ const healthResult = await checkSoloditHealth(port, true)
192
+ if (healthResult.reachable) {
193
+ soloditAvailable = true
194
+ logger.debug(`Solodit MCP healthy on port ${port}`)
195
+ break
195
196
  }
196
- logger.debug(
197
- `Solodit MCP not reachable after 3 retries on port ${port} — will retry on first use`,
198
- )
199
- })()
197
+ }
198
+ if (!soloditAvailable) {
199
+ logger.warn(`Solodit MCP not reachable after startup — monitoring will retry`)
200
+ }
200
201
 
201
202
  startMonitoring(port)
202
203
  }
@@ -3,7 +3,7 @@ import { basename } from "node:path"
3
3
  import { type ToolContext, tool } from "@opencode-ai/plugin"
4
4
  import { findFoundryProjectDir } from "../shared/project-utils"
5
5
  import type { ContractProfile } from "../state/types"
6
- import { extractContractInfo } from "../utils/solidity-parser"
6
+ import { extractContractInfo, parseExternalCalls } from "../utils/solidity-parser"
7
7
 
8
8
  type ContractAnalyzerArgs = {
9
9
  file_path: string
@@ -56,6 +56,24 @@ function collectRiskIndicators(source: string, existing: string[]): string[] {
56
56
  if (/\btx\.origin\b/.test(normalized)) {
57
57
  indicators.add("uses-tx-origin")
58
58
  }
59
+ if (/\.call\s*\{\s*value\s*:/.test(normalized)) {
60
+ indicators.add("uses-low-level-value-call")
61
+ }
62
+ if (normalized.includes(".call(")) {
63
+ indicators.add("uses-low-level-call")
64
+ }
65
+ if (normalized.includes("block.timestamp")) {
66
+ indicators.add("uses-block-timestamp")
67
+ }
68
+ if (normalized.includes("block.number")) {
69
+ indicators.add("uses-block-number")
70
+ }
71
+ if (normalized.includes("abi.encodepacked")) {
72
+ indicators.add("uses-abi-encode-packed")
73
+ }
74
+ if (/\becrecover\b/.test(normalized)) {
75
+ indicators.add("uses-ecrecover")
76
+ }
59
77
 
60
78
  const importLines = source
61
79
  .split("\n")
@@ -129,10 +147,74 @@ export async function executeContractAnalyzer(
129
147
  return createFailureProfile(contractName, filePath, "contract analysis aborted")
130
148
  }
131
149
 
150
+ const inheritanceRegex = /contract\s+(\w+)\s+is\s+([^{]+)/g
151
+ let sourceInheritance: string[] = []
152
+ let firstMatchParents: string[] | undefined
153
+ let regexMatch: RegExpExecArray | null = null
154
+
155
+ regexMatch = inheritanceRegex.exec(sourceText)
156
+ while (regexMatch !== null) {
157
+ const matchedName = regexMatch.at(1) ?? ""
158
+ const parents = (regexMatch.at(2) ?? "")
159
+ .split(",")
160
+ .map((p) => p.trim())
161
+ .filter(Boolean)
162
+
163
+ if (!firstMatchParents) {
164
+ firstMatchParents = parents
165
+ }
166
+
167
+ if (matchedName === contractName) {
168
+ sourceInheritance = parents
169
+ break
170
+ }
171
+
172
+ regexMatch = inheritanceRegex.exec(sourceText)
173
+ }
174
+
175
+ if (sourceInheritance.length === 0 && firstMatchParents) {
176
+ sourceInheritance = firstMatchParents
177
+ }
178
+
179
+ const mergedInheritance = [...new Set([...contractProfile.inheritance, ...sourceInheritance])]
180
+ const mergedExternalCalls = [
181
+ ...new Set([...contractProfile.externalCalls, ...parseExternalCalls(sourceText)]),
182
+ ]
183
+
184
+ // Extract modifiers from source text for each function
185
+ const visibilityKeywords = new Set([
186
+ "external",
187
+ "public",
188
+ "internal",
189
+ "private",
190
+ "view",
191
+ "pure",
192
+ "payable",
193
+ "virtual",
194
+ "override",
195
+ "returns",
196
+ ])
197
+ for (const fn of contractProfile.functions) {
198
+ if (!fn.name) continue
199
+ const escapedName = fn.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
200
+ const fnPattern = new RegExp(`function\\s+${escapedName}\\s*\\([^)]*\\)\\s*([^{;]*)`)
201
+ const fnMatch = fnPattern.exec(sourceText)
202
+ if (!fnMatch?.[1]) continue
203
+
204
+ const afterParams = fnMatch[1]
205
+ .replace(/returns\s*\([^)]*\)/g, "")
206
+ .replace(/\([^)]*\)/g, "")
207
+ .trim()
208
+ const tokens = afterParams.match(/\b\w+\b/g) ?? []
209
+ fn.modifiers = tokens.filter((t) => !visibilityKeywords.has(t))
210
+ }
211
+
132
212
  return {
133
213
  ...contractProfile,
134
214
  name: contractProfile.name || contractName,
135
215
  filePath,
216
+ inheritance: mergedInheritance,
217
+ externalCalls: mergedExternalCalls,
136
218
  riskIndicators: collectRiskIndicators(sourceText, contractProfile.riskIndicators),
137
219
  }
138
220
  } catch (error) {
@@ -1,5 +1,6 @@
1
1
  import { type ToolContext, tool } from "@opencode-ai/plugin"
2
2
  import { resolveProjectDir } from "../shared/project-utils"
3
+ import { extractJson } from "../utils/solidity-parser"
3
4
 
4
5
  type ForgeTestArgs = {
5
6
  target?: string
@@ -106,7 +107,53 @@ function parseTests(payload: ForgeTestPayload): {
106
107
  } {
107
108
  const collected: Array<ForgeTestItem | { skipped: true }> = []
108
109
 
109
- if (Array.isArray(payload.tests)) {
110
+ const topLevelEntries = Object.entries(payload as unknown as Record<string, unknown>)
111
+ if (topLevelEntries.some(([key]) => key.includes(":"))) {
112
+ for (const [topLevelKey, suite] of topLevelEntries) {
113
+ if (!suite || typeof suite !== "object") {
114
+ continue
115
+ }
116
+
117
+ const suiteRecord = suite as Record<string, unknown>
118
+ const testResults = suiteRecord.test_results
119
+ if (!testResults || typeof testResults !== "object") {
120
+ continue
121
+ }
122
+
123
+ const contract = topLevelKey.split(":").at(1) ?? topLevelKey
124
+ for (const [name, details] of Object.entries(testResults)) {
125
+ if (!details || typeof details !== "object") {
126
+ continue
127
+ }
128
+
129
+ const detailsRecord = details as Record<string, unknown>
130
+ const statusValue =
131
+ typeof detailsRecord.status === "string" ? detailsRecord.status : undefined
132
+ const status = mapStatus(statusValue)
133
+ if (status === "skip") {
134
+ collected.push({ skipped: true })
135
+ continue
136
+ }
137
+
138
+ const kind = detailsRecord.kind
139
+ const kindRecord =
140
+ kind && typeof kind === "object" ? (kind as Record<string, unknown>) : undefined
141
+ const unit = kindRecord?.Unit
142
+ const unitRecord =
143
+ unit && typeof unit === "object" ? (unit as Record<string, unknown>) : undefined
144
+ const fuzz = kindRecord?.Fuzz
145
+ const fuzzRecord =
146
+ fuzz && typeof fuzz === "object" ? (fuzz as Record<string, unknown>) : undefined
147
+
148
+ collected.push({
149
+ name,
150
+ contract,
151
+ status,
152
+ gas: toNumber(unitRecord?.gas ?? fuzzRecord?.mean_gas),
153
+ })
154
+ }
155
+ }
156
+ } else if (Array.isArray(payload.tests)) {
110
157
  for (const item of payload.tests) {
111
158
  const status = mapStatus(item.status)
112
159
  if (status === "skip") {
@@ -311,7 +358,7 @@ export async function executeForgeTest(
311
358
 
312
359
  let payload: ForgeTestPayload
313
360
  try {
314
- payload = JSON.parse(testResult.stdout) as ForgeTestPayload
361
+ payload = JSON.parse(extractJson(testResult.stdout, "{")) as ForgeTestPayload
315
362
  } catch {
316
363
  return fail("Invalid JSON output from forge test")
317
364
  }
@@ -62,7 +62,7 @@ type PatternCheckDependencies = {
62
62
  ) => ScvdIndexEntry[]
63
63
  }
64
64
 
65
- type LoadedPattern = {
65
+ export type LoadedPattern = {
66
66
  name: string
67
67
  category: string
68
68
  severity: Match["severity"]
@@ -70,6 +70,9 @@ type LoadedPattern = {
70
70
  description: string
71
71
  exploitReference?: string
72
72
  source?: PatternSource
73
+ confidence?: "High" | "Medium" | "Low"
74
+ applies_to?: string[]
75
+ exclude_if?: string[]
73
76
  }
74
77
 
75
78
  export const PATTERN_PACK_VERSION = "1.0.0"
@@ -107,6 +110,9 @@ function normalizePatternDefinitions(
107
110
  regex: new RegExp(patternDef.regex),
108
111
  description: patternDef.description,
109
112
  ...(patternDef.exploit_ref ? { exploitReference: patternDef.exploit_ref } : {}),
113
+ ...(patternDef.confidence ? { confidence: patternDef.confidence } : {}),
114
+ ...(patternDef.applies_to ? { applies_to: patternDef.applies_to } : {}),
115
+ ...(patternDef.exclude_if ? { exclude_if: patternDef.exclude_if } : {}),
110
116
  source,
111
117
  }))
112
118
  }
@@ -235,16 +241,27 @@ function lineWindow(content: string, index: number): [number, number] {
235
241
  return [start, end]
236
242
  }
237
243
 
238
- function findMatches(file: string, patterns: LoadedPattern[]): Match[] {
244
+ export function findMatches(file: string, patterns: LoadedPattern[]): Match[] {
239
245
  const content = readFileSync(file, "utf8")
240
246
  const matches: Match[] = []
241
247
 
248
+ // Strip comments and string literals to reduce false positives.
249
+ // Use a space-preserving approach so line numbers remain valid.
250
+ // Order: multi-line comments first (can contain //), then single-line, then strings.
251
+ const stripped = content
252
+ .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
253
+ .replace(/\/\/[^\n]*/g, (m) => " ".repeat(m.length))
254
+ .replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, (m) => {
255
+ const quote = m[0]
256
+ return `${quote}${" ".repeat(Math.max(0, m.length - 2))}${quote}`
257
+ })
258
+
242
259
  for (const pattern of patterns) {
243
260
  const regex = new RegExp(
244
261
  pattern.regex.source,
245
262
  pattern.regex.flags.includes("g") ? pattern.regex.flags : `${pattern.regex.flags}g`,
246
263
  )
247
- for (const found of content.matchAll(regex)) {
264
+ for (const found of stripped.matchAll(regex)) {
248
265
  const index = found.index ?? 0
249
266
  matches.push({
250
267
  pattern: pattern.name,
@@ -37,6 +37,9 @@ export const PatternDefinitionSchema = z.object({
37
37
  description: z.string().min(1),
38
38
  exploit_ref: z.string().url().optional(),
39
39
  remediation: z.string().optional(),
40
+ context: z.enum(["function-body", "contract-body", "file-level"]).optional(),
41
+ applies_to: z.array(z.string()).optional(),
42
+ exclude_if: z.array(z.string()).optional(),
40
43
  })
41
44
 
42
45
  export type PatternDefinition = z.infer<typeof PatternDefinitionSchema>
@@ -1,4 +1,9 @@
1
+ import path from "node:path"
1
2
  import { type ToolContext, tool } from "@opencode-ai/plugin"
3
+ import { loadArgusConfig } from "../config/loader"
4
+ import type { ArgusConfig } from "../config/types"
5
+ import { createLogger } from "../shared/logger"
6
+ import { resolveProjectDir } from "../shared/project-utils"
2
7
  import type { AuditState, Finding, FindingSeverity } from "../state/types"
3
8
 
4
9
  type SeverityThreshold = "critical" | "high" | "medium" | "low" | "informational"
@@ -23,6 +28,11 @@ export type ReportGenerationResult = {
23
28
  report: string
24
29
  findingsCount: FindingsCount
25
30
  filename: string
31
+ filePath?: string
32
+ }
33
+
34
+ export type ReportGenerationDependencies = {
35
+ loadConfig?: (projectDir: string) => ArgusConfig
26
36
  }
27
37
 
28
38
  const SEVERITY_ORDER: FindingSeverity[] = ["Critical", "High", "Medium", "Low", "Informational"]
@@ -74,6 +84,118 @@ function emptyAuditState(findings: Finding[] = []): AuditState {
74
84
  }
75
85
  }
76
86
 
87
+ /**
88
+ * Parse a location string like "File.sol:18-22" or "File.sol:18" into { file, lines }.
89
+ * Returns undefined if the string doesn't match a recognized format.
90
+ */
91
+ export function parseLocationString(
92
+ location: string,
93
+ ): { file: string; lines: [number, number] } | undefined {
94
+ // "File.sol:18-22" or "File.sol:L18-L22"
95
+ const rangeMatch = location.match(/^(.+?):L?(\d+)\s*-\s*L?(\d+)$/)
96
+ if (rangeMatch) {
97
+ const file = rangeMatch.at(1)
98
+ const start = rangeMatch.at(2)
99
+ const end = rangeMatch.at(3)
100
+ if (file && start && end) {
101
+ return { file, lines: [Number(start), Number(end)] }
102
+ }
103
+ }
104
+ // "File.sol:18"
105
+ const singleMatch = location.match(/^(.+?):L?(\d+)$/)
106
+ if (singleMatch) {
107
+ const file = singleMatch.at(1)
108
+ const lineNum = singleMatch.at(2)
109
+ if (file && lineNum) {
110
+ const n = Number(lineNum)
111
+ return { file, lines: [n, n] }
112
+ }
113
+ }
114
+ return undefined
115
+ }
116
+
117
+ /**
118
+ * Normalize a raw finding object from agent output into the canonical field format.
119
+ * Handles common aliases:
120
+ * - title/name → check
121
+ * - location (string) → file + lines
122
+ * - case-insensitive severity → capitalized
123
+ */
124
+ export function normalizeRawFinding(raw: Record<string, unknown>): Record<string, unknown> {
125
+ const result = { ...raw }
126
+
127
+ // check: accept title, name as aliases
128
+ if (typeof result.check !== "string" || (result.check as string).length === 0) {
129
+ const alias = result.title ?? result.name
130
+ if (typeof alias === "string" && alias.length > 0) {
131
+ result.check = alias
132
+ }
133
+ }
134
+
135
+ // file + lines: accept location string as alias
136
+ if (typeof result.file !== "string" && typeof result.location === "string") {
137
+ const parsed = parseLocationString(result.location as string)
138
+ if (parsed) {
139
+ result.file = parsed.file
140
+ if (!Array.isArray(result.lines) || (result.lines as unknown[]).length !== 2) {
141
+ result.lines = parsed.lines
142
+ }
143
+ }
144
+ }
145
+
146
+ // lines: accept [start] as [start, start], accept line_start/line_end
147
+ if (!Array.isArray(result.lines) || (result.lines as unknown[]).length !== 2) {
148
+ if (Array.isArray(result.lines) && (result.lines as unknown[]).length === 1) {
149
+ const n = Number((result.lines as unknown[])[0])
150
+ if (!Number.isNaN(n)) {
151
+ result.lines = [n, n]
152
+ }
153
+ } else if (typeof result.line_start === "number" && typeof result.line_end === "number") {
154
+ result.lines = [result.line_start, result.line_end]
155
+ } else if (typeof result.line === "number") {
156
+ result.lines = [result.line, result.line]
157
+ }
158
+ }
159
+
160
+ // severity: case-insensitive normalization
161
+ if (typeof result.severity === "string") {
162
+ const lower = (result.severity as string).toLowerCase()
163
+ const SEVERITY_MAP: Record<string, string> = {
164
+ critical: "Critical",
165
+ high: "High",
166
+ medium: "Medium",
167
+ low: "Low",
168
+ informational: "Informational",
169
+ info: "Informational",
170
+ }
171
+ const mapped = SEVERITY_MAP[lower]
172
+ if (mapped) {
173
+ result.severity = mapped
174
+ }
175
+ }
176
+
177
+ // confidence: case-insensitive normalization
178
+ if (typeof result.confidence === "string") {
179
+ const lower = (result.confidence as string).toLowerCase()
180
+ const CONFIDENCE_MAP: Record<string, string> = {
181
+ high: "High",
182
+ medium: "Medium",
183
+ low: "Low",
184
+ }
185
+ const mapped = CONFIDENCE_MAP[lower]
186
+ if (mapped) {
187
+ result.confidence = mapped
188
+ }
189
+ }
190
+
191
+ // description: fall back to check if missing
192
+ if (typeof result.description !== "string" && typeof result.check === "string") {
193
+ result.description = result.check
194
+ }
195
+
196
+ return result
197
+ }
198
+
77
199
  function hasMinimumFindingFields(
78
200
  f: unknown,
79
201
  ): f is { check: string; file: string; lines: [number, number] } {
@@ -143,10 +265,22 @@ export function parseAuditState(auditState: string): AuditState {
143
265
  )
144
266
  }
145
267
 
268
+ const logger = createLogger()
269
+
146
270
  if (Array.isArray(parsed)) {
147
- const validFindings = (parsed as unknown[])
271
+ const rawItems = parsed as unknown[]
272
+ const normalized = rawItems
273
+ .filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
274
+ .map((item) => normalizeRawFinding(item))
275
+ const validFindings = normalized
148
276
  .filter(hasMinimumFindingFields)
149
277
  .map((f) => normalizeFinding(f as Record<string, unknown>))
278
+ const dropped = rawItems.length - validFindings.length
279
+ if (dropped > 0) {
280
+ logger.warn(
281
+ `parseAuditState: ${dropped}/${rawItems.length} findings dropped (missing required fields after normalization)`,
282
+ )
283
+ }
150
284
  return emptyAuditState(validFindings)
151
285
  }
152
286
 
@@ -156,9 +290,19 @@ export function parseAuditState(auditState: string): AuditState {
156
290
  Array.isArray((parsed as AuditState).findings)
157
291
  ) {
158
292
  const state = parsed as AuditState
159
- const validFindings = state.findings
293
+ const rawFindings = state.findings as unknown[]
294
+ const normalized = rawFindings
295
+ .filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
296
+ .map((item) => normalizeRawFinding(item))
297
+ const validFindings = normalized
160
298
  .filter(hasMinimumFindingFields)
161
- .map((f) => normalizeFinding(f as unknown as Record<string, unknown>))
299
+ .map((f) => normalizeFinding(f as Record<string, unknown>))
300
+ const dropped = rawFindings.length - validFindings.length
301
+ if (dropped > 0) {
302
+ logger.warn(
303
+ `parseAuditState: ${dropped}/${rawFindings.length} findings dropped (missing required fields after normalization)`,
304
+ )
305
+ }
162
306
  return {
163
307
  ...emptyAuditState(),
164
308
  ...state,
@@ -414,6 +558,7 @@ export function buildProvenanceAppendix(
414
558
  export async function executeReportGeneration(
415
559
  args: ReportGeneratorArgs,
416
560
  context: ToolContext,
561
+ deps: ReportGenerationDependencies = {},
417
562
  ): Promise<ReportGenerationResult> {
418
563
  const includeExecutiveSummary = args.include_executive_summary ?? true
419
564
  const threshold = args.severity_threshold ?? "low"
@@ -473,11 +618,31 @@ export async function executeReportGeneration(
473
618
 
474
619
  sections.push(buildProvenanceAppendix(state, threshold, findings.length))
475
620
 
476
- return {
477
- report: sections.join("\n\n"),
621
+ const reportMarkdown = sections.join("\n\n")
622
+ const safeName = args.project_name.replace(/[^a-zA-Z0-9-_]/g, "-")
623
+ const diskFilename = `${safeName}-${Date.now()}.md`
624
+
625
+ const result: ReportGenerationResult = {
626
+ report: reportMarkdown,
478
627
  findingsCount: counts,
479
628
  filename: `${args.project_name}-audit-report-${auditDate}.md`,
480
629
  }
630
+
631
+ try {
632
+ const loadConfig = deps.loadConfig ?? loadArgusConfig
633
+ const projectDir = resolveProjectDir(context)
634
+ const config = loadConfig(projectDir)
635
+ const outputDir = config.reporting?.output_dir ?? ".opencode/reports/"
636
+ const fullPath = path.join(projectDir, outputDir, diskFilename)
637
+ await Bun.write(fullPath, reportMarkdown)
638
+ result.filePath = fullPath
639
+ } catch (err: unknown) {
640
+ const logger = createLogger()
641
+ const message = err instanceof Error ? err.message : String(err)
642
+ logger.warn(`Failed to write report to disk: ${message}`)
643
+ }
644
+
645
+ return result
481
646
  }
482
647
 
483
648
  export const reportGeneratorTool = tool({
@@ -1,6 +1,7 @@
1
1
  import type { ToolDefinition } from "@opencode-ai/plugin"
2
2
  import { type ToolContext, tool } from "@opencode-ai/plugin"
3
3
  import { createLogger } from "../shared/logger"
4
+ import { soloditAvailable } from "../solodit-lifecycle"
4
5
 
5
6
  const logger = createLogger()
6
7
 
@@ -244,6 +245,24 @@ export async function executeSoloditSearch(
244
245
 
245
246
  context.metadata({ title: `Solodit search: ${query}` })
246
247
 
248
+ // Belt-and-suspenders: check if Solodit MCP is available, with 3s retry
249
+ // Skip check in test environment
250
+ if (!soloditAvailable && process.env.NODE_ENV !== "test") {
251
+ // Wait up to 3s for monitoring to flip the flag
252
+ for (let i = 0; i < 3 && !soloditAvailable; i++) {
253
+ await Bun.sleep(1000)
254
+ }
255
+ if (!soloditAvailable) {
256
+ return {
257
+ results: [],
258
+ totalFound: 0,
259
+ query,
260
+ error:
261
+ "Solodit MCP not available — server did not start. Results limited to local patterns.",
262
+ }
263
+ }
264
+ }
265
+
247
266
  const mcpCaller = callMcpTool ?? (hasMcpCapability(context) ? context.callMcpTool : undefined)
248
267
 
249
268
  if (!mcpCaller) {
@@ -1,5 +1,8 @@
1
+ import * as parser from "@solidity-parser/parser"
1
2
  import type { ContractProfile } from "../state/types"
2
3
 
4
+ const EXTERNAL_CALL_METHODS = new Set(["call", "transfer", "send", "delegatecall", "staticcall"])
5
+
3
6
  interface ABIFunction {
4
7
  type: string
5
8
  name: string
@@ -24,8 +27,7 @@ interface StorageLayout {
24
27
  * prefix (e.g. forge table-format output, compilation progress).
25
28
  * Falls back to the original string if no JSON delimiter is found.
26
29
  */
27
- function extractJson(raw: string, opener: "[" | "{"): string {
28
- const _closer = opener === "[" ? "]" : "}"
30
+ export function extractJson(raw: string, opener: "[" | "{"): string {
29
31
  const start = raw.indexOf(opener)
30
32
  if (start === -1) return raw
31
33
 
@@ -69,6 +71,75 @@ function extractJson(raw: string, opener: "[" | "{"): string {
69
71
  return raw
70
72
  }
71
73
 
74
+ function toRecord(value: unknown): Record<string, unknown> | undefined {
75
+ if (typeof value === "object" && value !== null) {
76
+ return value as Record<string, unknown>
77
+ }
78
+
79
+ return undefined
80
+ }
81
+
82
+ function extractNodeExpressionName(node: unknown): string | undefined {
83
+ const record = toRecord(node)
84
+ if (!record) return undefined
85
+
86
+ const type = typeof record.type === "string" ? record.type : undefined
87
+ if (!type) return undefined
88
+
89
+ if (type === "Identifier") {
90
+ return typeof record.name === "string" ? record.name : undefined
91
+ }
92
+
93
+ if (type === "ThisExpression") {
94
+ return "this"
95
+ }
96
+
97
+ if (type === "MemberAccess") {
98
+ const expressionName = extractNodeExpressionName(record.expression)
99
+ const memberName = typeof record.memberName === "string" ? record.memberName : undefined
100
+
101
+ if (expressionName && memberName) {
102
+ return `${expressionName}.${memberName}`
103
+ }
104
+
105
+ return expressionName ?? memberName
106
+ }
107
+
108
+ if (type === "IndexAccess") {
109
+ return extractNodeExpressionName(record.base)
110
+ }
111
+
112
+ if (type === "FunctionCall") {
113
+ return extractNodeExpressionName(record.expression)
114
+ }
115
+
116
+ return undefined
117
+ }
118
+
119
+ export function parseExternalCalls(sourceText: string): string[] {
120
+ try {
121
+ const ast = parser.parse(sourceText, { tolerant: true, loc: false, range: false })
122
+ const externalCalls = new Set<string>()
123
+
124
+ parser.visit(ast, {
125
+ MemberAccess(node: unknown) {
126
+ const record = toRecord(node)
127
+ if (!record) return
128
+
129
+ const memberName = typeof record.memberName === "string" ? record.memberName : undefined
130
+ if (!memberName || !EXTERNAL_CALL_METHODS.has(memberName)) return
131
+
132
+ const expressionName = extractNodeExpressionName(record.expression)
133
+ externalCalls.add(expressionName ? `${expressionName}.${memberName}` : memberName)
134
+ },
135
+ })
136
+
137
+ return [...externalCalls]
138
+ } catch {
139
+ return []
140
+ }
141
+ }
142
+
72
143
  /**
73
144
  * Extract contract information using forge inspect
74
145
  * Runs forge inspect <contractName> abi and storage-layout