solidity-argus 0.3.6 → 0.5.6

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.
Files changed (107) hide show
  1. package/AGENTS.md +13 -6
  2. package/README.md +24 -12
  3. package/package.json +7 -3
  4. package/skills/checklists/cyfrin-best-practices-runtime/SKILL.md +1 -0
  5. package/skills/checklists/cyfrin-best-practices-upgrades/SKILL.md +1 -0
  6. package/skills/checklists/cyfrin-defi-core/SKILL.md +1 -0
  7. package/skills/checklists/cyfrin-defi-integrations/SKILL.md +1 -0
  8. package/skills/checklists/cyfrin-gas/SKILL.md +1 -0
  9. package/skills/checklists/general-audit/SKILL.md +1 -0
  10. package/skills/methodology/audit-workflow/SKILL.md +1 -0
  11. package/skills/methodology/report-template/SKILL.md +1 -0
  12. package/skills/methodology/severity-classification/SKILL.md +1 -0
  13. package/skills/protocol-patterns/amm-dex/SKILL.md +1 -0
  14. package/skills/protocol-patterns/bridges-cross-chain/SKILL.md +1 -0
  15. package/skills/protocol-patterns/dao-governance/SKILL.md +1 -0
  16. package/skills/protocol-patterns/lending-borrowing/SKILL.md +1 -0
  17. package/skills/protocol-patterns/staking-vesting/SKILL.md +1 -0
  18. package/skills/vulnerability-patterns/flash-loan-attacks/SKILL.md +0 -50
  19. package/skills/vulnerability-patterns/oracle-manipulation/SKILL.md +0 -63
  20. package/src/agents/argus-prompt.ts +98 -33
  21. package/src/agents/pythia-prompt.ts +18 -1
  22. package/src/agents/scribe-prompt.ts +32 -10
  23. package/src/agents/sentinel-prompt.ts +19 -0
  24. package/src/agents/themis-prompt.ts +110 -0
  25. package/src/cli/commands/doctor.ts +29 -17
  26. package/src/config/loader.ts +29 -5
  27. package/src/config/schema.ts +45 -45
  28. package/src/constants/defaults.ts +1 -0
  29. package/src/create-hooks.ts +851 -142
  30. package/src/create-managers.ts +4 -2
  31. package/src/create-tools.ts +5 -1
  32. package/src/features/audit-enforcer/audit-enforcer.ts +1 -11
  33. package/src/features/background-agent/background-manager.ts +32 -5
  34. package/src/features/error-recovery/tool-error-recovery.ts +1 -0
  35. package/src/features/persistent-state/audit-state-manager.ts +272 -29
  36. package/src/features/persistent-state/event-sink.ts +96 -25
  37. package/src/features/persistent-state/findings-materializer.ts +57 -3
  38. package/src/features/persistent-state/global-run-index.ts +86 -8
  39. package/src/features/persistent-state/index.ts +7 -1
  40. package/src/features/persistent-state/run-finalizer.ts +116 -7
  41. package/src/features/persistent-state/run-pruner.ts +93 -0
  42. package/src/hooks/agent-tracker.ts +14 -2
  43. package/src/hooks/compaction-hook.ts +7 -16
  44. package/src/hooks/config-handler.ts +83 -29
  45. package/src/hooks/context-budget.ts +4 -5
  46. package/src/hooks/event-hook.ts +213 -57
  47. package/src/hooks/knowledge-sync-hook.ts +2 -3
  48. package/src/hooks/safe-create-hook.ts +13 -1
  49. package/src/hooks/system-prompt-hook.ts +20 -39
  50. package/src/hooks/tool-tracking-hook.ts +606 -326
  51. package/src/index.ts +15 -1
  52. package/src/knowledge/scvd-client.ts +2 -4
  53. package/src/knowledge/scvd-errors.ts +25 -2
  54. package/src/knowledge/scvd-index.ts +7 -5
  55. package/src/knowledge/scvd-sync.ts +6 -6
  56. package/src/managers/types.ts +20 -2
  57. package/src/shared/agent-names.ts +23 -0
  58. package/src/shared/audit-artifact-resolver.ts +8 -3
  59. package/src/shared/audit-phases.ts +12 -0
  60. package/src/shared/cache-paths.ts +41 -0
  61. package/src/shared/drop-diagnostics.ts +2 -2
  62. package/src/shared/forge-errors.ts +31 -0
  63. package/src/shared/forge-runner.ts +30 -0
  64. package/src/shared/format-error.ts +3 -0
  65. package/src/shared/index.ts +9 -0
  66. package/src/shared/key-tools.ts +39 -0
  67. package/src/shared/logger.ts +7 -7
  68. package/src/shared/path-containment.ts +25 -0
  69. package/src/shared/path-utils.ts +11 -0
  70. package/src/shared/report-path-resolver.ts +4 -2
  71. package/src/shared/safe-emit.ts +24 -0
  72. package/src/shared/token-utils.ts +5 -0
  73. package/src/shared/type-guards.ts +8 -0
  74. package/src/shared/validation-constants.ts +52 -0
  75. package/src/skills/analysis/cluster.ts +1 -114
  76. package/src/skills/analysis/normalize.ts +2 -114
  77. package/src/skills/analysis/stopwords.ts +109 -0
  78. package/src/skills/argus-skill-resolver.ts +6 -3
  79. package/src/solodit-lifecycle.ts +153 -37
  80. package/src/state/adapters.ts +60 -66
  81. package/src/state/finding-aggregation.ts +6 -8
  82. package/src/state/finding-fingerprint.ts +1 -1
  83. package/src/state/finding-store.ts +31 -9
  84. package/src/state/index.ts +1 -1
  85. package/src/state/projectors.ts +27 -19
  86. package/src/state/schemas.ts +8 -32
  87. package/src/state/types.ts +3 -0
  88. package/src/tools/contract-analyzer-tool.ts +4 -6
  89. package/src/tools/forge-coverage-tool.ts +10 -35
  90. package/src/tools/forge-fuzz-tool.ts +21 -51
  91. package/src/tools/forge-test-tool.ts +25 -47
  92. package/src/tools/gas-analysis-tool.ts +12 -41
  93. package/src/tools/pattern-checker-tool.ts +37 -15
  94. package/src/tools/pattern-loader.ts +18 -4
  95. package/src/tools/persist-deduped-tool.ts +94 -0
  96. package/src/tools/proxy-detection-tool.ts +35 -34
  97. package/src/tools/read-findings-tool.ts +390 -0
  98. package/src/tools/record-finding-tool.ts +120 -25
  99. package/src/tools/report-generator-tool.ts +396 -328
  100. package/src/tools/report-preflight.ts +5 -1
  101. package/src/tools/slither-tool.ts +55 -16
  102. package/src/tools/solodit-search-tool.ts +260 -112
  103. package/src/tools/sync-knowledge-tool.ts +2 -3
  104. package/src/utils/solidity-parser.ts +39 -24
  105. package/src/features/migration/index.ts +0 -14
  106. package/src/features/migration/migration-adapter.ts +0 -151
  107. package/src/features/migration/parity-telemetry.ts +0 -133
@@ -45,6 +45,10 @@ function hasCompletedTool(events: AuditEvent[], toolName: string): boolean {
45
45
  return false
46
46
  }
47
47
 
48
+ function hasRunFinalized(events: AuditEvent[]): boolean {
49
+ return events.some((event) => event.type === "run.finalized")
50
+ }
51
+
48
52
  export function checkReportPreflight(
49
53
  events: AuditEvent[],
50
54
  options: PreflightOptions = {},
@@ -53,7 +57,7 @@ export function checkReportPreflight(
53
57
  if (!hasSessionCreated(events)) {
54
58
  missingLifecycle.push("session.created")
55
59
  }
56
- if (!hasSessionDeleted(events)) {
60
+ if (!hasSessionDeleted(events) && !hasRunFinalized(events)) {
57
61
  missingLifecycle.push("session.deleted")
58
62
  }
59
63
 
@@ -230,20 +230,22 @@ async function defaultSpawnFn(
230
230
  return { stdout, exitCode }
231
231
  }
232
232
 
233
- const defaultFlattenDeps: FlattenFallbackDeps = {
234
- runCommand: runSlitherCommand,
235
- hasBinary,
236
- ensureSolc,
237
- parseSolcVersion,
238
- extractContractNames,
239
- spawnFn: defaultSpawnFn,
240
- cwd: process.cwd(),
233
+ function getDefaultFlattenDeps(): FlattenFallbackDeps {
234
+ return {
235
+ runCommand: runSlitherCommand,
236
+ hasBinary,
237
+ ensureSolc,
238
+ parseSolcVersion,
239
+ extractContractNames,
240
+ spawnFn: defaultSpawnFn,
241
+ cwd: process.cwd(),
242
+ }
241
243
  }
242
244
 
243
245
  export async function flattenFallback(
244
246
  args: SlitherArgs,
245
247
  context: ToolContext,
246
- deps: FlattenFallbackDeps = defaultFlattenDeps,
248
+ deps: FlattenFallbackDeps = getDefaultFlattenDeps(),
247
249
  ): Promise<SlitherAnalyzeResult | undefined> {
248
250
  const startedAt = Date.now()
249
251
 
@@ -309,14 +311,37 @@ export async function flattenFallback(
309
311
  ],
310
312
  { timeout: 5_000 },
311
313
  )
312
- if (findResult.exitCode !== 0) return undefined
314
+ if (findResult.exitCode !== 0) {
315
+ return {
316
+ success: false,
317
+ findingsCount: 0,
318
+ findings: [],
319
+ executionTime: Date.now() - startedAt,
320
+ errors: ["[flatten-fallback] find command failed — could not discover .sol files"],
321
+ }
322
+ }
313
323
  solFiles = findResult.stdout.trim().split("\n").filter(Boolean)
314
- } catch (_e) {
315
- return undefined
324
+ } catch (e) {
325
+ const msg = e instanceof Error ? e.message : String(e)
326
+ return {
327
+ success: false,
328
+ findingsCount: 0,
329
+ findings: [],
330
+ executionTime: Date.now() - startedAt,
331
+ errors: [`[flatten-fallback] file discovery failed: ${msg}`],
332
+ }
316
333
  }
317
334
  }
318
335
 
319
- if (solFiles.length === 0) return undefined
336
+ if (solFiles.length === 0) {
337
+ return {
338
+ success: false,
339
+ findingsCount: 0,
340
+ findings: [],
341
+ executionTime: Date.now() - startedAt,
342
+ errors: ["[flatten-fallback] no .sol files found in target directory"],
343
+ }
344
+ }
320
345
 
321
346
  const tmpDir = mkdtempSync(join(tmpdir(), "argus-slither-"))
322
347
  const allFindings: Finding[] = []
@@ -412,6 +437,7 @@ function parseFindings(payload: SlitherPayload): Finding[] {
412
437
  id: createFindingID(check, file, lines),
413
438
  check,
414
439
  severity: mapSeverity(detector.impact),
440
+ impact: detector.impact,
415
441
  confidence: mapConfidence(detector.confidence),
416
442
  description: detector.description ?? "",
417
443
  file,
@@ -431,9 +457,22 @@ export async function executeSlitherAnalyze(
431
457
  const startedAt = Date.now()
432
458
  context.metadata({ title: `Slither analysis: ${args.target}` })
433
459
 
460
+ if (args.solc_version && !/^\d+\.\d+\.\d+$/.test(args.solc_version)) {
461
+ return {
462
+ success: false,
463
+ findingsCount: 0,
464
+ findings: [],
465
+ executionTime: Date.now() - startedAt,
466
+ errors: [
467
+ `Invalid solc_version format: "${args.solc_version}". Expected semver format (e.g. 0.8.20)`,
468
+ ],
469
+ error: `Invalid solc_version format: "${args.solc_version}". Expected semver format (e.g. 0.8.20)`,
470
+ }
471
+ }
472
+
434
473
  if (args.via_ir) {
435
474
  const fallbackResult = await flattenFallback(args, context, {
436
- ...defaultFlattenDeps,
475
+ ...getDefaultFlattenDeps(),
437
476
  runCommand,
438
477
  cwd: projectDir,
439
478
  })
@@ -471,7 +510,7 @@ export async function executeSlitherAnalyze(
471
510
  const message = error instanceof Error ? error.message : "Unknown parse error"
472
511
  if (shouldTryFlattenFallback(errors, runResult.stderr)) {
473
512
  const fallbackResult = await flattenFallback(args, context, {
474
- ...defaultFlattenDeps,
513
+ ...getDefaultFlattenDeps(),
475
514
  runCommand,
476
515
  cwd: projectDir,
477
516
  })
@@ -496,7 +535,7 @@ export async function executeSlitherAnalyze(
496
535
 
497
536
  if (!success && findings.length === 0 && shouldTryFlattenFallback(errors, runResult.stderr)) {
498
537
  const fallbackResult = await flattenFallback(args, context, {
499
- ...defaultFlattenDeps,
538
+ ...getDefaultFlattenDeps(),
500
539
  runCommand,
501
540
  cwd: projectDir,
502
541
  })
@@ -1,24 +1,25 @@
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
+ import { isSoloditAvailable } from "../solodit-lifecycle"
5
5
 
6
6
  const logger = createLogger()
7
7
 
8
- const SOLODIT_MCP_SERVER = "solodit-mcp"
9
8
  const SOLODIT_MCP_TOOLS = ["search", "search_findings"] as const
10
9
  const DEFAULT_LIMIT = 10
11
- const DEFAULT_SOLODIT_PORT = 3000
10
+ export const DEFAULT_SOLODIT_PORT = 54173
12
11
  const SOLODIT_HTTP_TIMEOUT_MS = 10_000
12
+ const SOLODIT_TRPC_TIMEOUT_MS = 15_000
13
+ const SOLODIT_TRPC_ENDPOINT = "https://solodit.cyfrin.io/api/trpc/findings.get"
13
14
 
14
15
  type SoloditSearchArgs = {
15
16
  query: string
16
- severity?: string[]
17
17
  limit?: number
18
18
  }
19
19
 
20
- type SoloditFinding = {
20
+ export type SoloditFinding = {
21
21
  title: string
22
+ slug: string
22
23
  severity: string
23
24
  description: string
24
25
  protocol: string
@@ -33,22 +34,38 @@ export type SoloditSearchResult = {
33
34
  error?: string
34
35
  }
35
36
 
36
- export type CallMcpTool = (
37
- server: string,
38
- tool: string,
39
- args: Record<string, unknown>,
40
- ) => Promise<unknown>
37
+ /** Fetch abstraction for testing */
38
+ export type SoloditFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>
41
39
 
42
- type McpCapableContext = ToolContext & { callMcpTool: CallMcpTool }
40
+ // ---------------------------------------------------------------------------
41
+ // Shared helpers
42
+ // ---------------------------------------------------------------------------
43
43
 
44
- function hasMcpCapability(ctx: ToolContext): ctx is McpCapableContext {
45
- return "callMcpTool" in ctx
44
+ /** Extract severity from common audit title prefixes like [H-01], [M-17], H-1:, M-2: */
45
+ function extractSeverityFromTitle(title: string): string {
46
+ const match = title.match(/^\[?([HMhm])[-\s]?\d+\]?[:\s]/)
47
+ if (match) {
48
+ const letter = match[1]?.toUpperCase()
49
+ if (letter === "H") return "High"
50
+ if (letter === "M") return "Medium"
51
+ }
52
+ const prefixMatch = title.match(/^\[?(Critical|High|Medium|Low|Informational)\]?[:\s-]/i)
53
+ if (prefixMatch) {
54
+ const severity = prefixMatch[1]
55
+ if (!severity) return ""
56
+ const s = severity.toLowerCase()
57
+ return s.charAt(0).toUpperCase() + s.slice(1)
58
+ }
59
+ return ""
46
60
  }
47
61
 
62
+ const SOLODIT_BASE_URL = "https://solodit.cyfrin.io/issues"
63
+
48
64
  function parseFinding(raw: unknown): SoloditFinding {
49
65
  if (typeof raw !== "object" || raw === null) {
50
66
  return {
51
67
  title: "",
68
+ slug: "",
52
69
  severity: "",
53
70
  description: "",
54
71
  protocol: "",
@@ -58,20 +75,32 @@ function parseFinding(raw: unknown): SoloditFinding {
58
75
  }
59
76
 
60
77
  const obj = raw as Record<string, unknown>
78
+ const title = typeof obj.title === "string" ? obj.title : ""
79
+ const slug = typeof obj.slug === "string" ? obj.slug : ""
80
+ const severity =
81
+ typeof obj.severity === "string" && obj.severity.length > 0
82
+ ? obj.severity
83
+ : extractSeverityFromTitle(title)
84
+ const url =
85
+ typeof obj.url === "string" && obj.url.length > 0
86
+ ? obj.url
87
+ : slug.length > 0
88
+ ? `${SOLODIT_BASE_URL}/${slug}`
89
+ : ""
90
+
61
91
  return {
62
- title: typeof obj.title === "string" ? obj.title : "",
63
- severity: typeof obj.severity === "string" ? obj.severity : "",
64
- description: typeof obj.description === "string" ? obj.description : "",
92
+ title,
93
+ slug,
94
+ severity,
95
+ description: typeof obj.description === "string" ? obj.description : title,
65
96
  protocol: typeof obj.protocol === "string" ? obj.protocol : "",
66
- url: typeof obj.url === "string" ? obj.url : "",
97
+ url,
67
98
  remediation: typeof obj.remediation === "string" ? obj.remediation : "",
68
99
  }
69
100
  }
70
101
 
71
102
  function parseFindings(response: unknown): SoloditFinding[] {
72
- if (!Array.isArray(response)) {
73
- return []
74
- }
103
+ if (!Array.isArray(response)) return []
75
104
  return response.map(parseFinding)
76
105
  }
77
106
 
@@ -89,49 +118,18 @@ function parseFindingsFromAnyResponse(response: unknown): SoloditFinding[] {
89
118
 
90
119
  function hasMcpError(response: unknown): boolean {
91
120
  if (typeof response !== "object" || response === null) return false
92
- const obj = response as Record<string, unknown>
93
- return "error" in obj
94
- }
95
-
96
- function normalizeImpacts(
97
- severity?: string[],
98
- ): Array<"HIGH" | "MEDIUM" | "LOW" | "GAS"> | undefined {
99
- if (!severity || severity.length === 0) return undefined
100
- const allowed = new Set(["HIGH", "MEDIUM", "LOW", "GAS"] as const)
101
- const impacts = severity
102
- .map((s) => s.toUpperCase())
103
- .filter((s): s is "HIGH" | "MEDIUM" | "LOW" | "GAS" =>
104
- allowed.has(s as "HIGH" | "MEDIUM" | "LOW" | "GAS"),
105
- )
106
- return impacts.length > 0 ? impacts : undefined
121
+ return "error" in (response as Record<string, unknown>)
107
122
  }
108
123
 
109
124
  function buildMcpArgs(
110
125
  toolName: (typeof SOLODIT_MCP_TOOLS)[number],
111
126
  query: string,
112
127
  limit: number,
113
- severity?: string[],
114
128
  ): Record<string, unknown> {
115
129
  if (toolName === "search") {
116
130
  return { keywords: query }
117
131
  }
118
-
119
- const impact = normalizeImpacts(severity)
120
- return {
121
- keywords: query,
122
- ...(impact ? { impact } : {}),
123
- pageSize: limit,
124
- }
125
- }
126
-
127
- function filterFindingsBySeverity(
128
- findings: SoloditFinding[],
129
- severities?: string[],
130
- ): SoloditFinding[] {
131
- if (!severities || severities.length === 0) return findings
132
-
133
- const allowed = new Set(severities.map((s) => s.toLowerCase()))
134
- return findings.filter((finding) => allowed.has(finding.severity.toLowerCase()))
132
+ return { keywords: query, pageSize: limit }
135
133
  }
136
134
 
137
135
  function parseSseData(body: string): unknown {
@@ -185,17 +183,26 @@ function extractFindingsFromMcpResponse(envelope: unknown): SoloditFinding[] {
185
183
  return []
186
184
  }
187
185
 
188
- async function callSoloditHttp(
186
+ // ---------------------------------------------------------------------------
187
+ // Primary path: MCP HTTP
188
+ // ---------------------------------------------------------------------------
189
+
190
+ async function callSoloditMcpHttp(
189
191
  query: string,
190
192
  limit: number,
191
- severities?: string[],
192
- port: number = DEFAULT_SOLODIT_PORT,
193
- ): Promise<SoloditSearchResult> {
193
+ port: number,
194
+ fetchImpl: SoloditFetch = fetch,
195
+ ): Promise<SoloditSearchResult | null> {
196
+ if (!isSoloditAvailable()) {
197
+ logger.debug(`[solodit] MCP not available — skipping HTTP primary path`)
198
+ return null
199
+ }
200
+
194
201
  let lastError: string | undefined
195
202
 
196
203
  for (const toolName of SOLODIT_MCP_TOOLS) {
197
204
  try {
198
- const response = await fetch(`http://localhost:${port}/mcp`, {
205
+ const response = await fetchImpl(`http://localhost:${port}/mcp`, {
199
206
  method: "POST",
200
207
  headers: {
201
208
  "Content-Type": "application/json",
@@ -204,7 +211,7 @@ async function callSoloditHttp(
204
211
  body: JSON.stringify({
205
212
  jsonrpc: "2.0",
206
213
  method: "tools/call",
207
- params: { name: toolName, arguments: buildMcpArgs(toolName, query, limit, severities) },
214
+ params: { name: toolName, arguments: buildMcpArgs(toolName, query, limit) },
208
215
  id: 1,
209
216
  }),
210
217
  signal: AbortSignal.timeout(SOLODIT_HTTP_TIMEOUT_MS),
@@ -222,8 +229,7 @@ async function callSoloditHttp(
222
229
  continue
223
230
  }
224
231
 
225
- const findings = filterFindingsBySeverity(parseFindingsFromAnyResponse(envelope), severities)
226
-
232
+ const findings = parseFindingsFromAnyResponse(envelope)
227
233
  return { results: findings.slice(0, limit), totalFound: findings.length, query }
228
234
  } catch (error) {
229
235
  const message = error instanceof Error ? error.message : "Unknown error"
@@ -231,86 +237,228 @@ async function callSoloditHttp(
231
237
  }
232
238
  }
233
239
 
234
- return { results: [], totalFound: 0, query, error: lastError ?? "Solodit MCP call failed" }
240
+ logger.debug(
241
+ `[solodit] MCP HTTP failed: ${lastError ?? "all tools failed"} — will try tRPC fallback`,
242
+ )
243
+ return null
244
+ }
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // Fallback path: tRPC direct to solodit.cyfrin.io
248
+ // ---------------------------------------------------------------------------
249
+
250
+ function buildTrpcInput(query: string, page: number = 1): string {
251
+ const inner = JSON.stringify([
252
+ { filters: 1, page: 20 },
253
+ {
254
+ keywords: 2,
255
+ firms: 3,
256
+ tags: 4,
257
+ forked: 5,
258
+ impact: 6,
259
+ user: -1,
260
+ protocol: -1,
261
+ reported: 10,
262
+ reportedAfter: -1,
263
+ protocolCategory: 13,
264
+ minFinders: 14,
265
+ maxFinders: 15,
266
+ rarityScore: 16,
267
+ qualityScore: 16,
268
+ bookmarked: 17,
269
+ read: 17,
270
+ unread: 17,
271
+ sortField: 18,
272
+ sortDirection: 19,
273
+ },
274
+ query,
275
+ [],
276
+ [],
277
+ [],
278
+ [7, 8, 9],
279
+ "HIGH",
280
+ "MEDIUM",
281
+ "LOW",
282
+ { label: 11, value: 12 },
283
+ "All time",
284
+ "alltime",
285
+ [],
286
+ "1",
287
+ "100",
288
+ 1,
289
+ true,
290
+ "Recency",
291
+ "Desc",
292
+ page,
293
+ ])
294
+ return JSON.stringify({ 0: inner })
235
295
  }
236
296
 
237
- export async function executeSoloditSearch(
238
- args: SoloditSearchArgs,
239
- context: ToolContext,
240
- callMcpTool?: CallMcpTool,
241
- port: number = DEFAULT_SOLODIT_PORT,
242
- ): Promise<SoloditSearchResult> {
243
- const { query } = args
244
- const limit = args.limit ?? DEFAULT_LIMIT
297
+ function truncateDescription(content: string): string {
298
+ return content.length > 500 ? `${content.slice(0, 500)}...` : content
299
+ }
245
300
 
246
- context.metadata({ title: `Solodit search: ${query}` })
301
+ function mapTrpcFinding(raw: unknown): SoloditFinding {
302
+ if (typeof raw !== "object" || raw === null) {
303
+ return {
304
+ title: "",
305
+ slug: "",
306
+ severity: "",
307
+ description: "",
308
+ protocol: "",
309
+ url: "",
310
+ remediation: "",
311
+ }
312
+ }
247
313
 
248
- const mcpCaller = callMcpTool ?? (hasMcpCapability(context) ? context.callMcpTool : undefined)
314
+ const finding = raw as Record<string, unknown>
315
+ const slug = typeof finding.slug === "string" ? finding.slug : ""
316
+ const content = typeof finding.content === "string" ? finding.content : ""
249
317
 
250
- // When MCP is unavailable or no caller exists, go straight to HTTP fallback
251
- if (!mcpCaller) {
252
- const reason = !soloditAvailable ? "MCP unavailable" : "no callMcpTool"
253
- logger.debug(`[solodit] ${reason} using HTTP fallback for query: ${query}`)
254
- return callSoloditHttp(query, limit, args.severity, port)
318
+ return {
319
+ title: typeof finding.title === "string" ? finding.title : "",
320
+ slug,
321
+ severity: typeof finding.impact === "string" ? finding.impact : "",
322
+ description: truncateDescription(content),
323
+ protocol: typeof finding.protocol_name === "string" ? finding.protocol_name : "",
324
+ url: slug ? `https://solodit.cyfrin.io/issues/${slug}` : "",
325
+ remediation: "",
255
326
  }
327
+ }
256
328
 
257
- // MCP path: try each tool name in order, fall back to HTTP on any failure
258
- let hadMcpError = false
259
- for (const toolName of SOLODIT_MCP_TOOLS) {
260
- try {
261
- logger.debug(
262
- `[solodit] Trying MCP tool '${toolName}' on server '${SOLODIT_MCP_SERVER}' for query: ${query}`,
263
- )
264
- const response = await mcpCaller(
265
- SOLODIT_MCP_SERVER,
266
- toolName,
267
- buildMcpArgs(toolName, query, limit, args.severity),
268
- )
269
-
270
- if (hasMcpError(response)) {
271
- logger.debug(`[solodit] MCP tool '${toolName}' returned error envelope — trying next tool`)
272
- hadMcpError = true
273
- continue
329
+ function parseTrpcData(dataStr: string): { findings?: unknown } {
330
+ const cleaned = dataStr.trim().replace(/^\(/, "").replace(/\)$/, "")
331
+
332
+ // Try standard JSON first
333
+ try {
334
+ return JSON.parse(cleaned) as { findings?: unknown }
335
+ } catch {
336
+ // Fall through to unquoted-key fixup
337
+ }
338
+
339
+ // Fallback: attempt to fix unquoted keys
340
+ try {
341
+ const fixed = cleaned.replace(/([{,]\s*)([a-zA-Z_]\w*)\s*:/g, '$1"$2":')
342
+ return JSON.parse(fixed) as { findings?: unknown }
343
+ } catch {
344
+ return {}
345
+ }
346
+ }
347
+
348
+ async function callSoloditTrpc(
349
+ query: string,
350
+ limit: number,
351
+ fetchImpl: SoloditFetch = fetch,
352
+ ): Promise<SoloditSearchResult> {
353
+ try {
354
+ const input = buildTrpcInput(query)
355
+ const url = `${SOLODIT_TRPC_ENDPOINT}?batch=1&input=${encodeURIComponent(input)}`
356
+ const response = await fetchImpl(url, {
357
+ method: "GET",
358
+ headers: {
359
+ accept: "*/*",
360
+ referer: "https://solodit.cyfrin.io/",
361
+ origin: "https://solodit.cyfrin.io",
362
+ },
363
+ signal: AbortSignal.timeout(SOLODIT_TRPC_TIMEOUT_MS),
364
+ })
365
+
366
+ if (!response.ok) {
367
+ return {
368
+ results: [],
369
+ totalFound: 0,
370
+ query,
371
+ error: `Solodit tRPC returned ${response.status}`,
274
372
  }
373
+ }
275
374
 
276
- const findings = filterFindingsBySeverity(
277
- parseFindingsFromAnyResponse(response),
278
- args.severity,
279
- )
375
+ const responseText = await response.text()
376
+ const batchResults = JSON.parse(responseText) as Array<Record<string, unknown>>
377
+ const first = batchResults[0] as { result?: { data?: unknown } } | undefined
378
+ const dataStr = typeof first?.result?.data === "string" ? first.result.data : ""
280
379
 
281
- logger.debug(`[solodit] MCP tool '${toolName}' succeeded — found ${findings.length} findings`)
380
+ if (!dataStr) {
282
381
  return {
283
- results: findings.slice(0, limit),
284
- totalFound: findings.length,
382
+ results: [],
383
+ totalFound: 0,
285
384
  query,
385
+ error: "Solodit tRPC response did not include result data",
286
386
  }
287
- } catch {
288
- logger.debug(`[solodit] MCP tool '${toolName}' threw — trying next tool`)
289
- hadMcpError = true
290
387
  }
388
+
389
+ const parsed = parseTrpcData(dataStr)
390
+ if (!Array.isArray(parsed.findings)) {
391
+ return { results: [], totalFound: 0, query, error: "Failed to parse Solodit response" }
392
+ }
393
+ const findingsRaw = parsed.findings
394
+ const findings = findingsRaw.map(mapTrpcFinding)
395
+ return { results: findings.slice(0, limit), totalFound: findings.length, query }
396
+ } catch (error) {
397
+ const message = error instanceof Error ? error.message : "Unknown error"
398
+ logger.debug(`[solodit] tRPC fallback error for query '${query}': ${message}`)
399
+ return { results: [], totalFound: 0, query, error: `Solodit tRPC fallback failed: ${message}` }
291
400
  }
401
+ }
292
402
 
293
- // All MCP tools failed — fall back to HTTP
294
- logger.debug(
295
- `[solodit] All MCP tools failed (hadMcpError=${hadMcpError}) — falling back to HTTP for query: ${query}`,
296
- )
297
- return callSoloditHttp(query, limit, args.severity, port)
403
+ // ---------------------------------------------------------------------------
404
+ // Main entry point
405
+ // ---------------------------------------------------------------------------
406
+
407
+ export async function executeSoloditSearch(
408
+ args: SoloditSearchArgs,
409
+ context: ToolContext,
410
+ port: number = DEFAULT_SOLODIT_PORT,
411
+ fetchImpl: SoloditFetch = fetch,
412
+ ): Promise<SoloditSearchResult> {
413
+ const { query } = args
414
+ const limit = args.limit ?? DEFAULT_LIMIT
415
+
416
+ context.metadata({ title: `Solodit search: ${query}` })
417
+
418
+ // Primary: MCP HTTP to local solodit-mcp server
419
+ const mcpResult = await callSoloditMcpHttp(query, limit, port, fetchImpl)
420
+ if (mcpResult !== null && mcpResult.results.length > 0) {
421
+ logger.debug(`[solodit] MCP HTTP returned ${mcpResult.results.length} findings for '${query}'`)
422
+ return mcpResult
423
+ }
424
+
425
+ // Fallback: tRPC direct to solodit.cyfrin.io
426
+ logger.debug(`[solodit] Falling back to tRPC for query: ${query}`)
427
+ return callSoloditTrpc(query, limit, fetchImpl)
298
428
  }
299
429
 
300
430
  export function createSoloditSearchTool(port: number = DEFAULT_SOLODIT_PORT): ToolDefinition {
301
431
  return tool({
302
432
  description:
303
- "Search Solodit audit findings database for known vulnerabilities and past audit results via the Solodit MCP server.",
433
+ "Search Solodit audit findings database for known vulnerabilities and past audit results.",
304
434
  args: {
305
435
  query: tool.schema.string(),
306
- severity: tool.schema.array(tool.schema.string()).optional(),
307
436
  limit: tool.schema.number().optional(),
308
437
  },
309
438
  async execute(args, context) {
310
- const result = await executeSoloditSearch(args, context, undefined, port)
439
+ const result = await executeSoloditSearch(args, context, port)
311
440
  return JSON.stringify(result)
312
441
  },
313
442
  })
314
443
  }
315
444
 
316
445
  export const soloditSearchTool = createSoloditSearchTool()
446
+
447
+ // ---------------------------------------------------------------------------
448
+ // Test-only exports
449
+ // ---------------------------------------------------------------------------
450
+
451
+ export const _testExports = {
452
+ buildTrpcInput,
453
+ mapTrpcFinding,
454
+ truncateDescription,
455
+ callSoloditMcpHttp,
456
+ callSoloditTrpc,
457
+ parseSseData,
458
+ extractFindingsFromMcpResponse,
459
+ parseFindingsFromAnyResponse,
460
+ parseFinding,
461
+ buildMcpArgs,
462
+ hasMcpError,
463
+ parseTrpcData,
464
+ }
@@ -1,10 +1,9 @@
1
- import os from "node:os"
2
- import path from "node:path"
3
1
  import { type ToolContext, tool } from "@opencode-ai/plugin"
4
2
  import { loadArgusConfig } from "../config/loader"
5
3
  import type { ArgusConfig } from "../config/types"
6
4
  import { ScvdClient } from "../knowledge/scvd-client"
7
5
  import { type SyncResult, syncAll, syncIncremental } from "../knowledge/scvd-sync"
6
+ import { getScvdIndexPath } from "../shared/cache-paths"
8
7
  import { resolveProjectDir } from "../shared/project-utils"
9
8
 
10
9
  type SyncKnowledgeArgs = {
@@ -82,7 +81,7 @@ export async function executeSyncKnowledge(
82
81
  }
83
82
 
84
83
  const apiUrl = argusConfig.knowledge?.scvd?.apiUrl ?? DEFAULT_SCVD_API_URL
85
- const indexPath = path.join(os.homedir(), ".cache", "solidity-argus", "scvd-index.json")
84
+ const indexPath = getScvdIndexPath()
86
85
 
87
86
  const client = dependencies.createClient(apiUrl, context.abort)
88
87
  const result = args.force