solidity-argus 0.3.7 → 0.5.7

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 (108) 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 +24 -2
  22. package/src/agents/scribe-prompt.ts +34 -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/cli/commands/install.ts +74 -33
  27. package/src/config/loader.ts +29 -5
  28. package/src/config/schema.ts +45 -45
  29. package/src/constants/defaults.ts +1 -0
  30. package/src/create-hooks.ts +806 -173
  31. package/src/create-managers.ts +4 -2
  32. package/src/create-tools.ts +5 -1
  33. package/src/features/audit-enforcer/audit-enforcer.ts +1 -11
  34. package/src/features/background-agent/background-manager.ts +32 -5
  35. package/src/features/error-recovery/tool-error-recovery.ts +1 -0
  36. package/src/features/persistent-state/audit-state-manager.ts +272 -29
  37. package/src/features/persistent-state/event-sink.ts +96 -25
  38. package/src/features/persistent-state/findings-materializer.ts +68 -2
  39. package/src/features/persistent-state/global-run-index.ts +86 -8
  40. package/src/features/persistent-state/index.ts +7 -1
  41. package/src/features/persistent-state/run-finalizer.ts +116 -7
  42. package/src/features/persistent-state/run-pruner.ts +93 -0
  43. package/src/hooks/agent-tracker.ts +14 -2
  44. package/src/hooks/compaction-hook.ts +7 -16
  45. package/src/hooks/config-handler.ts +83 -29
  46. package/src/hooks/context-budget.ts +4 -5
  47. package/src/hooks/event-hook.ts +213 -57
  48. package/src/hooks/knowledge-sync-hook.ts +2 -3
  49. package/src/hooks/safe-create-hook.ts +13 -1
  50. package/src/hooks/system-prompt-hook.ts +20 -39
  51. package/src/hooks/tool-tracking-hook.ts +602 -323
  52. package/src/index.ts +15 -1
  53. package/src/knowledge/scvd-client.ts +2 -4
  54. package/src/knowledge/scvd-errors.ts +25 -2
  55. package/src/knowledge/scvd-index.ts +7 -5
  56. package/src/knowledge/scvd-sync.ts +6 -6
  57. package/src/managers/types.ts +20 -2
  58. package/src/shared/agent-names.ts +23 -0
  59. package/src/shared/audit-artifact-resolver.ts +8 -3
  60. package/src/shared/audit-phases.ts +12 -0
  61. package/src/shared/cache-paths.ts +41 -0
  62. package/src/shared/drop-diagnostics.ts +2 -2
  63. package/src/shared/forge-errors.ts +31 -0
  64. package/src/shared/forge-runner.ts +30 -0
  65. package/src/shared/format-error.ts +3 -0
  66. package/src/shared/index.ts +9 -0
  67. package/src/shared/key-tools.ts +39 -0
  68. package/src/shared/logger.ts +7 -7
  69. package/src/shared/path-containment.ts +25 -0
  70. package/src/shared/path-utils.ts +11 -0
  71. package/src/shared/report-path-resolver.ts +4 -2
  72. package/src/shared/safe-emit.ts +24 -0
  73. package/src/shared/token-utils.ts +5 -0
  74. package/src/shared/type-guards.ts +8 -0
  75. package/src/shared/validation-constants.ts +52 -0
  76. package/src/skills/analysis/cluster.ts +1 -114
  77. package/src/skills/analysis/normalize.ts +2 -114
  78. package/src/skills/analysis/stopwords.ts +109 -0
  79. package/src/skills/argus-skill-resolver.ts +6 -3
  80. package/src/solodit-lifecycle.ts +153 -37
  81. package/src/state/adapters.ts +60 -66
  82. package/src/state/finding-aggregation.ts +6 -8
  83. package/src/state/finding-fingerprint.ts +1 -1
  84. package/src/state/finding-store.ts +31 -9
  85. package/src/state/index.ts +1 -1
  86. package/src/state/projectors.ts +27 -19
  87. package/src/state/schemas.ts +8 -32
  88. package/src/state/types.ts +3 -0
  89. package/src/tools/contract-analyzer-tool.ts +4 -6
  90. package/src/tools/forge-coverage-tool.ts +10 -35
  91. package/src/tools/forge-fuzz-tool.ts +21 -51
  92. package/src/tools/forge-test-tool.ts +25 -47
  93. package/src/tools/gas-analysis-tool.ts +12 -41
  94. package/src/tools/pattern-checker-tool.ts +37 -15
  95. package/src/tools/pattern-loader.ts +18 -4
  96. package/src/tools/persist-deduped-tool.ts +94 -0
  97. package/src/tools/proxy-detection-tool.ts +35 -34
  98. package/src/tools/read-findings-tool.ts +390 -0
  99. package/src/tools/record-finding-tool.ts +130 -25
  100. package/src/tools/report-generator-tool.ts +475 -327
  101. package/src/tools/report-preflight.ts +5 -1
  102. package/src/tools/slither-tool.ts +55 -16
  103. package/src/tools/solodit-search-tool.ts +260 -112
  104. package/src/tools/sync-knowledge-tool.ts +2 -3
  105. package/src/utils/solidity-parser.ts +39 -24
  106. package/src/features/migration/index.ts +0 -14
  107. package/src/features/migration/migration-adapter.ts +0 -151
  108. package/src/features/migration/parity-telemetry.ts +0 -133
@@ -0,0 +1,390 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs"
2
+ import { mkdir, writeFile } from "node:fs/promises"
3
+ import { dirname, join } from "node:path"
4
+ import { type ToolContext, tool } from "@opencode-ai/plugin"
5
+ import { createAuditArtifactResolver } from "../shared/audit-artifact-resolver"
6
+ import { createLogger } from "../shared/logger"
7
+ import { defaultRootResolver } from "../shared/path-root-resolver"
8
+ import { resolveProjectDir } from "../shared/project-utils"
9
+ import type { CanonicalFinding, CanonicalToolExecution, ReportInput } from "../state/schemas"
10
+ import { SCHEMA_VERSION } from "../state/schemas"
11
+ import type { AuditState } from "../state/types"
12
+
13
+ type ReadFindingsArgs = {
14
+ run_id: string
15
+ }
16
+
17
+ type ReportFinding = Omit<
18
+ CanonicalFinding,
19
+ | "run_id"
20
+ | "seq"
21
+ | "schema_version"
22
+ | "observation_id"
23
+ | "issue_fingerprint"
24
+ | "observation_fingerprint"
25
+ | "reported_by_agent"
26
+ | "reported_by_session_id"
27
+ >
28
+
29
+ type ReportToolExecution = Omit<CanonicalToolExecution, "run_id" | "schema_version">
30
+
31
+ type CompactReportInput = Omit<
32
+ ReportInput,
33
+ | "findings"
34
+ | "toolsExecuted"
35
+ | "run_id"
36
+ | "seq"
37
+ | "session_id"
38
+ | "tool_call_id"
39
+ | "source"
40
+ | "schema_version"
41
+ > & {
42
+ run_id: string
43
+ findings: ReportFinding[]
44
+ toolsExecuted: ReportToolExecution[]
45
+ }
46
+
47
+ type ReadFindingsInlineResult = {
48
+ success: boolean
49
+ truncated: false
50
+ source: "report-input.json"
51
+ reportInput: CompactReportInput
52
+ }
53
+
54
+ type ReadFindingsFileResult = {
55
+ success: boolean
56
+ truncated: true
57
+ source: "report-input.json"
58
+ compactReportInputFile: string
59
+ summary: {
60
+ run_id: string
61
+ findingsCount: number
62
+ toolsExecutedCount: number
63
+ scope: string[]
64
+ severityDistribution: Record<string, number>
65
+ topFindings: Array<{ title: string; severity: string; category: string }>
66
+ }
67
+ instructions: string
68
+ }
69
+
70
+ export type ReadFindingsResult = ReadFindingsInlineResult | ReadFindingsFileResult
71
+
72
+ /**
73
+ * OpenCode truncates plugin tool output above ~50KB (exact threshold unknown,
74
+ * but observed at 122KB). We use 40KB as a safe ceiling to avoid truncation.
75
+ */
76
+ const OUTPUT_SIZE_THRESHOLD_BYTES = 40_000
77
+
78
+ const FINDING_INTERNAL_KEYS: ReadonlySet<string> = new Set([
79
+ "run_id",
80
+ "seq",
81
+ "schema_version",
82
+ "observation_id",
83
+ "issue_fingerprint",
84
+ "observation_fingerprint",
85
+ "reported_by_agent",
86
+ "reported_by_session_id",
87
+ ])
88
+
89
+ const TOOL_EXECUTION_INTERNAL_KEYS: ReadonlySet<string> = new Set(["run_id", "schema_version"])
90
+
91
+ function stripInternalKeys(obj: object, keysToStrip: ReadonlySet<string>): Record<string, unknown> {
92
+ const result: Record<string, unknown> = {}
93
+ for (const [key, value] of Object.entries(obj)) {
94
+ if (!keysToStrip.has(key)) {
95
+ result[key] = value
96
+ }
97
+ }
98
+ return result
99
+ }
100
+
101
+ function buildCompactInput(reportInput: ReportInput): CompactReportInput {
102
+ return {
103
+ run_id: reportInput.run_id,
104
+ projectDir: reportInput.projectDir,
105
+ findings: reportInput.findings.map(
106
+ (f) => stripInternalKeys(f, FINDING_INTERNAL_KEYS) as ReportFinding,
107
+ ),
108
+ toolsExecuted: reportInput.toolsExecuted.map(
109
+ (t) => stripInternalKeys(t, TOOL_EXECUTION_INTERNAL_KEYS) as ReportToolExecution,
110
+ ),
111
+ scope: reportInput.scope,
112
+ ...(reportInput.soloditResults && { soloditResults: reportInput.soloditResults }),
113
+ ...(reportInput.fuzzCounterexamples && {
114
+ fuzzCounterexamples: reportInput.fuzzCounterexamples,
115
+ }),
116
+ ...(reportInput.coverageReport && { coverageReport: reportInput.coverageReport }),
117
+ ...(reportInput.gasHotspots && { gasHotspots: reportInput.gasHotspots }),
118
+ ...(reportInput.proxyContracts && { proxyContracts: reportInput.proxyContracts }),
119
+ ...(reportInput.patternVersion && { patternVersion: reportInput.patternVersion }),
120
+ ...(reportInput.skillsLoaded && { skillsLoaded: reportInput.skillsLoaded }),
121
+ }
122
+ }
123
+
124
+ function buildSeverityDistribution(findings: ReportFinding[]): Record<string, number> {
125
+ const dist: Record<string, number> = {}
126
+ for (const f of findings) {
127
+ const sev = (f as Record<string, unknown>).severity as string | undefined
128
+ const key = sev ?? "Unknown"
129
+ dist[key] = (dist[key] ?? 0) + 1
130
+ }
131
+ return dist
132
+ }
133
+
134
+ function buildTopFindings(
135
+ findings: ReportFinding[],
136
+ limit = 10,
137
+ ): Array<{ title: string; severity: string; category: string }> {
138
+ const severityOrder: Record<string, number> = {
139
+ Critical: 0,
140
+ High: 1,
141
+ Medium: 2,
142
+ Low: 3,
143
+ Informational: 4,
144
+ }
145
+ const sorted = [...findings].sort((a, b) => {
146
+ const ra = a as Record<string, unknown>
147
+ const rb = b as Record<string, unknown>
148
+ const sa = severityOrder[(ra.severity as string) ?? ""] ?? 5
149
+ const sb = severityOrder[(rb.severity as string) ?? ""] ?? 5
150
+ return sa - sb
151
+ })
152
+ return sorted.slice(0, limit).map((f) => {
153
+ const r = f as Record<string, unknown>
154
+ return {
155
+ title: (r.title as string) ?? (r.description as string)?.slice(0, 80) ?? "Untitled",
156
+ severity: (r.severity as string) ?? "Unknown",
157
+ category: (r.category as string) ?? "Unknown",
158
+ }
159
+ })
160
+ }
161
+
162
+ function convertAuditStateToReportInput(
163
+ state: AuditState,
164
+ runId: string,
165
+ projectDir: string,
166
+ ): ReportInput {
167
+ const findings: CanonicalFinding[] = (state.findings ?? []).map((f, i) => ({
168
+ ...f,
169
+ run_id: state.sessionId ?? runId,
170
+ seq: i + 1,
171
+ session_id: "audit",
172
+ tool_call_id: "",
173
+ source: f.source ?? ("unknown" as const),
174
+ schema_version: SCHEMA_VERSION,
175
+ issue_fingerprint: f.id ?? "",
176
+ observation_fingerprint: f.id ?? "",
177
+ observation_id: f.id ?? "",
178
+ reported_by_agent: f.reported_by_agent ?? ("unknown" as const),
179
+ reported_by_session_id: f.reported_by_session_id ?? "",
180
+ }))
181
+
182
+ return {
183
+ run_id: state.sessionId ?? runId,
184
+ seq: findings.length,
185
+ session_id: "audit",
186
+ tool_call_id: "",
187
+ source: "audit-state",
188
+ schema_version: SCHEMA_VERSION,
189
+ projectDir: state.projectDir ?? projectDir,
190
+ findings,
191
+ toolsExecuted: (state.toolsExecuted ?? []).map((t) => ({
192
+ ...t,
193
+ run_id: state.sessionId ?? runId,
194
+ schema_version: SCHEMA_VERSION,
195
+ })),
196
+ scope: state.scope?.length
197
+ ? state.scope
198
+ : [...new Set(findings.map((f) => f.file).filter(Boolean))],
199
+ soloditResults: state.soloditResults,
200
+ fuzzCounterexamples: state.fuzzCounterexamples,
201
+ coverageReport: state.coverageReport,
202
+ gasHotspots: state.gasHotspots,
203
+ proxyContracts: state.proxyContracts,
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Scan .argus/sessions/ for the newest state file with findings.
209
+ * Mirrors the fallback logic in audit-state-manager.ts load().
210
+ */
211
+ function readNewestSessionState(argusRoot: string): AuditState | null {
212
+ const sessionsDir = join(argusRoot, "sessions")
213
+ try {
214
+ const entries = readdirSync(sessionsDir)
215
+ const stateFiles = entries.filter((e) => e.startsWith("state-") && e.endsWith(".json"))
216
+ if (stateFiles.length === 0) return null
217
+
218
+ const ranked = stateFiles
219
+ .map((name) => {
220
+ const filePath = join(sessionsDir, name)
221
+ try {
222
+ return { name, path: filePath, mtime: statSync(filePath).mtimeMs }
223
+ } catch {
224
+ return null
225
+ }
226
+ })
227
+ .filter((entry): entry is NonNullable<typeof entry> => entry !== null)
228
+ .sort((a, b) => b.mtime - a.mtime)
229
+
230
+ for (const entry of ranked) {
231
+ try {
232
+ const state = JSON.parse(readFileSync(entry.path, "utf8")) as AuditState
233
+ if (state.findings && state.findings.length > 0) {
234
+ return state
235
+ }
236
+ } catch {
237
+ /* skip unreadable files */
238
+ }
239
+ }
240
+ } catch {
241
+ /* sessions dir doesn't exist */
242
+ }
243
+ return null
244
+ }
245
+
246
+ function readAuditStateAsReportInput(projectDir: string, runId: string): ReportInput {
247
+ const logger = createLogger()
248
+ const argusRoot = defaultRootResolver.writeRoot(projectDir)
249
+
250
+ const dedupedFile = createAuditArtifactResolver(runId, projectDir).paths().dedupedFindingsFile
251
+ try {
252
+ const dedupedRaw = JSON.parse(readFileSync(dedupedFile, "utf8")) as {
253
+ findings?: unknown[]
254
+ run_id?: string
255
+ }
256
+ if (Array.isArray(dedupedRaw.findings) && dedupedRaw.findings.length > 0) {
257
+ logger.debug(`Loaded deduped findings from: ${dedupedFile}`)
258
+
259
+ const perRunFile = createAuditArtifactResolver(runId, projectDir).paths().reportInputFile
260
+ let baseReportInput: Partial<ReportInput> = {}
261
+ try {
262
+ baseReportInput = JSON.parse(readFileSync(perRunFile, "utf8")) as Partial<ReportInput>
263
+ } catch {}
264
+
265
+ return {
266
+ ...baseReportInput,
267
+ run_id: dedupedRaw.run_id ?? runId,
268
+ findings: dedupedRaw.findings as CanonicalFinding[],
269
+ toolsExecuted: baseReportInput.toolsExecuted ?? [],
270
+ scope: baseReportInput.scope ?? [],
271
+ projectDir: baseReportInput.projectDir ?? projectDir,
272
+ seq: 0,
273
+ session_id: "audit",
274
+ tool_call_id: "",
275
+ source: "deduped-findings",
276
+ schema_version: SCHEMA_VERSION,
277
+ } as ReportInput
278
+ }
279
+ } catch {
280
+ logger.debug(`No deduped findings at ${dedupedFile}`)
281
+ }
282
+
283
+ // 1. Per-run report-input artifact (materialized by findings-materializer into runs/{runId}/)
284
+ const perRunFile = createAuditArtifactResolver(runId, projectDir).paths().reportInputFile
285
+ try {
286
+ const data = JSON.parse(readFileSync(perRunFile, "utf8")) as ReportInput
287
+ if (data.findings && data.findings.length > 0) {
288
+ logger.debug(`Loaded report-input from per-run artifact: ${perRunFile}`)
289
+ return data
290
+ }
291
+ } catch {
292
+ logger.debug(`No per-run report-input at ${perRunFile}`)
293
+ }
294
+
295
+ // 2. Flat report-input at argus root (legacy location)
296
+ const flatFile = join(argusRoot, "report-input.json")
297
+ try {
298
+ const data = JSON.parse(readFileSync(flatFile, "utf8")) as ReportInput
299
+ if (data.findings && data.findings.length > 0) {
300
+ logger.debug(`Loaded report-input from flat file: ${flatFile}`)
301
+ return data
302
+ }
303
+ } catch {
304
+ logger.debug(`No flat report-input at ${flatFile}`)
305
+ }
306
+
307
+ // 3. Per-session state files (per-session managers write to sessions/state-{sessionId}.json)
308
+ const sessionState = readNewestSessionState(argusRoot)
309
+ if (sessionState) {
310
+ logger.debug("Loaded audit state from newest session state file")
311
+ return convertAuditStateToReportInput(sessionState, runId, projectDir)
312
+ }
313
+
314
+ // 4. Shared audit state (legacy fallback)
315
+ const sharedStateFile = join(argusRoot, "argus-state.json")
316
+ try {
317
+ const state = JSON.parse(readFileSync(sharedStateFile, "utf8")) as AuditState
318
+ if (state.findings && state.findings.length > 0) {
319
+ logger.debug(`Loaded audit state from shared file: ${sharedStateFile}`)
320
+ return convertAuditStateToReportInput(state, runId, projectDir)
321
+ }
322
+ } catch {
323
+ /* shared state not available */
324
+ }
325
+
326
+ throw new Error(
327
+ `Cannot read findings from any source for run ${runId}. Checked: per-run artifact (${perRunFile}), flat file (${flatFile}), session state files, shared state (${sharedStateFile})`,
328
+ )
329
+ }
330
+
331
+ export async function executeReadFindings(
332
+ args: ReadFindingsArgs,
333
+ context: ToolContext,
334
+ ): Promise<string> {
335
+ const runId = args.run_id
336
+ if (!runId || runId.trim().length === 0) {
337
+ throw new Error("run_id is required")
338
+ }
339
+
340
+ const projectDir = resolveProjectDir(context)
341
+ const reportInput = readAuditStateAsReportInput(projectDir, runId)
342
+ const compactInput = buildCompactInput(reportInput)
343
+
344
+ const inlineJson = JSON.stringify({
345
+ success: true,
346
+ truncated: false,
347
+ source: "report-input.json" as const,
348
+ reportInput: compactInput,
349
+ })
350
+
351
+ if (Buffer.byteLength(inlineJson, "utf-8") <= OUTPUT_SIZE_THRESHOLD_BYTES) {
352
+ return inlineJson
353
+ }
354
+
355
+ const resolver = createAuditArtifactResolver(runId, projectDir)
356
+ const compactFilePath = resolver
357
+ .paths()
358
+ .reportInputFile.replace("report-input.json", "compact-report-input.json")
359
+ await mkdir(dirname(compactFilePath), { recursive: true })
360
+ await writeFile(compactFilePath, JSON.stringify(compactInput, null, 2))
361
+
362
+ const fileResult: ReadFindingsFileResult = {
363
+ success: true,
364
+ truncated: true,
365
+ source: "report-input.json",
366
+ compactReportInputFile: compactFilePath,
367
+ summary: {
368
+ run_id: runId,
369
+ findingsCount: compactInput.findings.length,
370
+ toolsExecutedCount: compactInput.toolsExecuted.length,
371
+ scope: compactInput.scope,
372
+ severityDistribution: buildSeverityDistribution(compactInput.findings),
373
+ topFindings: buildTopFindings(compactInput.findings),
374
+ },
375
+ instructions: `Output exceeds safe inline size (${Buffer.byteLength(inlineJson, "utf-8")} bytes). Full compact data written to: ${compactFilePath}. Use the read tool to access the file contents before generating the report.`,
376
+ }
377
+
378
+ return JSON.stringify(fileResult)
379
+ }
380
+
381
+ export const readFindingsTool = tool({
382
+ description:
383
+ "Read the materialized ReportInput artifact from disk for a given run. Returns the canonical findings, tools executed, scope, and all enrichment data. Scribe should call this before generating the report.",
384
+ args: {
385
+ run_id: tool.schema.string().describe("The run ID to read findings for."),
386
+ },
387
+ async execute(args, context) {
388
+ return executeReadFindings(args, context)
389
+ },
390
+ })
@@ -1,4 +1,5 @@
1
1
  import { type ToolContext, tool } from "@opencode-ai/plugin"
2
+ import { isNonEmptyString } from "../shared/type-guards"
2
3
  import { normalizeToCanonicalFinding } from "../state/adapters"
3
4
  import { SCHEMA_VERSION } from "../state/schemas"
4
5
  import type { ArgusAgentName } from "../state/types"
@@ -11,33 +12,52 @@ type RecordFindingArgs = {
11
12
  type RecordFindingResponse = {
12
13
  success: boolean
13
14
  count: number
14
- findings: ReturnType<typeof normalizeToCanonicalFinding>["data"][]
15
+ findings: Array<{
16
+ id: string
17
+ check: string
18
+ severity: string
19
+ confidence: string
20
+ file: string
21
+ description: string
22
+ lines: [number, number]
23
+ source: string
24
+ reported_by_agent: string
25
+ impact?: string
26
+ recommendation?: string
27
+ proofOfConcept?: string
28
+ }>
15
29
  schema_version: string
30
+ note: string
16
31
  }
17
32
 
18
- function parseFindingObject(raw: string, label: "finding" | "findings"): Record<string, unknown>[] {
33
+ type ParseResult = { ok: true; data: Record<string, unknown>[] } | { ok: false; error: string }
34
+
35
+ function parseFindingObject(raw: string, label: "finding" | "findings"): ParseResult {
19
36
  let parsed: unknown
20
37
  try {
21
38
  parsed = JSON.parse(raw)
22
39
  } catch {
23
- throw new Error(`${label} must be valid JSON`)
40
+ return { ok: false, error: `${label} must be valid JSON` }
24
41
  }
25
42
 
26
43
  if (label === "finding") {
27
44
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
28
- throw new Error("finding must be a JSON object")
45
+ return { ok: false, error: "finding must be a JSON object" }
29
46
  }
30
- return [parsed as Record<string, unknown>]
47
+ return { ok: true, data: [parsed as Record<string, unknown>] }
31
48
  }
32
49
 
33
50
  if (!Array.isArray(parsed)) {
34
- throw new Error("findings must be a JSON array")
51
+ return { ok: false, error: "findings must be a JSON array" }
35
52
  }
36
53
 
37
- return parsed.filter(
38
- (item): item is Record<string, unknown> =>
39
- typeof item === "object" && item !== null && !Array.isArray(item),
40
- )
54
+ return {
55
+ ok: true,
56
+ data: parsed.filter(
57
+ (item): item is Record<string, unknown> =>
58
+ typeof item === "object" && item !== null && !Array.isArray(item),
59
+ ),
60
+ }
41
61
  }
42
62
 
43
63
  function normalizeAgent(value: string): ArgusAgentName {
@@ -48,36 +68,80 @@ function normalizeAgent(value: string): ArgusAgentName {
48
68
  return "unknown"
49
69
  }
50
70
 
71
+ function errorResponse(error: string): string {
72
+ return JSON.stringify({
73
+ success: false,
74
+ count: 0,
75
+ findings: [],
76
+ schema_version: SCHEMA_VERSION,
77
+ note: error,
78
+ error,
79
+ })
80
+ }
81
+
51
82
  export async function executeRecordFinding(
52
83
  args: RecordFindingArgs,
53
84
  context: ToolContext,
54
85
  ): Promise<string> {
55
86
  const rawFindings: Record<string, unknown>[] = []
56
87
 
57
- if (typeof args.finding === "string" && args.finding.trim().length > 0) {
58
- rawFindings.push(...parseFindingObject(args.finding, "finding"))
88
+ if (isNonEmptyString(args.finding)) {
89
+ const result = parseFindingObject(args.finding, "finding")
90
+ if (!result.ok) return errorResponse(result.error)
91
+ rawFindings.push(...result.data)
59
92
  }
60
- if (typeof args.findings === "string" && args.findings.trim().length > 0) {
61
- rawFindings.push(...parseFindingObject(args.findings, "findings"))
93
+ if (isNonEmptyString(args.findings)) {
94
+ const result = parseFindingObject(args.findings, "findings")
95
+ if (!result.ok) return errorResponse(result.error)
96
+ rawFindings.push(...result.data)
62
97
  }
63
98
 
64
99
  if (rawFindings.length === 0) {
65
- throw new Error("Provide at least one finding via finding or findings")
100
+ return errorResponse("Provide at least one finding via finding or findings")
101
+ }
102
+
103
+ for (const f of rawFindings) {
104
+ if (!f.check && typeof f.title === "string") f.check = f.title
105
+ if (!f.check && typeof f.name === "string") f.check = f.name
106
+ if (!f.file && typeof f.location === "string") {
107
+ const loc = f.location as string
108
+ const colonIdx = loc.lastIndexOf(":")
109
+ if (colonIdx > 0 && /^\d+(-\d+)?$/.test(loc.substring(colonIdx + 1))) {
110
+ f.file = loc.substring(0, colonIdx)
111
+ if (!f.lines) {
112
+ const match = loc.substring(colonIdx + 1).match(/^(\d+)(?:-(\d+))?$/)
113
+ if (match)
114
+ f.lines = [
115
+ Number.parseInt(match[1] ?? "0", 10),
116
+ Number.parseInt(match[2] ?? match[1] ?? "0", 10),
117
+ ]
118
+ }
119
+ } else {
120
+ f.file = loc
121
+ }
122
+ }
66
123
  }
67
124
 
68
125
  const reportedByAgent = normalizeAgent(context.agent)
69
126
  const reportedBySessionId = context.sessionID
70
- const runId = context.sessionID || "manual-run"
127
+ const runId = "tool-local"
128
+ const projectDir = context.directory ?? process.cwd()
71
129
 
72
130
  const findings: ReturnType<typeof normalizeToCanonicalFinding>["data"][] = []
73
131
  const errors: string[] = []
74
132
 
75
133
  for (const [index, rawFinding] of rawFindings.entries()) {
76
- const normalized = normalizeToCanonicalFinding(rawFinding, runId, index + 1, {
77
- reportedByAgent,
78
- reportedBySessionId,
79
- observationId: `${reportedBySessionId}:${index + 1}`,
80
- })
134
+ const normalized = normalizeToCanonicalFinding(
135
+ rawFinding,
136
+ runId,
137
+ index + 1,
138
+ {
139
+ reportedByAgent,
140
+ reportedBySessionId,
141
+ observationId: `${reportedBySessionId}:${index + 1}`,
142
+ },
143
+ projectDir,
144
+ )
81
145
 
82
146
  const diagnosticsErrors = normalized.diagnostics.filter((diag) => diag.level === "error")
83
147
  if (diagnosticsErrors.length > 0) {
@@ -93,14 +157,51 @@ export async function executeRecordFinding(
93
157
  }
94
158
 
95
159
  if (errors.length > 0) {
96
- throw new Error(`Failed to record finding(s): ${errors.join("; ")}`)
160
+ return errorResponse(`Failed to record finding(s): ${errors.join("; ")}`)
161
+ }
162
+
163
+ // Warn when Critical/High findings are missing enrichment fields
164
+ const enrichmentWarnings: string[] = []
165
+ const HIGH_SEVERITIES = new Set(["Critical", "High"])
166
+ for (const f of findings) {
167
+ if (!HIGH_SEVERITIES.has(f.severity)) continue
168
+ const missing: string[] = []
169
+ if (!f.impact) missing.push("impact")
170
+ if (!f.recommendation) missing.push("recommendation")
171
+ if (!f.proofOfConcept) missing.push("proofOfConcept")
172
+ if (missing.length > 0) {
173
+ enrichmentWarnings.push(
174
+ `[${f.severity}] ${f.check} in ${f.file} is missing: ${missing.join(", ")}. Quality gate will flag this.`,
175
+ )
176
+ }
97
177
  }
98
178
 
99
179
  const response: RecordFindingResponse = {
100
180
  success: true,
101
181
  count: findings.length,
102
- findings,
182
+ findings: findings.map((f) => ({
183
+ id: f.id,
184
+ check: f.check,
185
+ severity: f.severity,
186
+ confidence: f.confidence,
187
+ file: f.file,
188
+ description: f.description,
189
+ lines: f.lines,
190
+ source: f.source,
191
+ reported_by_agent: f.reported_by_agent,
192
+ ...(f.impact !== undefined ? { impact: f.impact } : {}),
193
+ ...(f.recommendation !== undefined ? { recommendation: f.recommendation } : {}),
194
+ ...(f.proofOfConcept !== undefined ? { proofOfConcept: f.proofOfConcept } : {}),
195
+ })),
103
196
  schema_version: SCHEMA_VERSION,
197
+ note: "Findings recorded to event journal. The system assigns the canonical run_id automatically — use the run_id from <argus-context> for Scribe dispatch.",
198
+ ...(enrichmentWarnings.length > 0
199
+ ? {
200
+ enrichment_warnings: enrichmentWarnings,
201
+ enrichment_hint:
202
+ "Critical and High findings MUST include impact, recommendation, and proofOfConcept fields. Re-submit with these fields to pass the quality gate.",
203
+ }
204
+ : {}),
104
205
  }
105
206
 
106
207
  return JSON.stringify(response)
@@ -113,11 +214,15 @@ export const recordFindingTool = tool({
113
214
  finding: tool.schema
114
215
  .string()
115
216
  .optional()
116
- .describe("Serialized JSON object containing a single finding payload."),
217
+ .describe(
218
+ 'Serialized JSON object for a single finding. Required fields: check (string, e.g. "reentrancy-eth"), severity (Critical|High|Medium|Low|Informational), confidence (High|Medium|Low), description (string), file (relative path, e.g. "src/Vault.sol"), lines ([startLine, endLine] tuple), source ("manual"). Optional: impact, recommendation, proofOfConcept (mandatory for Critical/High).',
219
+ ),
117
220
  findings: tool.schema
118
221
  .string()
119
222
  .optional()
120
- .describe("Serialized JSON array containing one or more finding payload objects."),
223
+ .describe(
224
+ "Serialized JSON array of finding objects. Each object requires the same fields as the finding parameter: check, severity, confidence, description, file, lines, source. Aliases title/name → check and location → file are accepted but canonical names are preferred.",
225
+ ),
121
226
  },
122
227
  async execute(args, context) {
123
228
  return executeRecordFinding(args, context)