solidity-argus 0.3.2 → 0.3.4

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.
@@ -1,9 +1,16 @@
1
+ import { existsSync } from "node:fs"
1
2
  import path from "node:path"
2
3
  import { type ToolContext, tool } from "@opencode-ai/plugin"
3
4
  import { loadArgusConfig } from "../config/loader"
4
5
  import type { ArgusConfig } from "../config/types"
6
+ import type { DropDiagnostic, DropPolicy } from "../shared/drop-diagnostics"
7
+ import { createDropDiagnosticsCollector } from "../shared/drop-diagnostics"
5
8
  import { createLogger } from "../shared/logger"
6
9
  import { resolveProjectDir } from "../shared/project-utils"
10
+ import { resolveReportPath } from "../shared/report-path-resolver"
11
+ import { normalizeToCanonicalFinding } from "../state/adapters"
12
+ import { stableHash } from "../state/projectors"
13
+ import { type ReportInput, SCHEMA_VERSION, validateReportInput } from "../state/schemas"
7
14
  import type { AuditState, Finding, FindingSeverity } from "../state/types"
8
15
 
9
16
  type SeverityThreshold = "critical" | "high" | "medium" | "low" | "informational"
@@ -13,7 +20,9 @@ type ReportGeneratorArgs = {
13
20
  scope: string[]
14
21
  include_executive_summary?: boolean
15
22
  severity_threshold?: SeverityThreshold
16
- audit_state: string
23
+ quality_gate_policy?: QualityGatePolicy
24
+ report_input?: string
25
+ audit_state?: string
17
26
  }
18
27
 
19
28
  type FindingsCount = {
@@ -28,13 +37,77 @@ export type ReportGenerationResult = {
28
37
  report: string
29
38
  findingsCount: FindingsCount
30
39
  filename: string
40
+ contentHash: string
41
+ qualityGates: ReportQualityValidation
42
+ contractDiagnostics: DropDiagnostic[]
31
43
  filePath?: string
44
+ error?: { code: string; message: string }
45
+ }
46
+
47
+ type QualityGatePolicy = "warn" | "strict-fail"
48
+
49
+ type ReportQualityViolation = {
50
+ findingId: string
51
+ code: string
52
+ message: string
53
+ }
54
+
55
+ type ReportQualityValidation = {
56
+ passed: boolean
57
+ violations: ReportQualityViolation[]
32
58
  }
33
59
 
34
60
  export type ReportGenerationDependencies = {
35
61
  loadConfig?: (projectDir: string) => ArgusConfig
36
62
  }
37
63
 
64
+ export const SINGLE_WRITER_POLICY_VERSION = "1.0.0"
65
+
66
+ const REPORT_METADATA_REGEX = /<!-- argus:report_metadata (.+?) -->/
67
+
68
+ /**
69
+ * Extract the run_id from report metadata embedded as an HTML comment.
70
+ * Returns null if no metadata is found or run_id is missing.
71
+ */
72
+ export function extractReportRunId(content: string): string | null {
73
+ const match = content.match(REPORT_METADATA_REGEX)
74
+ if (!match?.[1]) return null
75
+ try {
76
+ const metadata = JSON.parse(match[1])
77
+ return typeof metadata.run_id === "string" ? metadata.run_id : null
78
+ } catch {
79
+ return null
80
+ }
81
+ }
82
+
83
+ function buildReportMetadataComment(runId: string): string {
84
+ const metadata = {
85
+ run_id: runId,
86
+ policy_version: SINGLE_WRITER_POLICY_VERSION,
87
+ }
88
+ return `<!-- argus:report_metadata ${JSON.stringify(metadata)} -->`
89
+ }
90
+
91
+ async function checkDuplicateWrite(
92
+ filePath: string,
93
+ runId: string,
94
+ ): Promise<{ code: string; message: string } | null> {
95
+ if (!existsSync(filePath)) return null
96
+ try {
97
+ const existingContent = await Bun.file(filePath).text()
98
+ const existingRunId = extractReportRunId(existingContent)
99
+ if (existingRunId === runId) {
100
+ return {
101
+ code: "DUPLICATE_WRITE_ATTEMPT",
102
+ message: `Report for run_id "${runId}" already exists at ${filePath}. Single-writer policy (v${SINGLE_WRITER_POLICY_VERSION}) prevents duplicate writes for the same run.`,
103
+ }
104
+ }
105
+ } catch {
106
+ // Cannot read existing file; allow write
107
+ }
108
+ return null
109
+ }
110
+
38
111
  const SEVERITY_ORDER: FindingSeverity[] = ["Critical", "High", "Medium", "Low", "Informational"]
39
112
 
40
113
  const SEVERITY_PREFIX: Record<FindingSeverity, string> = {
@@ -61,6 +134,24 @@ const FINDING_WEIGHT: Record<FindingSeverity, number> = {
61
134
  Informational: 1,
62
135
  }
63
136
 
137
+ const SEVERITY_RANK: Record<FindingSeverity, number> = {
138
+ Critical: 0,
139
+ High: 1,
140
+ Medium: 2,
141
+ Low: 3,
142
+ Informational: 4,
143
+ }
144
+
145
+ const MISSING_IMPACT_TEXT = "Impact details were not provided in the finding payload."
146
+ const MISSING_RECOMMENDATION_TEXT =
147
+ "Recommendation details were not provided in the finding payload."
148
+
149
+ type ReportFindingFields = {
150
+ impact?: string
151
+ recommendation?: string
152
+ proofOfConcept?: string
153
+ }
154
+
64
155
  function emptyCounts(): FindingsCount {
65
156
  return {
66
157
  critical: 0,
@@ -84,6 +175,118 @@ function emptyAuditState(findings: Finding[] = []): AuditState {
84
175
  }
85
176
  }
86
177
 
178
+ /**
179
+ * Parse a location string like "File.sol:18-22" or "File.sol:18" into { file, lines }.
180
+ * Returns undefined if the string doesn't match a recognized format.
181
+ */
182
+ export function parseLocationString(
183
+ location: string,
184
+ ): { file: string; lines: [number, number] } | undefined {
185
+ // "File.sol:18-22" or "File.sol:L18-L22"
186
+ const rangeMatch = location.match(/^(.+?):L?(\d+)\s*-\s*L?(\d+)$/)
187
+ if (rangeMatch) {
188
+ const file = rangeMatch.at(1)
189
+ const start = rangeMatch.at(2)
190
+ const end = rangeMatch.at(3)
191
+ if (file && start && end) {
192
+ return { file, lines: [Number(start), Number(end)] }
193
+ }
194
+ }
195
+ // "File.sol:18"
196
+ const singleMatch = location.match(/^(.+?):L?(\d+)$/)
197
+ if (singleMatch) {
198
+ const file = singleMatch.at(1)
199
+ const lineNum = singleMatch.at(2)
200
+ if (file && lineNum) {
201
+ const n = Number(lineNum)
202
+ return { file, lines: [n, n] }
203
+ }
204
+ }
205
+ return undefined
206
+ }
207
+
208
+ /**
209
+ * Normalize a raw finding object from agent output into the canonical field format.
210
+ * Handles common aliases:
211
+ * - title/name → check
212
+ * - location (string) → file + lines
213
+ * - case-insensitive severity → capitalized
214
+ */
215
+ export function normalizeRawFinding(raw: Record<string, unknown>): Record<string, unknown> {
216
+ const result = { ...raw }
217
+
218
+ // check: accept title, name as aliases
219
+ if (typeof result.check !== "string" || (result.check as string).length === 0) {
220
+ const alias = result.title ?? result.name
221
+ if (typeof alias === "string" && alias.length > 0) {
222
+ result.check = alias
223
+ }
224
+ }
225
+
226
+ // file + lines: accept location string as alias
227
+ if (typeof result.file !== "string" && typeof result.location === "string") {
228
+ const parsed = parseLocationString(result.location as string)
229
+ if (parsed) {
230
+ result.file = parsed.file
231
+ if (!Array.isArray(result.lines) || (result.lines as unknown[]).length !== 2) {
232
+ result.lines = parsed.lines
233
+ }
234
+ }
235
+ }
236
+
237
+ // lines: accept [start] as [start, start], accept line_start/line_end
238
+ if (!Array.isArray(result.lines) || (result.lines as unknown[]).length !== 2) {
239
+ if (Array.isArray(result.lines) && (result.lines as unknown[]).length === 1) {
240
+ const n = Number((result.lines as unknown[])[0])
241
+ if (!Number.isNaN(n)) {
242
+ result.lines = [n, n]
243
+ }
244
+ } else if (typeof result.line_start === "number" && typeof result.line_end === "number") {
245
+ result.lines = [result.line_start, result.line_end]
246
+ } else if (typeof result.line === "number") {
247
+ result.lines = [result.line, result.line]
248
+ }
249
+ }
250
+
251
+ // severity: case-insensitive normalization
252
+ if (typeof result.severity === "string") {
253
+ const lower = (result.severity as string).toLowerCase()
254
+ const SEVERITY_MAP: Record<string, string> = {
255
+ critical: "Critical",
256
+ high: "High",
257
+ medium: "Medium",
258
+ low: "Low",
259
+ informational: "Informational",
260
+ info: "Informational",
261
+ }
262
+ const mapped = SEVERITY_MAP[lower]
263
+ if (mapped) {
264
+ result.severity = mapped
265
+ }
266
+ }
267
+
268
+ // confidence: case-insensitive normalization
269
+ if (typeof result.confidence === "string") {
270
+ const lower = (result.confidence as string).toLowerCase()
271
+ const CONFIDENCE_MAP: Record<string, string> = {
272
+ high: "High",
273
+ medium: "Medium",
274
+ low: "Low",
275
+ }
276
+ const mapped = CONFIDENCE_MAP[lower]
277
+ if (mapped) {
278
+ result.confidence = mapped
279
+ }
280
+ }
281
+
282
+ // description: fall back to check if missing
283
+ if (typeof result.description !== "string" && typeof result.check === "string") {
284
+ result.description = result.check
285
+ }
286
+
287
+ return result
288
+ }
289
+
87
290
  function hasMinimumFindingFields(
88
291
  f: unknown,
89
292
  ): f is { check: string; file: string; lines: [number, number] } {
@@ -140,23 +343,247 @@ function normalizeFinding(f: Record<string, unknown>): Finding {
140
343
  source,
141
344
  remediation: typeof f.remediation === "string" ? f.remediation : undefined,
142
345
  exploitReference: typeof f.exploitReference === "string" ? f.exploitReference : undefined,
346
+ ...(typeof f.impact === "string" ? { impact: f.impact } : {}),
347
+ ...(typeof f.recommendation === "string" ? { recommendation: f.recommendation } : {}),
348
+ ...(typeof f.proofOfConcept === "string" ? { proofOfConcept: f.proofOfConcept } : {}),
349
+ ...(typeof f.proof_of_concept === "string" ? { proofOfConcept: f.proof_of_concept } : {}),
350
+ } as Finding
351
+ }
352
+
353
+ export type ParseAuditStateOptions = {
354
+ dropPolicy?: DropPolicy
355
+ }
356
+
357
+ export type ParseAuditStateResult = {
358
+ state: AuditState
359
+ diagnostics: DropDiagnostic[]
360
+ }
361
+
362
+ type ParseReportInputResult = {
363
+ reportInput: ReportInput
364
+ diagnostics: DropDiagnostic[]
365
+ }
366
+
367
+ function diagnosticsSummary(diagnostics: DropDiagnostic[]): string {
368
+ return diagnostics.map((diag) => `${diag.reason.code}:${diag.reason.message}`).join("; ")
369
+ }
370
+
371
+ function throwContractMismatch(message: string, diagnostics: DropDiagnostic[]): never {
372
+ const details = diagnosticsSummary(diagnostics)
373
+ const fullMessage = details.length > 0 ? `${message}. Diagnostics: ${details}` : message
374
+ throw new Error(fullMessage)
375
+ }
376
+
377
+ function reportInputToAuditState(reportInput: ReportInput): AuditState {
378
+ return {
379
+ sessionId: reportInput.session_id,
380
+ projectDir: reportInput.projectDir,
381
+ contractsReviewed: Array.from(
382
+ new Set(reportInput.findings.map((finding) => finding.file)),
383
+ ).sort((a, b) => a.localeCompare(b)),
384
+ findings: reportInput.findings,
385
+ toolsExecuted: reportInput.toolsExecuted,
386
+ currentPhase: "complete",
387
+ scope: reportInput.scope,
388
+ startTime: 0,
389
+ soloditResults: reportInput.soloditResults,
390
+ fuzzCounterexamples: reportInput.fuzzCounterexamples,
391
+ coverageReport: reportInput.coverageReport,
392
+ gasHotspots: reportInput.gasHotspots,
393
+ proxyContracts: reportInput.proxyContracts,
394
+ patternVersion: reportInput.patternVersion,
395
+ skillsLoaded: reportInput.skillsLoaded,
396
+ }
397
+ }
398
+
399
+ function buildLegacyCompatibleReportInput(
400
+ state: AuditState,
401
+ context: ToolContext,
402
+ diagnostics: ReturnType<typeof createDropDiagnosticsCollector>,
403
+ ): ReportInput {
404
+ diagnostics.warn(
405
+ "REPORT_INPUT_DEPRECATED_LEGACY_PAYLOAD",
406
+ "Legacy audit_state payload is deprecated; pass report_input with canonical ReportInput schema.",
407
+ "audit_state",
408
+ )
409
+
410
+ const runId = state.sessionId || context.sessionID || "legacy-run"
411
+ const sessionId = state.sessionId || context.sessionID || runId
412
+
413
+ if (!state.sessionId) {
414
+ diagnostics.warn(
415
+ "REPORT_INPUT_SYNTHESIZED_SESSION",
416
+ "Legacy payload missing sessionId; synthesized session_id from tool context/run_id.",
417
+ "session_id",
418
+ )
419
+ }
420
+ if (!state.projectDir) {
421
+ diagnostics.warn(
422
+ "REPORT_INPUT_SYNTHESIZED_PROJECT_DIR",
423
+ "Legacy payload missing projectDir; synthesized projectDir from tool context.",
424
+ "projectDir",
425
+ )
426
+ }
427
+
428
+ const canonicalFindings = state.findings
429
+ .map((finding, index) => {
430
+ const normalized = normalizeToCanonicalFinding(finding, runId, index + 1)
431
+ for (const diag of normalized.diagnostics) {
432
+ diagnostics.warn(
433
+ "REPORT_INPUT_LEGACY_FINDING_NORMALIZED",
434
+ `[index:${index}] ${diag.message}`,
435
+ diag.field,
436
+ )
437
+ }
438
+ return normalized.data
439
+ })
440
+ .filter((finding) => finding.check.length > 0 && finding.file.length > 0)
441
+
442
+ return {
443
+ run_id: runId,
444
+ seq: state.toolsExecuted.length + canonicalFindings.length,
445
+ session_id: sessionId,
446
+ tool_call_id: "legacy-adapter",
447
+ source: "report-generator-legacy-adapter",
448
+ schema_version: SCHEMA_VERSION,
449
+ projectDir: state.projectDir || resolveProjectDir(context),
450
+ findings: canonicalFindings,
451
+ toolsExecuted: state.toolsExecuted.map((toolExec) => ({
452
+ ...toolExec,
453
+ run_id: runId,
454
+ schema_version: SCHEMA_VERSION,
455
+ })),
456
+ scope: state.scope,
457
+ soloditResults: state.soloditResults,
458
+ fuzzCounterexamples: state.fuzzCounterexamples,
459
+ coverageReport: state.coverageReport,
460
+ gasHotspots: state.gasHotspots,
461
+ proxyContracts: state.proxyContracts,
462
+ patternVersion: state.patternVersion,
463
+ skillsLoaded: state.skillsLoaded,
143
464
  }
144
465
  }
145
466
 
146
- export function parseAuditState(auditState: string): AuditState {
467
+ function parseReportInputPayload(
468
+ args: ReportGeneratorArgs,
469
+ context: ToolContext,
470
+ ): ParseReportInputResult {
471
+ const diagnostics = createDropDiagnosticsCollector(
472
+ "warn",
473
+ "report-generator",
474
+ "argus_generate_report",
475
+ )
476
+
477
+ if (typeof args.report_input === "string" && args.report_input.trim().length > 0) {
478
+ let parsed: unknown
479
+ try {
480
+ parsed = JSON.parse(args.report_input)
481
+ } catch {
482
+ diagnostics.error(
483
+ "REPORT_INPUT_MALFORMED_JSON",
484
+ "report_input is not valid JSON. Expected serialized ReportInput object.",
485
+ "report_input",
486
+ )
487
+ throwContractMismatch(
488
+ "ReportInput contract mismatch: malformed report_input JSON",
489
+ diagnostics.getDiagnostics(),
490
+ )
491
+ }
492
+
493
+ const validation = validateReportInput(parsed)
494
+ if (!validation.success) {
495
+ for (const error of validation.errors) {
496
+ diagnostics.error(
497
+ "REPORT_INPUT_CONTRACT_MISMATCH",
498
+ `${error.field}: ${error.message}`,
499
+ error.field,
500
+ )
501
+ }
502
+ throwContractMismatch(
503
+ "ReportInput contract mismatch: report_input failed schema validation",
504
+ diagnostics.getDiagnostics(),
505
+ )
506
+ }
507
+
508
+ if (typeof args.audit_state === "string" && args.audit_state.trim().length > 0) {
509
+ diagnostics.warn(
510
+ "REPORT_INPUT_LEGACY_FIELD_IGNORED",
511
+ "Both report_input and audit_state were provided; audit_state is ignored.",
512
+ "audit_state",
513
+ )
514
+ }
515
+
516
+ return { reportInput: validation.data, diagnostics: diagnostics.getDiagnostics() }
517
+ }
518
+
519
+ if (typeof args.audit_state === "string" && args.audit_state.trim().length > 0) {
520
+ const legacy = parseAuditStateWithDiagnostics(args.audit_state, { dropPolicy: "warn" })
521
+ for (const diagnostic of legacy.diagnostics) {
522
+ diagnostics.warn(diagnostic.reason.code, diagnostic.reason.message, diagnostic.reason.field)
523
+ }
524
+ const reportInput = buildLegacyCompatibleReportInput(legacy.state, context, diagnostics)
525
+ return { reportInput, diagnostics: diagnostics.getDiagnostics() }
526
+ }
527
+
528
+ diagnostics.error(
529
+ "REPORT_INPUT_MISSING",
530
+ "Missing report_input payload. Provide report_input (preferred) or legacy audit_state for transition.",
531
+ "report_input",
532
+ )
533
+ throwContractMismatch(
534
+ "ReportInput contract mismatch: missing required payload",
535
+ diagnostics.getDiagnostics(),
536
+ )
537
+ }
538
+
539
+ function emitDropDiagnosticsForFindings(
540
+ rawItems: unknown[],
541
+ normalized: Record<string, unknown>[],
542
+ validFindings: Finding[],
543
+ diag: ReturnType<typeof createDropDiagnosticsCollector>,
544
+ ): void {
545
+ const droppedCount = rawItems.length - validFindings.length
546
+ if (droppedCount <= 0) return
547
+
548
+ for (const item of normalized) {
549
+ if (hasMinimumFindingFields(item)) continue
550
+ const missing: string[] = []
551
+ if (typeof item.check !== "string" || (item.check as string).length === 0) missing.push("check")
552
+ if (typeof item.file !== "string") missing.push("file")
553
+ if (!Array.isArray(item.lines) || (item.lines as unknown[]).length !== 2) missing.push("lines")
554
+ diag.error(
555
+ "MISSING_REQUIRED_FIELD",
556
+ `Finding dropped: missing ${missing.join(", ") || "unknown fields"} after normalization`,
557
+ missing[0],
558
+ )
559
+ }
560
+ }
561
+
562
+ export function parseAuditState(auditState: string, options?: ParseAuditStateOptions): AuditState {
563
+ const policy = options?.dropPolicy ?? "warn"
564
+ const diag = createDropDiagnosticsCollector(policy, "report-generator")
565
+
147
566
  let parsed: unknown
148
567
  try {
149
568
  parsed = JSON.parse(auditState)
150
569
  } catch {
570
+ diag.error("MALFORMED_JSON", "audit_state is not valid JSON")
571
+ diag.throwIfStrict()
151
572
  throw new Error(
152
573
  "audit_state is not valid JSON — expected an AuditState object or Finding[] array",
153
574
  )
154
575
  }
155
576
 
156
577
  if (Array.isArray(parsed)) {
157
- const validFindings = (parsed as unknown[])
578
+ const rawItems = parsed as unknown[]
579
+ const normalized = rawItems
580
+ .filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
581
+ .map((item) => normalizeRawFinding(item))
582
+ const validFindings = normalized
158
583
  .filter(hasMinimumFindingFields)
159
584
  .map((f) => normalizeFinding(f as Record<string, unknown>))
585
+ emitDropDiagnosticsForFindings(rawItems, normalized, validFindings, diag)
586
+ diag.throwIfStrict()
160
587
  return emptyAuditState(validFindings)
161
588
  }
162
589
 
@@ -166,9 +593,15 @@ export function parseAuditState(auditState: string): AuditState {
166
593
  Array.isArray((parsed as AuditState).findings)
167
594
  ) {
168
595
  const state = parsed as AuditState
169
- const validFindings = state.findings
596
+ const rawFindings = state.findings as unknown[]
597
+ const normalized = rawFindings
598
+ .filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
599
+ .map((item) => normalizeRawFinding(item))
600
+ const validFindings = normalized
170
601
  .filter(hasMinimumFindingFields)
171
- .map((f) => normalizeFinding(f as unknown as Record<string, unknown>))
602
+ .map((f) => normalizeFinding(f as Record<string, unknown>))
603
+ emitDropDiagnosticsForFindings(rawFindings, normalized, validFindings, diag)
604
+ diag.throwIfStrict()
172
605
  return {
173
606
  ...emptyAuditState(),
174
607
  ...state,
@@ -179,6 +612,59 @@ export function parseAuditState(auditState: string): AuditState {
179
612
  return emptyAuditState()
180
613
  }
181
614
 
615
+ export function parseAuditStateWithDiagnostics(
616
+ auditState: string,
617
+ options?: ParseAuditStateOptions,
618
+ ): ParseAuditStateResult {
619
+ const policy = options?.dropPolicy ?? "warn"
620
+ const diag = createDropDiagnosticsCollector(policy, "report-generator")
621
+
622
+ let parsed: unknown
623
+ try {
624
+ parsed = JSON.parse(auditState)
625
+ } catch {
626
+ diag.error("MALFORMED_JSON", "audit_state is not valid JSON")
627
+ diag.throwIfStrict()
628
+ return { state: emptyAuditState(), diagnostics: diag.getDiagnostics() }
629
+ }
630
+
631
+ if (Array.isArray(parsed)) {
632
+ const rawItems = parsed as unknown[]
633
+ const normalized = rawItems
634
+ .filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
635
+ .map((item) => normalizeRawFinding(item))
636
+ const validFindings = normalized
637
+ .filter(hasMinimumFindingFields)
638
+ .map((f) => normalizeFinding(f as Record<string, unknown>))
639
+ emitDropDiagnosticsForFindings(rawItems, normalized, validFindings, diag)
640
+ diag.throwIfStrict()
641
+ return { state: emptyAuditState(validFindings), diagnostics: diag.getDiagnostics() }
642
+ }
643
+
644
+ if (
645
+ typeof parsed === "object" &&
646
+ parsed !== null &&
647
+ Array.isArray((parsed as AuditState).findings)
648
+ ) {
649
+ const auditStateObj = parsed as AuditState
650
+ const rawFindings = auditStateObj.findings as unknown[]
651
+ const normalized = rawFindings
652
+ .filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
653
+ .map((item) => normalizeRawFinding(item))
654
+ const validFindings = normalized
655
+ .filter(hasMinimumFindingFields)
656
+ .map((f) => normalizeFinding(f as Record<string, unknown>))
657
+ emitDropDiagnosticsForFindings(rawFindings, normalized, validFindings, diag)
658
+ diag.throwIfStrict()
659
+ return {
660
+ state: { ...emptyAuditState(), ...auditStateObj, findings: validFindings },
661
+ diagnostics: diag.getDiagnostics(),
662
+ }
663
+ }
664
+
665
+ return { state: emptyAuditState(), diagnostics: diag.getDiagnostics() }
666
+ }
667
+
182
668
  function normalizeTitle(check: string): string {
183
669
  if (!check || typeof check !== "string") return "Unknown Check"
184
670
  return check
@@ -250,6 +736,146 @@ function genericRecommendation(severity: FindingSeverity): string {
250
736
  return "Track and resolve during routine code quality and documentation improvements."
251
737
  }
252
738
 
739
+ function getExtendedFinding(finding: Finding): Finding & ReportFindingFields {
740
+ return finding as Finding & ReportFindingFields
741
+ }
742
+
743
+ function getFindingImpact(finding: Finding): string {
744
+ const extended = getExtendedFinding(finding)
745
+ if (typeof extended.impact === "string" && extended.impact.trim().length > 0) {
746
+ return extended.impact.trim()
747
+ }
748
+ return MISSING_IMPACT_TEXT
749
+ }
750
+
751
+ function getFindingRecommendation(finding: Finding): string {
752
+ const extended = getExtendedFinding(finding)
753
+ if (typeof extended.recommendation === "string" && extended.recommendation.trim().length > 0) {
754
+ return extended.recommendation.trim()
755
+ }
756
+ if (typeof finding.remediation === "string" && finding.remediation.trim().length > 0) {
757
+ return finding.remediation.trim()
758
+ }
759
+ return MISSING_RECOMMENDATION_TEXT
760
+ }
761
+
762
+ function getPocEvidence(finding: Finding): string | undefined {
763
+ const extended = getExtendedFinding(finding)
764
+ if (typeof extended.proofOfConcept === "string" && extended.proofOfConcept.trim().length > 0) {
765
+ return extended.proofOfConcept.trim()
766
+ }
767
+ if (typeof finding.exploitReference === "string" && finding.exploitReference.trim().length > 0) {
768
+ return finding.exploitReference.trim()
769
+ }
770
+ return undefined
771
+ }
772
+
773
+ function compareFindingsDeterministically(a: Finding, b: Finding): number {
774
+ const severityDelta = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]
775
+ if (severityDelta !== 0) return severityDelta
776
+
777
+ const fileDelta = a.file.localeCompare(b.file)
778
+ if (fileDelta !== 0) return fileDelta
779
+
780
+ const lineDelta = (a.lines[0] ?? 0) - (b.lines[0] ?? 0)
781
+ if (lineDelta !== 0) return lineDelta
782
+
783
+ return a.id.localeCompare(b.id)
784
+ }
785
+
786
+ function sortFindingsDeterministically(findings: Finding[]): Finding[] {
787
+ return [...findings].sort(compareFindingsDeterministically)
788
+ }
789
+
790
+ export function validateReportQuality(
791
+ findings: Finding[],
792
+ policy: QualityGatePolicy,
793
+ ): ReportQualityValidation {
794
+ const violations: ReportQualityViolation[] = []
795
+
796
+ for (const finding of findings) {
797
+ const findingId = finding.id
798
+ const impact = getFindingImpact(finding)
799
+ const recommendation = getFindingRecommendation(finding)
800
+ const severity = finding.severity
801
+
802
+ if (!finding.id || !finding.check || !finding.file || !Array.isArray(finding.lines)) {
803
+ violations.push({
804
+ findingId,
805
+ code: "schema.missing-required",
806
+ message: "Finding is missing required fields for deterministic report rendering.",
807
+ })
808
+ }
809
+
810
+ if (!finding.description || finding.description.trim().length === 0) {
811
+ violations.push({
812
+ findingId,
813
+ code: "completeness.missing-description",
814
+ message: "Finding description must be non-empty.",
815
+ })
816
+ }
817
+
818
+ if (!finding.source || finding.source.trim().length === 0) {
819
+ violations.push({
820
+ findingId,
821
+ code: "provenance.missing-source",
822
+ message: "Finding source is required for provenance traceability.",
823
+ })
824
+ }
825
+
826
+ if (severity !== "Critical" && severity !== "High") {
827
+ continue
828
+ }
829
+
830
+ if (
831
+ impact.length === 0 ||
832
+ impact === MISSING_IMPACT_TEXT ||
833
+ impact === genericImpact(severity)
834
+ ) {
835
+ violations.push({
836
+ findingId,
837
+ code: "severity-justification.missing-impact",
838
+ message: `${severity} findings must include specific non-generic impact details.`,
839
+ })
840
+ }
841
+
842
+ if (
843
+ recommendation.length === 0 ||
844
+ recommendation === MISSING_RECOMMENDATION_TEXT ||
845
+ recommendation === genericRecommendation(severity)
846
+ ) {
847
+ violations.push({
848
+ findingId,
849
+ code: "severity-justification.missing-recommendation",
850
+ message: `${severity} findings must include specific non-generic recommendations.`,
851
+ })
852
+ }
853
+
854
+ if (getPocEvidence(finding) == null) {
855
+ violations.push({
856
+ findingId,
857
+ code: "severity-justification.missing-poc",
858
+ message: `${severity} findings must satisfy PoC policy with exploitReference or proofOfConcept.`,
859
+ })
860
+ }
861
+ }
862
+
863
+ if (policy === "warn" && violations.length > 0) {
864
+ const logger = createLogger()
865
+ logger.warn(`[report-generator] quality gates failed with ${violations.length} violation(s)`)
866
+ for (const violation of violations) {
867
+ logger.warn(
868
+ `[report-generator] [${violation.code}] finding=${violation.findingId}: ${violation.message}`,
869
+ )
870
+ }
871
+ }
872
+
873
+ return {
874
+ passed: violations.length === 0,
875
+ violations,
876
+ }
877
+ }
878
+
253
879
  function buildRecommendations(counts: FindingsCount): string[] {
254
880
  const items: string[] = []
255
881
 
@@ -300,7 +926,8 @@ function buildFindingsSection(findings: Finding[]): string {
300
926
  const prefix = SEVERITY_PREFIX[severity]
301
927
  const findingId = `[${prefix}-${index + 1}]`
302
928
  const title = normalizeTitle(finding.check)
303
- const recommendation = finding.remediation ?? genericRecommendation(severity)
929
+ const recommendation = getFindingRecommendation(finding)
930
+ const impact = getFindingImpact(finding)
304
931
 
305
932
  lines.push(`### ${findingId} ${title}`)
306
933
  lines.push(`**Severity**: ${finding.severity}`)
@@ -309,9 +936,14 @@ function buildFindingsSection(findings: Finding[]): string {
309
936
  lines.push("")
310
937
  lines.push(`**Description**: ${finding.description}`)
311
938
  lines.push("")
312
- lines.push(`**Impact**: ${genericImpact(finding.severity)}`)
939
+ lines.push(`**Impact**: ${impact}`)
313
940
  lines.push("")
314
941
  lines.push(`**Recommendation**: ${recommendation}`)
942
+ const pocEvidence = getPocEvidence(finding)
943
+ if (pocEvidence) {
944
+ lines.push("")
945
+ lines.push(`**PoC / Evidence**: ${pocEvidence}`)
946
+ }
315
947
  lines.push("")
316
948
  })
317
949
  }
@@ -331,7 +963,7 @@ export function buildProvenanceAppendix(
331
963
  ): string {
332
964
  const lines: string[] = ["## Appendix: Data Provenance"]
333
965
 
334
- lines.push("- Data source: `audit_state` payload")
966
+ lines.push("- Data source: `report_input` payload (legacy `audit_state` supported via adapter)")
335
967
  lines.push(`- Severity threshold applied: ${threshold}`)
336
968
  lines.push(`- Findings included in report: ${includedCount}`)
337
969
 
@@ -345,7 +977,11 @@ export function buildProvenanceAppendix(
345
977
  lines.push("")
346
978
  lines.push("| Source | Count |")
347
979
  lines.push("| --- | ---: |")
348
- for (const [source, count] of Object.entries(sourceCounts).sort((a, b) => b[1] - a[1])) {
980
+ for (const [source, count] of Object.entries(sourceCounts).sort((a, b) => {
981
+ const countDelta = b[1] - a[1]
982
+ if (countDelta !== 0) return countDelta
983
+ return a[0].localeCompare(b[0])
984
+ })) {
349
985
  lines.push(`| ${source} | ${count} |`)
350
986
  }
351
987
  }
@@ -428,8 +1064,19 @@ export async function executeReportGeneration(
428
1064
  ): Promise<ReportGenerationResult> {
429
1065
  const includeExecutiveSummary = args.include_executive_summary ?? true
430
1066
  const threshold = args.severity_threshold ?? "low"
431
- const state = parseAuditState(args.audit_state)
432
- const findings = state.findings.filter((finding) => shouldIncludeFinding(finding, threshold))
1067
+ const qualityGatePolicy = args.quality_gate_policy ?? "warn"
1068
+ const { reportInput, diagnostics } = parseReportInputPayload(args, context)
1069
+ const state = reportInputToAuditState(reportInput)
1070
+ const scope = args.scope.length > 0 ? args.scope : reportInput.scope
1071
+ const findings = sortFindingsDeterministically(
1072
+ state.findings.filter((finding) => shouldIncludeFinding(finding, threshold)),
1073
+ )
1074
+ const qualityGates = validateReportQuality(findings, qualityGatePolicy)
1075
+ if (!qualityGates.passed && qualityGatePolicy === "strict-fail") {
1076
+ throw new Error(
1077
+ `Report quality gates failed: ${JSON.stringify({ passed: false, violations: qualityGates.violations })}`,
1078
+ )
1079
+ }
433
1080
  const counts = calculateCounts(findings)
434
1081
  const auditDate = new Date().toISOString().slice(0, 10)
435
1082
 
@@ -456,10 +1103,10 @@ export async function executeReportGeneration(
456
1103
 
457
1104
  sections.push("## Scope")
458
1105
  sections.push("Contracts in scope:")
459
- if (args.scope.length === 0) {
1106
+ if (scope.length === 0) {
460
1107
  sections.push("- None provided")
461
1108
  } else {
462
- for (const contract of args.scope) {
1109
+ for (const contract of scope) {
463
1110
  sections.push(`- ${contract}`)
464
1111
  }
465
1112
  }
@@ -472,7 +1119,7 @@ export async function executeReportGeneration(
472
1119
  sections.push("- Pattern Analysis")
473
1120
  sections.push("- Solodit research cross-referencing")
474
1121
  sections.push(
475
- "Approach: Findings were normalized, deduplicated by detector signature and location, then prioritized by severity and confidence.",
1122
+ "Approach: Findings are normalized, deterministically ordered by severity/file/line, and validated against report quality gates before emission.",
476
1123
  )
477
1124
 
478
1125
  sections.push(buildFindingsSection(findings))
@@ -484,14 +1131,28 @@ export async function executeReportGeneration(
484
1131
 
485
1132
  sections.push(buildProvenanceAppendix(state, threshold, findings.length))
486
1133
 
1134
+ // Embed report metadata for single-writer policy enforcement
1135
+ const runId = reportInput.run_id || state.sessionId || ""
1136
+ if (runId) {
1137
+ sections.push(buildReportMetadataComment(runId))
1138
+ }
1139
+
487
1140
  const reportMarkdown = sections.join("\n\n")
488
- const safeName = args.project_name.replace(/[^a-zA-Z0-9-_]/g, "-")
489
- const diskFilename = `${safeName}-${Date.now()}.md`
1141
+ const contentHash = stableHash(reportMarkdown)
1142
+ const { filename: canonicalFilename } = resolveReportPath({
1143
+ contractName: args.project_name,
1144
+ date: new Date(auditDate),
1145
+ outputDir: ".opencode/reports/",
1146
+ runId: runId || undefined,
1147
+ })
490
1148
 
491
1149
  const result: ReportGenerationResult = {
492
1150
  report: reportMarkdown,
493
1151
  findingsCount: counts,
494
- filename: `${args.project_name}-audit-report-${auditDate}.md`,
1152
+ filename: canonicalFilename,
1153
+ contentHash,
1154
+ qualityGates,
1155
+ contractDiagnostics: diagnostics,
495
1156
  }
496
1157
 
497
1158
  try {
@@ -499,7 +1160,17 @@ export async function executeReportGeneration(
499
1160
  const projectDir = resolveProjectDir(context)
500
1161
  const config = loadConfig(projectDir)
501
1162
  const outputDir = config.reporting?.output_dir ?? ".opencode/reports/"
502
- const fullPath = path.join(projectDir, outputDir, diskFilename)
1163
+ const fullPath = path.join(projectDir, outputDir, canonicalFilename)
1164
+
1165
+ // Single-writer policy: check for duplicate writes with same run_id
1166
+ if (runId) {
1167
+ const duplicateError = await checkDuplicateWrite(fullPath, runId)
1168
+ if (duplicateError) {
1169
+ result.error = duplicateError
1170
+ return result
1171
+ }
1172
+ }
1173
+
503
1174
  await Bun.write(fullPath, reportMarkdown)
504
1175
  result.filePath = fullPath
505
1176
  } catch (err: unknown) {
@@ -513,7 +1184,7 @@ export async function executeReportGeneration(
513
1184
 
514
1185
  export const reportGeneratorTool = tool({
515
1186
  description:
516
- "Generate a professional markdown security audit report from serialized findings and audit context.",
1187
+ "Generate a professional markdown security audit report from versioned ReportInput payloads with legacy audit_state compatibility.",
517
1188
  args: {
518
1189
  project_name: tool.schema.string(),
519
1190
  scope: tool.schema.array(tool.schema.string()),
@@ -521,7 +1192,8 @@ export const reportGeneratorTool = tool({
521
1192
  severity_threshold: tool.schema
522
1193
  .enum(["critical", "high", "medium", "low", "informational"])
523
1194
  .default("low"),
524
- audit_state: tool.schema.string(),
1195
+ report_input: tool.schema.string().optional(),
1196
+ audit_state: tool.schema.string().optional(),
525
1197
  },
526
1198
  async execute(args, context) {
527
1199
  const result = await executeReportGeneration(args, context)