solidity-argus 0.3.0 → 0.3.2

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.2",
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.
@@ -57,6 +57,19 @@ If Argus passes findings in natural language (which is common), write the full r
57
57
  **Choose Approach 2 when**: Argus gives you a natural language list of findings, descriptions, and context. Just write the report.
58
58
  **Choose Approach 1 when**: You have structured JSON finding data ready to pass.
59
59
 
60
+ ## FILE PERSISTENCE
61
+
62
+ **Critical Operational Block**: You must ALWAYS use the \`argus_generate_report\` tool to write the audit report to disk. This tool now automatically writes the report to the filesystem via \`Bun.write()\` and returns the file path in its result.
63
+
64
+ **Your workflow**:
65
+ 1. Prepare your findings data (either structured JSON or natural language context).
66
+ 2. Call \`argus_generate_report\` with the appropriate parameters.
67
+ 3. After the tool returns, extract the \`filePath\` field from the result.
68
+ 4. **Always confirm the file path in your response to Argus**: "Report written to: {filePath}".
69
+ 5. If the result does not include a \`filePath\` field, warn Argus: "Warning: filePath missing from tool result. The report may not have been written to disk."
70
+
71
+ This ensures the audit report is persisted and Argus can verify the output location.
72
+
60
73
  ## QUALITY STANDARDS
61
74
 
62
75
  Before generating the report, verify:
@@ -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,
@@ -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"]
@@ -414,6 +424,7 @@ export function buildProvenanceAppendix(
414
424
  export async function executeReportGeneration(
415
425
  args: ReportGeneratorArgs,
416
426
  context: ToolContext,
427
+ deps: ReportGenerationDependencies = {},
417
428
  ): Promise<ReportGenerationResult> {
418
429
  const includeExecutiveSummary = args.include_executive_summary ?? true
419
430
  const threshold = args.severity_threshold ?? "low"
@@ -473,11 +484,31 @@ export async function executeReportGeneration(
473
484
 
474
485
  sections.push(buildProvenanceAppendix(state, threshold, findings.length))
475
486
 
476
- return {
477
- report: sections.join("\n\n"),
487
+ const reportMarkdown = sections.join("\n\n")
488
+ const safeName = args.project_name.replace(/[^a-zA-Z0-9-_]/g, "-")
489
+ const diskFilename = `${safeName}-${Date.now()}.md`
490
+
491
+ const result: ReportGenerationResult = {
492
+ report: reportMarkdown,
478
493
  findingsCount: counts,
479
494
  filename: `${args.project_name}-audit-report-${auditDate}.md`,
480
495
  }
496
+
497
+ try {
498
+ const loadConfig = deps.loadConfig ?? loadArgusConfig
499
+ const projectDir = resolveProjectDir(context)
500
+ const config = loadConfig(projectDir)
501
+ const outputDir = config.reporting?.output_dir ?? ".opencode/reports/"
502
+ const fullPath = path.join(projectDir, outputDir, diskFilename)
503
+ await Bun.write(fullPath, reportMarkdown)
504
+ result.filePath = fullPath
505
+ } catch (err: unknown) {
506
+ const logger = createLogger()
507
+ const message = err instanceof Error ? err.message : String(err)
508
+ logger.warn(`Failed to write report to disk: ${message}`)
509
+ }
510
+
511
+ return result
481
512
  }
482
513
 
483
514
  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