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.
- package/AGENTS.md +13 -6
- package/README.md +24 -12
- package/package.json +7 -3
- package/skills/checklists/cyfrin-best-practices-runtime/SKILL.md +1 -0
- package/skills/checklists/cyfrin-best-practices-upgrades/SKILL.md +1 -0
- package/skills/checklists/cyfrin-defi-core/SKILL.md +1 -0
- package/skills/checklists/cyfrin-defi-integrations/SKILL.md +1 -0
- package/skills/checklists/cyfrin-gas/SKILL.md +1 -0
- package/skills/checklists/general-audit/SKILL.md +1 -0
- package/skills/methodology/audit-workflow/SKILL.md +1 -0
- package/skills/methodology/report-template/SKILL.md +1 -0
- package/skills/methodology/severity-classification/SKILL.md +1 -0
- package/skills/protocol-patterns/amm-dex/SKILL.md +1 -0
- package/skills/protocol-patterns/bridges-cross-chain/SKILL.md +1 -0
- package/skills/protocol-patterns/dao-governance/SKILL.md +1 -0
- package/skills/protocol-patterns/lending-borrowing/SKILL.md +1 -0
- package/skills/protocol-patterns/staking-vesting/SKILL.md +1 -0
- package/skills/vulnerability-patterns/flash-loan-attacks/SKILL.md +0 -50
- package/skills/vulnerability-patterns/oracle-manipulation/SKILL.md +0 -63
- package/src/agents/argus-prompt.ts +98 -33
- package/src/agents/pythia-prompt.ts +18 -1
- package/src/agents/scribe-prompt.ts +32 -10
- package/src/agents/sentinel-prompt.ts +19 -0
- package/src/agents/themis-prompt.ts +110 -0
- package/src/cli/commands/doctor.ts +29 -17
- package/src/config/loader.ts +29 -5
- package/src/config/schema.ts +45 -45
- package/src/constants/defaults.ts +1 -0
- package/src/create-hooks.ts +851 -142
- package/src/create-managers.ts +4 -2
- package/src/create-tools.ts +5 -1
- package/src/features/audit-enforcer/audit-enforcer.ts +1 -11
- package/src/features/background-agent/background-manager.ts +32 -5
- package/src/features/error-recovery/tool-error-recovery.ts +1 -0
- package/src/features/persistent-state/audit-state-manager.ts +272 -29
- package/src/features/persistent-state/event-sink.ts +96 -25
- package/src/features/persistent-state/findings-materializer.ts +57 -3
- package/src/features/persistent-state/global-run-index.ts +86 -8
- package/src/features/persistent-state/index.ts +7 -1
- package/src/features/persistent-state/run-finalizer.ts +116 -7
- package/src/features/persistent-state/run-pruner.ts +93 -0
- package/src/hooks/agent-tracker.ts +14 -2
- package/src/hooks/compaction-hook.ts +7 -16
- package/src/hooks/config-handler.ts +83 -29
- package/src/hooks/context-budget.ts +4 -5
- package/src/hooks/event-hook.ts +213 -57
- package/src/hooks/knowledge-sync-hook.ts +2 -3
- package/src/hooks/safe-create-hook.ts +13 -1
- package/src/hooks/system-prompt-hook.ts +20 -39
- package/src/hooks/tool-tracking-hook.ts +606 -326
- package/src/index.ts +15 -1
- package/src/knowledge/scvd-client.ts +2 -4
- package/src/knowledge/scvd-errors.ts +25 -2
- package/src/knowledge/scvd-index.ts +7 -5
- package/src/knowledge/scvd-sync.ts +6 -6
- package/src/managers/types.ts +20 -2
- package/src/shared/agent-names.ts +23 -0
- package/src/shared/audit-artifact-resolver.ts +8 -3
- package/src/shared/audit-phases.ts +12 -0
- package/src/shared/cache-paths.ts +41 -0
- package/src/shared/drop-diagnostics.ts +2 -2
- package/src/shared/forge-errors.ts +31 -0
- package/src/shared/forge-runner.ts +30 -0
- package/src/shared/format-error.ts +3 -0
- package/src/shared/index.ts +9 -0
- package/src/shared/key-tools.ts +39 -0
- package/src/shared/logger.ts +7 -7
- package/src/shared/path-containment.ts +25 -0
- package/src/shared/path-utils.ts +11 -0
- package/src/shared/report-path-resolver.ts +4 -2
- package/src/shared/safe-emit.ts +24 -0
- package/src/shared/token-utils.ts +5 -0
- package/src/shared/type-guards.ts +8 -0
- package/src/shared/validation-constants.ts +52 -0
- package/src/skills/analysis/cluster.ts +1 -114
- package/src/skills/analysis/normalize.ts +2 -114
- package/src/skills/analysis/stopwords.ts +109 -0
- package/src/skills/argus-skill-resolver.ts +6 -3
- package/src/solodit-lifecycle.ts +153 -37
- package/src/state/adapters.ts +60 -66
- package/src/state/finding-aggregation.ts +6 -8
- package/src/state/finding-fingerprint.ts +1 -1
- package/src/state/finding-store.ts +31 -9
- package/src/state/index.ts +1 -1
- package/src/state/projectors.ts +27 -19
- package/src/state/schemas.ts +8 -32
- package/src/state/types.ts +3 -0
- package/src/tools/contract-analyzer-tool.ts +4 -6
- package/src/tools/forge-coverage-tool.ts +10 -35
- package/src/tools/forge-fuzz-tool.ts +21 -51
- package/src/tools/forge-test-tool.ts +25 -47
- package/src/tools/gas-analysis-tool.ts +12 -41
- package/src/tools/pattern-checker-tool.ts +37 -15
- package/src/tools/pattern-loader.ts +18 -4
- package/src/tools/persist-deduped-tool.ts +94 -0
- package/src/tools/proxy-detection-tool.ts +35 -34
- package/src/tools/read-findings-tool.ts +390 -0
- package/src/tools/record-finding-tool.ts +120 -25
- package/src/tools/report-generator-tool.ts +396 -328
- package/src/tools/report-preflight.ts +5 -1
- package/src/tools/slither-tool.ts +55 -16
- package/src/tools/solodit-search-tool.ts +260 -112
- package/src/tools/sync-knowledge-tool.ts +2 -3
- package/src/utils/solidity-parser.ts +39 -24
- package/src/features/migration/index.ts +0 -14
- package/src/features/migration/migration-adapter.ts +0 -151
- 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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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 =
|
|
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)
|
|
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 (
|
|
315
|
-
|
|
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)
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Shared helpers
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Primary path: MCP HTTP
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
async function callSoloditMcpHttp(
|
|
189
191
|
query: string,
|
|
190
192
|
limit: number,
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
380
|
+
if (!dataStr) {
|
|
282
381
|
return {
|
|
283
|
-
results:
|
|
284
|
-
totalFound:
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
|
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,
|
|
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 =
|
|
84
|
+
const indexPath = getScvdIndexPath()
|
|
86
85
|
|
|
87
86
|
const client = dependencies.createClient(apiUrl, context.abort)
|
|
88
87
|
const result = args.force
|