solidity-argus 0.3.7 → 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 +797 -148
  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 +34 -2
  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 +597 -323
  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 +394 -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
@@ -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,47 @@ 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
+ file: string
20
+ description: string
21
+ lines: [number, number]
22
+ source: string
23
+ }>
15
24
  schema_version: string
25
+ note: string
16
26
  }
17
27
 
18
- function parseFindingObject(raw: string, label: "finding" | "findings"): Record<string, unknown>[] {
28
+ type ParseResult = { ok: true; data: Record<string, unknown>[] } | { ok: false; error: string }
29
+
30
+ function parseFindingObject(raw: string, label: "finding" | "findings"): ParseResult {
19
31
  let parsed: unknown
20
32
  try {
21
33
  parsed = JSON.parse(raw)
22
34
  } catch {
23
- throw new Error(`${label} must be valid JSON`)
35
+ return { ok: false, error: `${label} must be valid JSON` }
24
36
  }
25
37
 
26
38
  if (label === "finding") {
27
39
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
28
- throw new Error("finding must be a JSON object")
40
+ return { ok: false, error: "finding must be a JSON object" }
29
41
  }
30
- return [parsed as Record<string, unknown>]
42
+ return { ok: true, data: [parsed as Record<string, unknown>] }
31
43
  }
32
44
 
33
45
  if (!Array.isArray(parsed)) {
34
- throw new Error("findings must be a JSON array")
46
+ return { ok: false, error: "findings must be a JSON array" }
35
47
  }
36
48
 
37
- return parsed.filter(
38
- (item): item is Record<string, unknown> =>
39
- typeof item === "object" && item !== null && !Array.isArray(item),
40
- )
49
+ return {
50
+ ok: true,
51
+ data: parsed.filter(
52
+ (item): item is Record<string, unknown> =>
53
+ typeof item === "object" && item !== null && !Array.isArray(item),
54
+ ),
55
+ }
41
56
  }
42
57
 
43
58
  function normalizeAgent(value: string): ArgusAgentName {
@@ -48,36 +63,80 @@ function normalizeAgent(value: string): ArgusAgentName {
48
63
  return "unknown"
49
64
  }
50
65
 
66
+ function errorResponse(error: string): string {
67
+ return JSON.stringify({
68
+ success: false,
69
+ count: 0,
70
+ findings: [],
71
+ schema_version: SCHEMA_VERSION,
72
+ note: error,
73
+ error,
74
+ })
75
+ }
76
+
51
77
  export async function executeRecordFinding(
52
78
  args: RecordFindingArgs,
53
79
  context: ToolContext,
54
80
  ): Promise<string> {
55
81
  const rawFindings: Record<string, unknown>[] = []
56
82
 
57
- if (typeof args.finding === "string" && args.finding.trim().length > 0) {
58
- rawFindings.push(...parseFindingObject(args.finding, "finding"))
83
+ if (isNonEmptyString(args.finding)) {
84
+ const result = parseFindingObject(args.finding, "finding")
85
+ if (!result.ok) return errorResponse(result.error)
86
+ rawFindings.push(...result.data)
59
87
  }
60
- if (typeof args.findings === "string" && args.findings.trim().length > 0) {
61
- rawFindings.push(...parseFindingObject(args.findings, "findings"))
88
+ if (isNonEmptyString(args.findings)) {
89
+ const result = parseFindingObject(args.findings, "findings")
90
+ if (!result.ok) return errorResponse(result.error)
91
+ rawFindings.push(...result.data)
62
92
  }
63
93
 
64
94
  if (rawFindings.length === 0) {
65
- throw new Error("Provide at least one finding via finding or findings")
95
+ return errorResponse("Provide at least one finding via finding or findings")
96
+ }
97
+
98
+ for (const f of rawFindings) {
99
+ if (!f.check && typeof f.title === "string") f.check = f.title
100
+ if (!f.check && typeof f.name === "string") f.check = f.name
101
+ if (!f.file && typeof f.location === "string") {
102
+ const loc = f.location as string
103
+ const colonIdx = loc.lastIndexOf(":")
104
+ if (colonIdx > 0 && /^\d+(-\d+)?$/.test(loc.substring(colonIdx + 1))) {
105
+ f.file = loc.substring(0, colonIdx)
106
+ if (!f.lines) {
107
+ const match = loc.substring(colonIdx + 1).match(/^(\d+)(?:-(\d+))?$/)
108
+ if (match)
109
+ f.lines = [
110
+ Number.parseInt(match[1] ?? "0", 10),
111
+ Number.parseInt(match[2] ?? match[1] ?? "0", 10),
112
+ ]
113
+ }
114
+ } else {
115
+ f.file = loc
116
+ }
117
+ }
66
118
  }
67
119
 
68
120
  const reportedByAgent = normalizeAgent(context.agent)
69
121
  const reportedBySessionId = context.sessionID
70
- const runId = context.sessionID || "manual-run"
122
+ const runId = "tool-local"
123
+ const projectDir = context.directory ?? process.cwd()
71
124
 
72
125
  const findings: ReturnType<typeof normalizeToCanonicalFinding>["data"][] = []
73
126
  const errors: string[] = []
74
127
 
75
128
  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
- })
129
+ const normalized = normalizeToCanonicalFinding(
130
+ rawFinding,
131
+ runId,
132
+ index + 1,
133
+ {
134
+ reportedByAgent,
135
+ reportedBySessionId,
136
+ observationId: `${reportedBySessionId}:${index + 1}`,
137
+ },
138
+ projectDir,
139
+ )
81
140
 
82
141
  const diagnosticsErrors = normalized.diagnostics.filter((diag) => diag.level === "error")
83
142
  if (diagnosticsErrors.length > 0) {
@@ -93,14 +152,46 @@ export async function executeRecordFinding(
93
152
  }
94
153
 
95
154
  if (errors.length > 0) {
96
- throw new Error(`Failed to record finding(s): ${errors.join("; ")}`)
155
+ return errorResponse(`Failed to record finding(s): ${errors.join("; ")}`)
156
+ }
157
+
158
+ // Warn when Critical/High findings are missing enrichment fields
159
+ const enrichmentWarnings: string[] = []
160
+ const HIGH_SEVERITIES = new Set(["Critical", "High"])
161
+ for (const f of findings) {
162
+ if (!HIGH_SEVERITIES.has(f.severity)) continue
163
+ const missing: string[] = []
164
+ if (!f.impact) missing.push("impact")
165
+ if (!f.recommendation) missing.push("recommendation")
166
+ if (!f.proofOfConcept) missing.push("proofOfConcept")
167
+ if (missing.length > 0) {
168
+ enrichmentWarnings.push(
169
+ `[${f.severity}] ${f.check} in ${f.file} is missing: ${missing.join(", ")}. Quality gate will flag this.`,
170
+ )
171
+ }
97
172
  }
98
173
 
99
174
  const response: RecordFindingResponse = {
100
175
  success: true,
101
176
  count: findings.length,
102
- findings,
177
+ findings: findings.map((f) => ({
178
+ id: f.id,
179
+ check: f.check,
180
+ severity: f.severity,
181
+ file: f.file,
182
+ description: f.description,
183
+ lines: f.lines,
184
+ source: f.source,
185
+ })),
103
186
  schema_version: SCHEMA_VERSION,
187
+ note: "Findings recorded to event journal. The system assigns the canonical run_id automatically — use the run_id from <argus-context> for Scribe dispatch.",
188
+ ...(enrichmentWarnings.length > 0
189
+ ? {
190
+ enrichment_warnings: enrichmentWarnings,
191
+ enrichment_hint:
192
+ "Critical and High findings MUST include impact, recommendation, and proofOfConcept fields. Re-submit with these fields to pass the quality gate.",
193
+ }
194
+ : {}),
104
195
  }
105
196
 
106
197
  return JSON.stringify(response)
@@ -113,11 +204,15 @@ export const recordFindingTool = tool({
113
204
  finding: tool.schema
114
205
  .string()
115
206
  .optional()
116
- .describe("Serialized JSON object containing a single finding payload."),
207
+ .describe(
208
+ '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).',
209
+ ),
117
210
  findings: tool.schema
118
211
  .string()
119
212
  .optional()
120
- .describe("Serialized JSON array containing one or more finding payload objects."),
213
+ .describe(
214
+ "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.",
215
+ ),
121
216
  },
122
217
  async execute(args, context) {
123
218
  return executeRecordFinding(args, context)