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.
- package/package.json +1 -1
- package/src/agents/argus-prompt.ts +21 -8
- package/src/agents/scribe-prompt.ts +9 -26
- package/src/cli/index.ts +0 -0
- package/src/config/schema.ts +5 -0
- package/src/create-hooks.ts +81 -20
- package/src/features/migration/index.ts +14 -0
- package/src/features/migration/migration-adapter.ts +151 -0
- package/src/features/migration/parity-telemetry.ts +133 -0
- package/src/features/persistent-state/event-sink.ts +171 -0
- package/src/features/persistent-state/index.ts +2 -0
- package/src/features/persistent-state/run-finalizer.ts +175 -0
- package/src/features/persistent-state/run-journal.ts +1 -1
- package/src/hooks/agent-tracker.ts +15 -0
- package/src/hooks/event-hook.ts +93 -1
- package/src/hooks/tool-tracking-hook.ts +263 -33
- package/src/shared/audit-artifact-resolver.ts +74 -0
- package/src/shared/drop-diagnostics.ts +108 -0
- package/src/shared/index.ts +14 -0
- package/src/shared/report-path-resolver.ts +70 -0
- package/src/solodit-lifecycle.ts +86 -7
- package/src/state/adapters.ts +262 -0
- package/src/state/index.ts +15 -0
- package/src/state/projectors.ts +437 -0
- package/src/state/schemas.ts +356 -0
- package/src/tools/report-generator-tool.ts +692 -20
- package/src/tools/solodit-search-tool.ts +11 -24
- package/src/utils/solodit-health.ts +18 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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**: ${
|
|
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`
|
|
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) =>
|
|
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
|
|
432
|
-
const
|
|
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 (
|
|
1106
|
+
if (scope.length === 0) {
|
|
460
1107
|
sections.push("- None provided")
|
|
461
1108
|
} else {
|
|
462
|
-
for (const contract of
|
|
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
|
|
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
|
|
489
|
-
const
|
|
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:
|
|
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,
|
|
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
|
|
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
|
-
|
|
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)
|