solidity-argus 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +161 -1
- package/package.json +5 -2
- package/skills/README.md +63 -0
- package/skills/checklists/cyfrin-defi-core/SKILL.md +3 -0
- package/skills/manifests/cyfrin.json +16 -0
- package/skills/manifests/defifofum.json +25 -0
- package/skills/manifests/kadenzipfel.json +48 -0
- package/skills/manifests/scvd.json +9 -0
- package/skills/manifests/smartbugs.json +11 -0
- package/skills/manifests/solodit.json +9 -0
- package/skills/manifests/sunweb3sec.json +11 -0
- package/skills/manifests/trailofbits.json +9 -0
- package/skills/methodology/audit-workflow/SKILL.md +3 -0
- package/skills/patterns/access-control.yaml +31 -0
- package/skills/patterns/erc4626.yaml +29 -0
- package/skills/patterns/flash-loan.yaml +20 -0
- package/skills/patterns/oracle.yaml +30 -0
- package/skills/patterns/proxy.yaml +30 -0
- package/skills/patterns/reentrancy.yaml +30 -0
- package/skills/patterns/signature.yaml +31 -0
- package/skills/protocol-patterns/amm-dex/SKILL.md +3 -0
- package/skills/references/exploit-reference/SKILL.md +3 -0
- package/skills/vulnerability-patterns/access-control/SKILL.md +13 -0
- package/skills/vulnerability-patterns/authorization-txorigin/SKILL.md +6 -0
- package/skills/vulnerability-patterns/delegatecall-untrusted-callee/SKILL.md +6 -0
- package/skills/vulnerability-patterns/dos-revert/SKILL.md +13 -1
- package/skills/vulnerability-patterns/flash-loan-attacks/SKILL.md +12 -0
- package/skills/vulnerability-patterns/oracle-manipulation/SKILL.md +13 -0
- package/skills/vulnerability-patterns/overflow-underflow/SKILL.md +10 -1
- package/skills/vulnerability-patterns/reentrancy/SKILL.md +13 -0
- package/skills/vulnerability-patterns/signature-malleability/SKILL.md +9 -0
- package/skills/vulnerability-patterns/unchecked-return-values/SKILL.md +11 -0
- package/src/agents/argus-prompt.ts +4 -4
- package/src/agents/pythia-prompt.ts +4 -4
- package/src/agents/scribe-prompt.ts +3 -3
- package/src/agents/sentinel-prompt.ts +4 -4
- package/src/cli/cli-output.ts +16 -0
- package/src/cli/cli-program.ts +9 -5
- package/src/cli/commands/doctor.ts +274 -16
- package/src/cli/commands/init.ts +5 -5
- package/src/cli/commands/install.ts +5 -5
- package/src/cli/commands/lint-skills.ts +114 -0
- package/src/cli/tui-prompts.ts +4 -2
- package/src/config/schema.ts +2 -0
- package/src/create-hooks.ts +99 -14
- package/src/create-tools.ts +2 -0
- package/src/features/error-recovery/tool-error-recovery.ts +74 -19
- package/src/features/persistent-state/audit-state-manager.ts +36 -13
- package/src/hooks/agent-tracker.ts +53 -0
- package/src/hooks/compaction-hook.ts +46 -37
- package/src/hooks/config-handler.ts +3 -0
- package/src/hooks/context-budget.ts +45 -0
- package/src/hooks/event-hook.ts +5 -4
- package/src/hooks/knowledge-sync-hook.ts +2 -1
- package/src/hooks/recon-context-builder.ts +66 -0
- package/src/hooks/safe-create-hook.ts +4 -5
- package/src/hooks/system-prompt-hook.ts +128 -0
- package/src/hooks/tool-tracking-hook.ts +86 -7
- package/src/index.ts +24 -1
- package/src/knowledge/retry.ts +53 -0
- package/src/knowledge/scvd-client.ts +37 -10
- package/src/knowledge/scvd-errors.ts +89 -0
- package/src/knowledge/scvd-index.ts +53 -3
- package/src/knowledge/scvd-sync.ts +205 -34
- package/src/knowledge/source-manifest.ts +102 -0
- package/src/plugin-interface.ts +14 -1
- package/src/shared/binary-utils.ts +1 -0
- package/src/shared/logger.ts +78 -17
- package/src/skills/argus-skill-resolver.ts +226 -0
- package/src/skills/skill-schema.ts +98 -0
- package/src/state/audit-state.ts +2 -0
- package/src/state/types.ts +32 -1
- package/src/tools/argus-skill-load-tool.ts +73 -0
- package/src/tools/pattern-checker-tool.ts +56 -12
- package/src/tools/pattern-loader.ts +183 -0
- package/src/tools/pattern-schema.ts +51 -0
- package/src/tools/report-generator-tool.ts +134 -11
- package/src/tools/slither-tool.ts +61 -19
- package/src/tools/solodit-search-tool.ts +92 -14
- package/src/utils/audit-artifact-detector.ts +119 -0
- package/src/utils/dependency-scanner.ts +93 -0
- package/src/utils/project-detector.ts +128 -26
- package/src/utils/solidity-parser.ts +20 -4
- package/src/utils/solodit-health.ts +29 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ProjectConfig } from "../utils/project-detector"
|
|
2
|
+
import type { DependencyRisk } from "../utils/dependency-scanner"
|
|
3
|
+
import type { AuditArtifact } from "../utils/audit-artifact-detector"
|
|
4
|
+
|
|
5
|
+
export interface ReconContext {
|
|
6
|
+
projectConfig: ProjectConfig | null
|
|
7
|
+
dependencyRisks: DependencyRisk[]
|
|
8
|
+
auditArtifacts: AuditArtifact[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Builds an XML-like reconnaissance context block from project data.
|
|
13
|
+
* Returns null if no data is available (all fields empty/null).
|
|
14
|
+
*
|
|
15
|
+
* The block is injected into compaction output so Argus agents retain
|
|
16
|
+
* project intelligence across context window compressions.
|
|
17
|
+
*/
|
|
18
|
+
export function buildReconContextBlock(recon: ReconContext): string | null {
|
|
19
|
+
if (
|
|
20
|
+
!recon.projectConfig &&
|
|
21
|
+
recon.dependencyRisks.length === 0 &&
|
|
22
|
+
recon.auditArtifacts.length === 0
|
|
23
|
+
) {
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const lines: string[] = ["<argus-recon>"]
|
|
28
|
+
|
|
29
|
+
if (recon.projectConfig) {
|
|
30
|
+
const frameworks: string[] = []
|
|
31
|
+
if (recon.projectConfig.hasFoundry) frameworks.push("Foundry")
|
|
32
|
+
if (recon.projectConfig.hasHardhat) frameworks.push("Hardhat")
|
|
33
|
+
if (frameworks.length > 0) {
|
|
34
|
+
lines.push(`Framework: ${frameworks.join(", ")}`)
|
|
35
|
+
}
|
|
36
|
+
if (recon.projectConfig.optimizer) {
|
|
37
|
+
lines.push(`Optimizer: runs=${recon.projectConfig.optimizer.runs}`)
|
|
38
|
+
}
|
|
39
|
+
if (recon.projectConfig.evmVersion) {
|
|
40
|
+
lines.push(`EVM Version: ${recon.projectConfig.evmVersion}`)
|
|
41
|
+
}
|
|
42
|
+
if (recon.projectConfig.isUpgradeable) {
|
|
43
|
+
lines.push(`Upgradeable: yes`)
|
|
44
|
+
}
|
|
45
|
+
if (recon.projectConfig.profiles && recon.projectConfig.profiles.length > 0) {
|
|
46
|
+
lines.push(`Profiles: ${recon.projectConfig.profiles.join(", ")}`)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (recon.dependencyRisks.length > 0) {
|
|
51
|
+
lines.push("Dependency Risks:")
|
|
52
|
+
for (const risk of recon.dependencyRisks.slice(0, 5)) {
|
|
53
|
+
lines.push(` - ${risk.package}@${risk.version}: ${risk.risk}`)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (recon.auditArtifacts.length > 0) {
|
|
58
|
+
lines.push("Existing Audit Artifacts:")
|
|
59
|
+
for (const artifact of recon.auditArtifacts.slice(0, 5)) {
|
|
60
|
+
lines.push(` - ${artifact.type}: ${artifact.path}`)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
lines.push("</argus-recon>")
|
|
65
|
+
return lines.join("\n")
|
|
66
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createLogger } from "../shared/logger"
|
|
2
|
+
|
|
1
3
|
export function safeCreateHook<T>(
|
|
2
4
|
factory: () => T,
|
|
3
5
|
hookName: string
|
|
@@ -5,11 +7,8 @@ export function safeCreateHook<T>(
|
|
|
5
7
|
try {
|
|
6
8
|
return factory();
|
|
7
9
|
} catch (error) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
error instanceof Error ? error.message : String(error)
|
|
11
|
-
}`
|
|
12
|
-
);
|
|
10
|
+
const logger = createLogger()
|
|
11
|
+
logger.error(`Failed to create hook "${hookName}": ${error instanceof Error ? error.message : String(error)}`)
|
|
13
12
|
return undefined;
|
|
14
13
|
}
|
|
15
14
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { AuditState, FindingSeverity } from "../state/types"
|
|
2
|
+
|
|
3
|
+
const DEFAULT_TOKEN_BUDGET = 2000
|
|
4
|
+
const TOKENS_PER_CHAR = 4
|
|
5
|
+
|
|
6
|
+
export interface SystemPromptHookDeps {
|
|
7
|
+
getAuditState: () => AuditState | null
|
|
8
|
+
getAgentForSession: (sessionID: string) => string | undefined
|
|
9
|
+
isArgusAgent: (sessionID: string) => boolean
|
|
10
|
+
getContextPressure?: (systemText: string) => number
|
|
11
|
+
getTokenBudget?: (agent: string, contextPressure: number) => number
|
|
12
|
+
getEnforcerReminder?: (state: AuditState) => string | null
|
|
13
|
+
getReconBlock?: () => string | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const FALLBACK_DIRECTIVES: Record<string, string> = {
|
|
17
|
+
slither:
|
|
18
|
+
"DO NOT re-attempt argus_slither_analyze. Use `argus_analyze_contract` and `argus_check_patterns` instead. Note limitation in report.",
|
|
19
|
+
forge:
|
|
20
|
+
"DO NOT re-attempt argus_forge_test or argus_forge_fuzz. Verify findings via manual code tracing. Note limitation in report.",
|
|
21
|
+
solodit:
|
|
22
|
+
"DO NOT re-attempt argus_solodit_search. Use `argus_check_patterns` with local rules. Note limitation in report.",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function buildFallbackDirectives(unavailableTools: string[]): string[] {
|
|
26
|
+
const directives: string[] = []
|
|
27
|
+
for (const tool of unavailableTools) {
|
|
28
|
+
const directive = FALLBACK_DIRECTIVES[tool]
|
|
29
|
+
if (directive) directives.push(directive)
|
|
30
|
+
}
|
|
31
|
+
return directives
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function estimateTokens(text: string): number {
|
|
35
|
+
return Math.ceil(text.length / TOKENS_PER_CHAR)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildDynamicContext(
|
|
39
|
+
auditState: AuditState,
|
|
40
|
+
agent: string,
|
|
41
|
+
tokenBudget: number = DEFAULT_TOKEN_BUDGET,
|
|
42
|
+
): string {
|
|
43
|
+
const severityCounts: Record<FindingSeverity, number> = {
|
|
44
|
+
Critical: 0,
|
|
45
|
+
High: 0,
|
|
46
|
+
Medium: 0,
|
|
47
|
+
Low: 0,
|
|
48
|
+
Informational: 0,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const finding of auditState.findings) {
|
|
52
|
+
severityCounts[finding.severity]++
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const tools = auditState.toolsExecuted.map((tool) => tool.tool).join(", ") || "none"
|
|
56
|
+
const unavailable = auditState.unavailableTools ?? []
|
|
57
|
+
const lines: string[] = [
|
|
58
|
+
`<argus-context agent="${agent}">`,
|
|
59
|
+
`Phase: ${auditState.currentPhase}`,
|
|
60
|
+
`Contracts: ${auditState.contractsReviewed.length} reviewed`,
|
|
61
|
+
`Findings: Critical=${severityCounts.Critical} High=${severityCounts.High} Medium=${severityCounts.Medium} Low=${severityCounts.Low} Info=${severityCounts.Informational}`,
|
|
62
|
+
`Tools: ${tools}`,
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
if (unavailable.length > 0) {
|
|
66
|
+
lines.push(`Unavailable: ${unavailable.join(", ")}`)
|
|
67
|
+
lines.push(...buildFallbackDirectives(unavailable))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
lines.push("</argus-context>")
|
|
71
|
+
|
|
72
|
+
let summary = lines.join("\n")
|
|
73
|
+
|
|
74
|
+
if (estimateTokens(summary) > tokenBudget) {
|
|
75
|
+
summary = [
|
|
76
|
+
`<argus-context agent="${agent}">`,
|
|
77
|
+
`Phase: ${auditState.currentPhase} | Findings: ${auditState.findings.length} | Contracts: ${auditState.contractsReviewed.length}`,
|
|
78
|
+
"</argus-context>",
|
|
79
|
+
].join("\n")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return summary
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createSystemPromptHook(deps: SystemPromptHookDeps) {
|
|
86
|
+
return async (
|
|
87
|
+
input: { sessionID?: string; model: unknown },
|
|
88
|
+
output: { system: string[] },
|
|
89
|
+
): Promise<void> => {
|
|
90
|
+
if (!input.sessionID) {
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!deps.isArgusAgent(input.sessionID)) {
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const auditState = deps.getAuditState()
|
|
99
|
+
if (!auditState) {
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const agent = deps.getAgentForSession(input.sessionID)
|
|
104
|
+
if (!agent) {
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const currentSystem = output.system.join("\n")
|
|
109
|
+
const pressure = deps.getContextPressure?.(currentSystem) ?? 0
|
|
110
|
+
const budget = deps.getTokenBudget?.(agent, pressure) ?? DEFAULT_TOKEN_BUDGET
|
|
111
|
+
|
|
112
|
+
output.system.push(buildDynamicContext(auditState, agent, budget))
|
|
113
|
+
|
|
114
|
+
if (deps.getReconBlock) {
|
|
115
|
+
const reconBlock = deps.getReconBlock()
|
|
116
|
+
if (reconBlock && estimateTokens(reconBlock) <= budget) {
|
|
117
|
+
output.system.push(reconBlock)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (agent === "argus" && deps.getEnforcerReminder) {
|
|
122
|
+
const reminder = deps.getEnforcerReminder(auditState)
|
|
123
|
+
if (reminder) {
|
|
124
|
+
output.system.push(reminder)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AuditState, FindingSeverity } from "../state/types"
|
|
1
|
+
import type { AuditState, FindingSeverity, FuzzCounterexample, SoloditResult } from "../state/types"
|
|
2
2
|
import type { FindingStore } from "../state/finding-store"
|
|
3
3
|
import { createFindingStore } from "../state/finding-store"
|
|
4
4
|
|
|
@@ -166,16 +166,91 @@ function processContractAnalyzerResult(
|
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
function processFuzzResult(
|
|
170
|
+
parsed: Record<string, unknown>,
|
|
171
|
+
state: AuditState
|
|
172
|
+
): void {
|
|
173
|
+
const counterexamples = parsed.counterexamples
|
|
174
|
+
if (!Array.isArray(counterexamples) || counterexamples.length === 0) return
|
|
175
|
+
|
|
176
|
+
const totalRuns =
|
|
177
|
+
typeof parsed.totalRuns === "number" ? parsed.totalRuns : 0
|
|
178
|
+
|
|
179
|
+
state.fuzzCounterexamples ??= []
|
|
180
|
+
|
|
181
|
+
for (const raw of counterexamples) {
|
|
182
|
+
const ce = toRecord(raw)
|
|
183
|
+
if (!ce) continue
|
|
184
|
+
|
|
185
|
+
const testName = ce.testName
|
|
186
|
+
if (typeof testName !== "string") continue
|
|
187
|
+
|
|
188
|
+
const rawInputs = toRecord(ce.inputs)
|
|
189
|
+
const inputs = rawInputs ? Object.values(rawInputs).map(String) : []
|
|
190
|
+
|
|
191
|
+
const entry: FuzzCounterexample = {
|
|
192
|
+
testName,
|
|
193
|
+
inputs,
|
|
194
|
+
runs: totalRuns,
|
|
195
|
+
timestamp: Date.now(),
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (typeof ce.revertReason === "string") {
|
|
199
|
+
entry.revertReason = ce.revertReason
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
state.fuzzCounterexamples.push(entry)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function processSoloditResult(
|
|
207
|
+
parsed: Record<string, unknown>,
|
|
208
|
+
state: AuditState
|
|
209
|
+
): void {
|
|
210
|
+
const query = typeof parsed.query === "string" ? parsed.query : ""
|
|
211
|
+
const results = Array.isArray(parsed.results) ? parsed.results : []
|
|
212
|
+
const totalFound =
|
|
213
|
+
typeof parsed.totalFound === "number" ? parsed.totalFound : results.length
|
|
214
|
+
|
|
215
|
+
const topResults: SoloditResult["topResults"] = results
|
|
216
|
+
.slice(0, 5)
|
|
217
|
+
.map((raw) => {
|
|
218
|
+
const r = toRecord(raw)
|
|
219
|
+
return {
|
|
220
|
+
title: typeof r?.title === "string" ? r.title : "",
|
|
221
|
+
severity: typeof r?.severity === "string" ? r.severity : "",
|
|
222
|
+
url: typeof r?.url === "string" ? r.url : "",
|
|
223
|
+
protocol: typeof r?.protocol === "string" ? r.protocol : "",
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
state.soloditResults ??= []
|
|
228
|
+
state.soloditResults.push({
|
|
229
|
+
query,
|
|
230
|
+
timestamp: Date.now(),
|
|
231
|
+
resultCount: totalFound,
|
|
232
|
+
topResults,
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Records a tool execution in the audit state.
|
|
238
|
+
*
|
|
239
|
+
* Multiple entries per tool name are allowed — if the same tool runs multiple times
|
|
240
|
+
* (e.g., argus_slither_analyze on different targets), each execution is recorded
|
|
241
|
+
* with its own findingsCount.
|
|
242
|
+
*
|
|
243
|
+
* Timing limitation: startTime and endTime are both set to Date.now() because this
|
|
244
|
+
* hook fires in the tool.execute.after phase, after execution has already completed.
|
|
245
|
+
* We cannot capture the actual start time. This is a known limitation of the hook
|
|
246
|
+
* architecture. For accurate timing, the hook would need to fire in tool.execute.before
|
|
247
|
+
* and tool.execute.after phases separately.
|
|
248
|
+
*/
|
|
169
249
|
function recordToolExecution(
|
|
170
250
|
state: AuditState,
|
|
171
251
|
toolName: string,
|
|
172
252
|
findingsCount: number
|
|
173
253
|
): void {
|
|
174
|
-
const alreadyRecorded = state.toolsExecuted.some(
|
|
175
|
-
(execution) => execution.tool === toolName
|
|
176
|
-
)
|
|
177
|
-
if (alreadyRecorded) return
|
|
178
|
-
|
|
179
254
|
const now = Date.now()
|
|
180
255
|
state.toolsExecuted.push({
|
|
181
256
|
tool: toolName,
|
|
@@ -243,9 +318,13 @@ export function createToolTrackingHook(
|
|
|
243
318
|
case "argus_analyze_contract":
|
|
244
319
|
processContractAnalyzerResult(record, auditState)
|
|
245
320
|
break
|
|
321
|
+
case "argus_solodit_search":
|
|
322
|
+
processSoloditResult(record, auditState)
|
|
323
|
+
break
|
|
246
324
|
case "argus_forge_test":
|
|
325
|
+
break
|
|
247
326
|
case "argus_forge_fuzz":
|
|
248
|
-
|
|
327
|
+
processFuzzResult(record, auditState)
|
|
249
328
|
break
|
|
250
329
|
}
|
|
251
330
|
|
package/src/index.ts
CHANGED
|
@@ -5,8 +5,19 @@ import { createTools } from "./create-tools"
|
|
|
5
5
|
import { createHooks } from "./create-hooks"
|
|
6
6
|
import { createManagers } from "./create-managers"
|
|
7
7
|
import { createPluginInterface } from "./plugin-interface"
|
|
8
|
+
import { checkSoloditHealth } from "./utils/solodit-health"
|
|
9
|
+
import { createLogger } from "./shared/logger"
|
|
10
|
+
|
|
11
|
+
async function startSoloditMcp(port: number): Promise<void> {
|
|
12
|
+
const logger = createLogger()
|
|
13
|
+
|
|
14
|
+
// Health check before spawn: if already reachable, skip spawn
|
|
15
|
+
const health = await checkSoloditHealth(port, true)
|
|
16
|
+
if (health.reachable) {
|
|
17
|
+
logger.debug(`Solodit MCP already running on port ${port} — skipping spawn`)
|
|
18
|
+
return
|
|
19
|
+
}
|
|
8
20
|
|
|
9
|
-
function startSoloditMcp(port: number): void {
|
|
10
21
|
const child = Bun.spawn(["npx", "-y", "@lyuboslavlyubenov/solodit-mcp"], {
|
|
11
22
|
stdin: "ignore",
|
|
12
23
|
stdout: "ignore",
|
|
@@ -14,6 +25,16 @@ function startSoloditMcp(port: number): void {
|
|
|
14
25
|
env: { ...process.env, PORT: String(port) },
|
|
15
26
|
})
|
|
16
27
|
child.unref()
|
|
28
|
+
|
|
29
|
+
// Health check after spawn: wait 2s, then ping
|
|
30
|
+
setTimeout(async () => {
|
|
31
|
+
const health = await checkSoloditHealth(port, true)
|
|
32
|
+
if (!health.reachable) {
|
|
33
|
+
logger.debug(`Solodit MCP not yet reachable on port ${port} — will retry on first use`)
|
|
34
|
+
} else {
|
|
35
|
+
logger.debug(`Solodit MCP healthy on port ${port}`)
|
|
36
|
+
}
|
|
37
|
+
}, 2000)
|
|
17
38
|
}
|
|
18
39
|
|
|
19
40
|
const ArgusPlugin: Plugin = async (ctx) => {
|
|
@@ -21,6 +42,8 @@ const ArgusPlugin: Plugin = async (ctx) => {
|
|
|
21
42
|
const config = loadArgusConfig(projectDir)
|
|
22
43
|
|
|
23
44
|
if (config.solodit?.enabled !== false) {
|
|
45
|
+
// Fire-and-forget: startSoloditMcp is now async but we don't await
|
|
46
|
+
// to avoid blocking plugin initialization
|
|
24
47
|
startSoloditMcp(config.solodit?.port ?? 3000)
|
|
25
48
|
}
|
|
26
49
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export interface RetryOptions<T> {
|
|
2
|
+
maxAttempts: number;
|
|
3
|
+
baseDelayMs: number;
|
|
4
|
+
shouldRetry: (error: unknown) => boolean;
|
|
5
|
+
onRetry?: (attempt: number, error: unknown) => void;
|
|
6
|
+
_valueType?: T;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface RetryResult<T> {
|
|
10
|
+
success: boolean;
|
|
11
|
+
value?: T;
|
|
12
|
+
error?: unknown;
|
|
13
|
+
attempts: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function sleep(delayMs: number): Promise<void> {
|
|
17
|
+
return new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function withRetry<T>(
|
|
21
|
+
fn: () => Promise<T>,
|
|
22
|
+
options: RetryOptions<T>
|
|
23
|
+
): Promise<RetryResult<T>> {
|
|
24
|
+
const maxAttempts = options.maxAttempts > 0 ? options.maxAttempts : 1;
|
|
25
|
+
let lastError: unknown;
|
|
26
|
+
|
|
27
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
28
|
+
try {
|
|
29
|
+
const value = await fn();
|
|
30
|
+
return { success: true, value, attempts: attempt };
|
|
31
|
+
} catch (error) {
|
|
32
|
+
lastError = error;
|
|
33
|
+
const canRetry = attempt < maxAttempts && options.shouldRetry(error);
|
|
34
|
+
|
|
35
|
+
if (!canRetry) {
|
|
36
|
+
return { success: false, error, attempts: attempt };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (options.onRetry) {
|
|
40
|
+
options.onRetry(attempt, error);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const delay = options.baseDelayMs * 2 ** (attempt - 1);
|
|
44
|
+
await sleep(delay);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
success: false,
|
|
50
|
+
error: lastError,
|
|
51
|
+
attempts: maxAttempts,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -150,6 +150,24 @@ function parseStats(raw: unknown): ScvdStats {
|
|
|
150
150
|
};
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
export class ScvdNetworkError extends Error {
|
|
154
|
+
override readonly name = "ScvdNetworkError" as const;
|
|
155
|
+
|
|
156
|
+
constructor(message: string) {
|
|
157
|
+
super(message);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export class ScvdApiError extends Error {
|
|
162
|
+
override readonly name = "ScvdApiError" as const;
|
|
163
|
+
readonly httpStatus: number;
|
|
164
|
+
|
|
165
|
+
constructor(httpStatus: number, message?: string) {
|
|
166
|
+
super(message ?? `SCVD API error: HTTP ${httpStatus}`);
|
|
167
|
+
this.httpStatus = httpStatus;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
153
171
|
export class ScvdClient {
|
|
154
172
|
private readonly baseUrl: string;
|
|
155
173
|
private readonly signal?: AbortSignal;
|
|
@@ -167,11 +185,14 @@ export class ScvdClient {
|
|
|
167
185
|
response = await fetch(url, { signal: this.signal });
|
|
168
186
|
} catch (error) {
|
|
169
187
|
const message = error instanceof Error ? error.message : "unknown network error";
|
|
170
|
-
throw new
|
|
188
|
+
throw new ScvdNetworkError(`Failed to fetch SCVD stats from ${url}: ${message}`);
|
|
171
189
|
}
|
|
172
190
|
|
|
173
191
|
if (!response.ok) {
|
|
174
|
-
throw new
|
|
192
|
+
throw new ScvdApiError(
|
|
193
|
+
response.status,
|
|
194
|
+
`Failed to fetch SCVD stats from ${url}: HTTP ${response.status}`
|
|
195
|
+
);
|
|
175
196
|
}
|
|
176
197
|
|
|
177
198
|
const body = (await response.json()) as unknown;
|
|
@@ -198,17 +219,23 @@ export class ScvdClient {
|
|
|
198
219
|
const query = searchParams.toString();
|
|
199
220
|
const url = `${this.baseUrl}/findings${query.length > 0 ? `?${query}` : ""}`;
|
|
200
221
|
|
|
222
|
+
let response: Response;
|
|
201
223
|
try {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
224
|
+
response = await fetch(url, { signal: this.signal });
|
|
225
|
+
} catch (error) {
|
|
226
|
+
const message = error instanceof Error ? error.message : "unknown network error";
|
|
227
|
+
throw new ScvdNetworkError(`Failed to fetch SCVD findings from ${url}: ${message}`);
|
|
228
|
+
}
|
|
206
229
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
230
|
+
if (!response.ok) {
|
|
231
|
+
throw new ScvdApiError(
|
|
232
|
+
response.status,
|
|
233
|
+
`SCVD API error: HTTP ${response.status} for ${url}`
|
|
234
|
+
);
|
|
211
235
|
}
|
|
236
|
+
|
|
237
|
+
const body = (await response.json()) as unknown;
|
|
238
|
+
return parseFindings(body);
|
|
212
239
|
}
|
|
213
240
|
|
|
214
241
|
async fetchAllFindings(onProgress?: (count: number) => void): Promise<ScvdFinding[]> {
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export type SyncError = {
|
|
2
|
+
status: "error";
|
|
3
|
+
success: false;
|
|
4
|
+
reason: "network" | "api" | "parse";
|
|
5
|
+
message: string;
|
|
6
|
+
error: string;
|
|
7
|
+
httpStatus?: number;
|
|
8
|
+
newFindings: 0;
|
|
9
|
+
totalIndexed: 0;
|
|
10
|
+
lastSync: string;
|
|
11
|
+
attempts?: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type SyncSuccess = {
|
|
15
|
+
status: "success";
|
|
16
|
+
success: true;
|
|
17
|
+
newFindings: number;
|
|
18
|
+
totalIndexed: number;
|
|
19
|
+
lastSync: string;
|
|
20
|
+
error?: undefined;
|
|
21
|
+
attempts?: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type SyncStale = {
|
|
25
|
+
status: "stale";
|
|
26
|
+
success: false;
|
|
27
|
+
newFindings: 0;
|
|
28
|
+
totalIndexed: 0;
|
|
29
|
+
lastSync: string;
|
|
30
|
+
error?: undefined;
|
|
31
|
+
daysSinceSync: number;
|
|
32
|
+
attempts?: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type SyncOutcome = SyncSuccess | SyncError | SyncStale;
|
|
36
|
+
|
|
37
|
+
export function createNetworkError(message: string): SyncError {
|
|
38
|
+
return {
|
|
39
|
+
status: "error",
|
|
40
|
+
success: false,
|
|
41
|
+
reason: "network",
|
|
42
|
+
message,
|
|
43
|
+
error: message,
|
|
44
|
+
newFindings: 0,
|
|
45
|
+
totalIndexed: 0,
|
|
46
|
+
lastSync: new Date().toISOString(),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createApiError(httpStatus: number, message: string): SyncError {
|
|
51
|
+
return {
|
|
52
|
+
status: "error",
|
|
53
|
+
success: false,
|
|
54
|
+
reason: "api",
|
|
55
|
+
message,
|
|
56
|
+
error: message,
|
|
57
|
+
httpStatus,
|
|
58
|
+
newFindings: 0,
|
|
59
|
+
totalIndexed: 0,
|
|
60
|
+
lastSync: new Date().toISOString(),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createParseError(message: string): SyncError {
|
|
65
|
+
return {
|
|
66
|
+
status: "error",
|
|
67
|
+
success: false,
|
|
68
|
+
reason: "parse",
|
|
69
|
+
message,
|
|
70
|
+
error: message,
|
|
71
|
+
newFindings: 0,
|
|
72
|
+
totalIndexed: 0,
|
|
73
|
+
lastSync: new Date().toISOString(),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function createSyncSuccess(
|
|
78
|
+
data: Omit<SyncSuccess, "status" | "success" | "error"> & { attempts?: number }
|
|
79
|
+
): SyncSuccess {
|
|
80
|
+
return {
|
|
81
|
+
status: "success",
|
|
82
|
+
success: true,
|
|
83
|
+
...data,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function isRetryableError(outcome: SyncOutcome): boolean {
|
|
88
|
+
return outcome.status === "error" && outcome.reason === "network";
|
|
89
|
+
}
|
|
@@ -10,15 +10,42 @@ export interface ScvdIndexEntry {
|
|
|
10
10
|
repoUrl: string;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
export interface ScvdIndexMetadata {
|
|
14
|
+
lastSuccess: string | null;
|
|
15
|
+
lastAttempt: string | null;
|
|
16
|
+
errorCount: number;
|
|
17
|
+
lastError: string | null;
|
|
18
|
+
lastErrorReason: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
13
21
|
export interface ScvdIndex {
|
|
14
22
|
version: number;
|
|
15
23
|
lastSync: string;
|
|
16
24
|
totalFindings: number;
|
|
17
25
|
entries: ScvdIndexEntry[];
|
|
26
|
+
metadata?: ScvdIndexMetadata;
|
|
18
27
|
}
|
|
19
28
|
|
|
20
29
|
const INDEX_VERSION = 1;
|
|
21
30
|
const DEFAULT_LIMIT = 10;
|
|
31
|
+
let syncInProgress = false;
|
|
32
|
+
|
|
33
|
+
export function acquireSyncLock(): boolean {
|
|
34
|
+
if (syncInProgress) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
syncInProgress = true;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function releaseSyncLock(): void {
|
|
43
|
+
syncInProgress = false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isSyncLocked(): boolean {
|
|
47
|
+
return syncInProgress;
|
|
48
|
+
}
|
|
22
49
|
|
|
23
50
|
function normalizeKeywordInput(value: string): string[] {
|
|
24
51
|
return value
|
|
@@ -96,8 +123,10 @@ export function searchIndex(
|
|
|
96
123
|
}
|
|
97
124
|
|
|
98
125
|
export async function saveIndex(index: ScvdIndex, filePath: string): Promise<void> {
|
|
99
|
-
const
|
|
100
|
-
await Bun.write(
|
|
126
|
+
const tmpPath = `${filePath}.tmp.${Date.now()}`;
|
|
127
|
+
await Bun.write(tmpPath, JSON.stringify(index, null, 2));
|
|
128
|
+
const { renameSync } = await import("node:fs");
|
|
129
|
+
renameSync(tmpPath, filePath);
|
|
101
130
|
}
|
|
102
131
|
|
|
103
132
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -142,6 +171,20 @@ function parseEntry(value: unknown): ScvdIndexEntry | null {
|
|
|
142
171
|
};
|
|
143
172
|
}
|
|
144
173
|
|
|
174
|
+
function parseNullableString(value: unknown): string | null {
|
|
175
|
+
return typeof value === "string" ? value : null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function parseMetadata(raw: Record<string, unknown>): ScvdIndexMetadata {
|
|
179
|
+
return {
|
|
180
|
+
lastSuccess: parseNullableString(raw.lastSuccess),
|
|
181
|
+
lastAttempt: parseNullableString(raw.lastAttempt),
|
|
182
|
+
errorCount: typeof raw.errorCount === "number" ? raw.errorCount : 0,
|
|
183
|
+
lastError: parseNullableString(raw.lastError),
|
|
184
|
+
lastErrorReason: parseNullableString(raw.lastErrorReason),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
145
188
|
export async function loadIndex(filePath: string): Promise<ScvdIndex | null> {
|
|
146
189
|
const file = Bun.file(filePath);
|
|
147
190
|
const exists = await file.exists();
|
|
@@ -174,10 +217,17 @@ export async function loadIndex(filePath: string): Promise<ScvdIndex | null> {
|
|
|
174
217
|
.map(parseEntry)
|
|
175
218
|
.filter((entry): entry is ScvdIndexEntry => entry !== null);
|
|
176
219
|
|
|
177
|
-
|
|
220
|
+
const index: ScvdIndex = {
|
|
178
221
|
version,
|
|
179
222
|
lastSync,
|
|
180
223
|
totalFindings,
|
|
181
224
|
entries,
|
|
182
225
|
};
|
|
226
|
+
|
|
227
|
+
const rawMetadata = raw.metadata;
|
|
228
|
+
if (isRecord(rawMetadata)) {
|
|
229
|
+
index.metadata = parseMetadata(rawMetadata);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return index;
|
|
183
233
|
}
|