solidity-argus 0.3.3 → 0.3.5
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 +67 -8
- package/src/agents/scribe-prompt.ts +13 -5
- package/src/cli/commands/init.ts +1 -1
- package/src/cli/index.ts +0 -0
- package/src/config/schema.ts +7 -2
- package/src/create-hooks.ts +116 -27
- package/src/features/audit-enforcer/audit-enforcer.ts +31 -2
- 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/audit-state-manager.ts +28 -6
- package/src/features/persistent-state/event-sink.ts +175 -0
- package/src/features/persistent-state/findings-materializer.ts +51 -0
- package/src/features/persistent-state/index.ts +2 -0
- package/src/features/persistent-state/run-finalizer.ts +192 -0
- package/src/features/persistent-state/run-journal.ts +15 -4
- package/src/hooks/agent-tracker.ts +15 -0
- package/src/hooks/event-hook.ts +93 -1
- package/src/hooks/system-prompt-hook.ts +20 -0
- package/src/hooks/tool-tracking-hook.ts +263 -33
- package/src/shared/audit-artifact-resolver.ts +75 -0
- package/src/shared/drop-diagnostics.ts +108 -0
- package/src/shared/file-utils.ts +7 -2
- package/src/shared/index.ts +14 -0
- package/src/shared/path-root-resolver.ts +34 -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 +453 -0
- package/src/state/types.ts +6 -0
- package/src/tools/report-generator-tool.ts +647 -36
- package/src/tools/report-preflight.ts +79 -0
- package/src/tools/solodit-search-tool.ts +15 -24
- package/src/utils/solodit-health.ts +18 -0
|
@@ -1,10 +1,19 @@
|
|
|
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 { readEvents } from "../features/persistent-state/event-sink"
|
|
7
|
+
import type { DropDiagnostic, DropPolicy } from "../shared/drop-diagnostics"
|
|
8
|
+
import { createDropDiagnosticsCollector } from "../shared/drop-diagnostics"
|
|
5
9
|
import { createLogger } from "../shared/logger"
|
|
6
10
|
import { resolveProjectDir } from "../shared/project-utils"
|
|
11
|
+
import { resolveReportPath } from "../shared/report-path-resolver"
|
|
12
|
+
import { normalizeToCanonicalFinding } from "../state/adapters"
|
|
13
|
+
import { stableHash } from "../state/projectors"
|
|
14
|
+
import { type ReportInput, SCHEMA_VERSION, validateReportInput } from "../state/schemas"
|
|
7
15
|
import type { AuditState, Finding, FindingSeverity } from "../state/types"
|
|
16
|
+
import { checkReportPreflight } from "./report-preflight"
|
|
8
17
|
|
|
9
18
|
type SeverityThreshold = "critical" | "high" | "medium" | "low" | "informational"
|
|
10
19
|
|
|
@@ -13,7 +22,10 @@ type ReportGeneratorArgs = {
|
|
|
13
22
|
scope: string[]
|
|
14
23
|
include_executive_summary?: boolean
|
|
15
24
|
severity_threshold?: SeverityThreshold
|
|
16
|
-
|
|
25
|
+
quality_gate_policy?: QualityGatePolicy
|
|
26
|
+
report_input?: string
|
|
27
|
+
audit_state?: string
|
|
28
|
+
preflight_policy?: PreflightPolicy
|
|
17
29
|
}
|
|
18
30
|
|
|
19
31
|
type FindingsCount = {
|
|
@@ -28,11 +40,81 @@ export type ReportGenerationResult = {
|
|
|
28
40
|
report: string
|
|
29
41
|
findingsCount: FindingsCount
|
|
30
42
|
filename: string
|
|
43
|
+
contentHash: string
|
|
44
|
+
qualityGates: ReportQualityValidation
|
|
45
|
+
contractDiagnostics: DropDiagnostic[]
|
|
31
46
|
filePath?: string
|
|
47
|
+
error?: { code: string; message: string }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type QualityGatePolicy = "warn" | "strict-fail"
|
|
51
|
+
|
|
52
|
+
type PreflightPolicy = "warn" | "strict-fail"
|
|
53
|
+
|
|
54
|
+
type ReportQualityViolation = {
|
|
55
|
+
findingId: string
|
|
56
|
+
code: string
|
|
57
|
+
message: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type ReportQualityValidation = {
|
|
61
|
+
passed: boolean
|
|
62
|
+
violations: ReportQualityViolation[]
|
|
32
63
|
}
|
|
33
64
|
|
|
34
65
|
export type ReportGenerationDependencies = {
|
|
35
66
|
loadConfig?: (projectDir: string) => ArgusConfig
|
|
67
|
+
readEvents?: (
|
|
68
|
+
runId: string,
|
|
69
|
+
projectDir: string,
|
|
70
|
+
) => Promise<import("../state/schemas").AuditEvent[]>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const SINGLE_WRITER_POLICY_VERSION = "1.0.0"
|
|
74
|
+
|
|
75
|
+
const REPORT_METADATA_REGEX = /<!-- argus:report_metadata (.+?) -->/
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Extract the run_id from report metadata embedded as an HTML comment.
|
|
79
|
+
* Returns null if no metadata is found or run_id is missing.
|
|
80
|
+
*/
|
|
81
|
+
export function extractReportRunId(content: string): string | null {
|
|
82
|
+
const match = content.match(REPORT_METADATA_REGEX)
|
|
83
|
+
if (!match?.[1]) return null
|
|
84
|
+
try {
|
|
85
|
+
const metadata = JSON.parse(match[1])
|
|
86
|
+
return typeof metadata.run_id === "string" ? metadata.run_id : null
|
|
87
|
+
} catch {
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function buildReportMetadataComment(runId: string): string {
|
|
93
|
+
const metadata = {
|
|
94
|
+
run_id: runId,
|
|
95
|
+
policy_version: SINGLE_WRITER_POLICY_VERSION,
|
|
96
|
+
}
|
|
97
|
+
return `<!-- argus:report_metadata ${JSON.stringify(metadata)} -->`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function checkDuplicateWrite(
|
|
101
|
+
filePath: string,
|
|
102
|
+
runId: string,
|
|
103
|
+
): Promise<{ code: string; message: string } | null> {
|
|
104
|
+
if (!existsSync(filePath)) return null
|
|
105
|
+
try {
|
|
106
|
+
const existingContent = await Bun.file(filePath).text()
|
|
107
|
+
const existingRunId = extractReportRunId(existingContent)
|
|
108
|
+
if (existingRunId === runId) {
|
|
109
|
+
return {
|
|
110
|
+
code: "DUPLICATE_WRITE_ATTEMPT",
|
|
111
|
+
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.`,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// Cannot read existing file; allow write
|
|
116
|
+
}
|
|
117
|
+
return null
|
|
36
118
|
}
|
|
37
119
|
|
|
38
120
|
const SEVERITY_ORDER: FindingSeverity[] = ["Critical", "High", "Medium", "Low", "Informational"]
|
|
@@ -61,6 +143,24 @@ const FINDING_WEIGHT: Record<FindingSeverity, number> = {
|
|
|
61
143
|
Informational: 1,
|
|
62
144
|
}
|
|
63
145
|
|
|
146
|
+
const SEVERITY_RANK: Record<FindingSeverity, number> = {
|
|
147
|
+
Critical: 0,
|
|
148
|
+
High: 1,
|
|
149
|
+
Medium: 2,
|
|
150
|
+
Low: 3,
|
|
151
|
+
Informational: 4,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const MISSING_IMPACT_TEXT = "Impact details were not provided in the finding payload."
|
|
155
|
+
const MISSING_RECOMMENDATION_TEXT =
|
|
156
|
+
"Recommendation details were not provided in the finding payload."
|
|
157
|
+
|
|
158
|
+
type ReportFindingFields = {
|
|
159
|
+
impact?: string
|
|
160
|
+
recommendation?: string
|
|
161
|
+
proofOfConcept?: string
|
|
162
|
+
}
|
|
163
|
+
|
|
64
164
|
function emptyCounts(): FindingsCount {
|
|
65
165
|
return {
|
|
66
166
|
critical: 0,
|
|
@@ -252,21 +352,237 @@ function normalizeFinding(f: Record<string, unknown>): Finding {
|
|
|
252
352
|
source,
|
|
253
353
|
remediation: typeof f.remediation === "string" ? f.remediation : undefined,
|
|
254
354
|
exploitReference: typeof f.exploitReference === "string" ? f.exploitReference : undefined,
|
|
355
|
+
...(typeof f.impact === "string" ? { impact: f.impact } : {}),
|
|
356
|
+
...(typeof f.recommendation === "string" ? { recommendation: f.recommendation } : {}),
|
|
357
|
+
...(typeof f.proofOfConcept === "string" ? { proofOfConcept: f.proofOfConcept } : {}),
|
|
358
|
+
...(typeof f.proof_of_concept === "string" ? { proofOfConcept: f.proof_of_concept } : {}),
|
|
359
|
+
} as Finding
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export type ParseAuditStateOptions = {
|
|
363
|
+
dropPolicy?: DropPolicy
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export type ParseAuditStateResult = {
|
|
367
|
+
state: AuditState
|
|
368
|
+
diagnostics: DropDiagnostic[]
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
type ParseReportInputResult = {
|
|
372
|
+
reportInput: ReportInput
|
|
373
|
+
diagnostics: DropDiagnostic[]
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function diagnosticsSummary(diagnostics: DropDiagnostic[]): string {
|
|
377
|
+
return diagnostics.map((diag) => `${diag.reason.code}:${diag.reason.message}`).join("; ")
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function throwContractMismatch(message: string, diagnostics: DropDiagnostic[]): never {
|
|
381
|
+
const details = diagnosticsSummary(diagnostics)
|
|
382
|
+
const fullMessage = details.length > 0 ? `${message}. Diagnostics: ${details}` : message
|
|
383
|
+
throw new Error(fullMessage)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function reportInputToAuditState(reportInput: ReportInput): AuditState {
|
|
387
|
+
return {
|
|
388
|
+
sessionId: reportInput.session_id,
|
|
389
|
+
projectDir: reportInput.projectDir,
|
|
390
|
+
contractsReviewed: Array.from(
|
|
391
|
+
new Set(reportInput.findings.map((finding) => finding.file)),
|
|
392
|
+
).sort((a, b) => a.localeCompare(b)),
|
|
393
|
+
findings: reportInput.findings,
|
|
394
|
+
toolsExecuted: reportInput.toolsExecuted,
|
|
395
|
+
currentPhase: "complete",
|
|
396
|
+
scope: reportInput.scope,
|
|
397
|
+
startTime: 0,
|
|
398
|
+
soloditResults: reportInput.soloditResults,
|
|
399
|
+
fuzzCounterexamples: reportInput.fuzzCounterexamples,
|
|
400
|
+
coverageReport: reportInput.coverageReport,
|
|
401
|
+
gasHotspots: reportInput.gasHotspots,
|
|
402
|
+
proxyContracts: reportInput.proxyContracts,
|
|
403
|
+
patternVersion: reportInput.patternVersion,
|
|
404
|
+
skillsLoaded: reportInput.skillsLoaded,
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function buildLegacyCompatibleReportInput(
|
|
409
|
+
state: AuditState,
|
|
410
|
+
context: ToolContext,
|
|
411
|
+
diagnostics: ReturnType<typeof createDropDiagnosticsCollector>,
|
|
412
|
+
): ReportInput {
|
|
413
|
+
diagnostics.warn(
|
|
414
|
+
"REPORT_INPUT_DEPRECATED_LEGACY_PAYLOAD",
|
|
415
|
+
"Legacy audit_state payload is deprecated; pass report_input with canonical ReportInput schema.",
|
|
416
|
+
"audit_state",
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
const runId = state.sessionId || context.sessionID || "legacy-run"
|
|
420
|
+
const sessionId = state.sessionId || context.sessionID || runId
|
|
421
|
+
|
|
422
|
+
if (!state.sessionId) {
|
|
423
|
+
diagnostics.warn(
|
|
424
|
+
"REPORT_INPUT_SYNTHESIZED_SESSION",
|
|
425
|
+
"Legacy payload missing sessionId; synthesized session_id from tool context/run_id.",
|
|
426
|
+
"session_id",
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
if (!state.projectDir) {
|
|
430
|
+
diagnostics.warn(
|
|
431
|
+
"REPORT_INPUT_SYNTHESIZED_PROJECT_DIR",
|
|
432
|
+
"Legacy payload missing projectDir; synthesized projectDir from tool context.",
|
|
433
|
+
"projectDir",
|
|
434
|
+
)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const canonicalFindings = state.findings
|
|
438
|
+
.map((finding, index) => {
|
|
439
|
+
const normalized = normalizeToCanonicalFinding(finding, runId, index + 1)
|
|
440
|
+
for (const diag of normalized.diagnostics) {
|
|
441
|
+
diagnostics.warn(
|
|
442
|
+
"REPORT_INPUT_LEGACY_FINDING_NORMALIZED",
|
|
443
|
+
`[index:${index}] ${diag.message}`,
|
|
444
|
+
diag.field,
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
return normalized.data
|
|
448
|
+
})
|
|
449
|
+
.filter((finding) => finding.check.length > 0 && finding.file.length > 0)
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
run_id: runId,
|
|
453
|
+
seq: state.toolsExecuted.length + canonicalFindings.length,
|
|
454
|
+
session_id: sessionId,
|
|
455
|
+
tool_call_id: "legacy-adapter",
|
|
456
|
+
source: "report-generator-legacy-adapter",
|
|
457
|
+
schema_version: SCHEMA_VERSION,
|
|
458
|
+
projectDir: state.projectDir || resolveProjectDir(context),
|
|
459
|
+
findings: canonicalFindings,
|
|
460
|
+
toolsExecuted: state.toolsExecuted.map((toolExec) => ({
|
|
461
|
+
...toolExec,
|
|
462
|
+
run_id: runId,
|
|
463
|
+
schema_version: SCHEMA_VERSION,
|
|
464
|
+
})),
|
|
465
|
+
scope: state.scope,
|
|
466
|
+
soloditResults: state.soloditResults,
|
|
467
|
+
fuzzCounterexamples: state.fuzzCounterexamples,
|
|
468
|
+
coverageReport: state.coverageReport,
|
|
469
|
+
gasHotspots: state.gasHotspots,
|
|
470
|
+
proxyContracts: state.proxyContracts,
|
|
471
|
+
patternVersion: state.patternVersion,
|
|
472
|
+
skillsLoaded: state.skillsLoaded,
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function parseReportInputPayload(
|
|
477
|
+
args: ReportGeneratorArgs,
|
|
478
|
+
context: ToolContext,
|
|
479
|
+
): ParseReportInputResult {
|
|
480
|
+
const diagnostics = createDropDiagnosticsCollector(
|
|
481
|
+
"warn",
|
|
482
|
+
"report-generator",
|
|
483
|
+
"argus_generate_report",
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
if (typeof args.report_input === "string" && args.report_input.trim().length > 0) {
|
|
487
|
+
let parsed: unknown
|
|
488
|
+
try {
|
|
489
|
+
parsed = JSON.parse(args.report_input)
|
|
490
|
+
} catch {
|
|
491
|
+
diagnostics.error(
|
|
492
|
+
"REPORT_INPUT_MALFORMED_JSON",
|
|
493
|
+
"report_input is not valid JSON. Expected serialized ReportInput object.",
|
|
494
|
+
"report_input",
|
|
495
|
+
)
|
|
496
|
+
throwContractMismatch(
|
|
497
|
+
"ReportInput contract mismatch: malformed report_input JSON",
|
|
498
|
+
diagnostics.getDiagnostics(),
|
|
499
|
+
)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const validation = validateReportInput(parsed)
|
|
503
|
+
if (!validation.success) {
|
|
504
|
+
for (const error of validation.errors) {
|
|
505
|
+
diagnostics.error(
|
|
506
|
+
"REPORT_INPUT_CONTRACT_MISMATCH",
|
|
507
|
+
`${error.field}: ${error.message}`,
|
|
508
|
+
error.field,
|
|
509
|
+
)
|
|
510
|
+
}
|
|
511
|
+
throwContractMismatch(
|
|
512
|
+
"ReportInput contract mismatch: report_input failed schema validation",
|
|
513
|
+
diagnostics.getDiagnostics(),
|
|
514
|
+
)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (typeof args.audit_state === "string" && args.audit_state.trim().length > 0) {
|
|
518
|
+
diagnostics.warn(
|
|
519
|
+
"REPORT_INPUT_LEGACY_FIELD_IGNORED",
|
|
520
|
+
"Both report_input and audit_state were provided; audit_state is ignored.",
|
|
521
|
+
"audit_state",
|
|
522
|
+
)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return { reportInput: validation.data, diagnostics: diagnostics.getDiagnostics() }
|
|
255
526
|
}
|
|
527
|
+
|
|
528
|
+
if (typeof args.audit_state === "string" && args.audit_state.trim().length > 0) {
|
|
529
|
+
const legacy = parseAuditStateWithDiagnostics(args.audit_state, { dropPolicy: "warn" })
|
|
530
|
+
for (const diagnostic of legacy.diagnostics) {
|
|
531
|
+
diagnostics.warn(diagnostic.reason.code, diagnostic.reason.message, diagnostic.reason.field)
|
|
532
|
+
}
|
|
533
|
+
const reportInput = buildLegacyCompatibleReportInput(legacy.state, context, diagnostics)
|
|
534
|
+
return { reportInput, diagnostics: diagnostics.getDiagnostics() }
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
diagnostics.error(
|
|
538
|
+
"REPORT_INPUT_MISSING",
|
|
539
|
+
"Missing report_input payload. Provide report_input (preferred) or legacy audit_state for transition.",
|
|
540
|
+
"report_input",
|
|
541
|
+
)
|
|
542
|
+
throwContractMismatch(
|
|
543
|
+
"ReportInput contract mismatch: missing required payload",
|
|
544
|
+
diagnostics.getDiagnostics(),
|
|
545
|
+
)
|
|
256
546
|
}
|
|
257
547
|
|
|
258
|
-
|
|
548
|
+
function emitDropDiagnosticsForFindings(
|
|
549
|
+
rawItems: unknown[],
|
|
550
|
+
normalized: Record<string, unknown>[],
|
|
551
|
+
validFindings: Finding[],
|
|
552
|
+
diag: ReturnType<typeof createDropDiagnosticsCollector>,
|
|
553
|
+
): void {
|
|
554
|
+
const droppedCount = rawItems.length - validFindings.length
|
|
555
|
+
if (droppedCount <= 0) return
|
|
556
|
+
|
|
557
|
+
for (const item of normalized) {
|
|
558
|
+
if (hasMinimumFindingFields(item)) continue
|
|
559
|
+
const missing: string[] = []
|
|
560
|
+
if (typeof item.check !== "string" || (item.check as string).length === 0) missing.push("check")
|
|
561
|
+
if (typeof item.file !== "string") missing.push("file")
|
|
562
|
+
if (!Array.isArray(item.lines) || (item.lines as unknown[]).length !== 2) missing.push("lines")
|
|
563
|
+
diag.error(
|
|
564
|
+
"MISSING_REQUIRED_FIELD",
|
|
565
|
+
`Finding dropped: missing ${missing.join(", ") || "unknown fields"} after normalization`,
|
|
566
|
+
missing[0],
|
|
567
|
+
)
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export function parseAuditState(auditState: string, options?: ParseAuditStateOptions): AuditState {
|
|
572
|
+
const policy = options?.dropPolicy ?? "warn"
|
|
573
|
+
const diag = createDropDiagnosticsCollector(policy, "report-generator")
|
|
574
|
+
|
|
259
575
|
let parsed: unknown
|
|
260
576
|
try {
|
|
261
577
|
parsed = JSON.parse(auditState)
|
|
262
578
|
} catch {
|
|
579
|
+
diag.error("MALFORMED_JSON", "audit_state is not valid JSON")
|
|
580
|
+
diag.throwIfStrict()
|
|
263
581
|
throw new Error(
|
|
264
582
|
"audit_state is not valid JSON — expected an AuditState object or Finding[] array",
|
|
265
583
|
)
|
|
266
584
|
}
|
|
267
585
|
|
|
268
|
-
const logger = createLogger()
|
|
269
|
-
|
|
270
586
|
if (Array.isArray(parsed)) {
|
|
271
587
|
const rawItems = parsed as unknown[]
|
|
272
588
|
const normalized = rawItems
|
|
@@ -275,12 +591,8 @@ export function parseAuditState(auditState: string): AuditState {
|
|
|
275
591
|
const validFindings = normalized
|
|
276
592
|
.filter(hasMinimumFindingFields)
|
|
277
593
|
.map((f) => normalizeFinding(f as Record<string, unknown>))
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
logger.warn(
|
|
281
|
-
`parseAuditState: ${dropped}/${rawItems.length} findings dropped (missing required fields after normalization)`,
|
|
282
|
-
)
|
|
283
|
-
}
|
|
594
|
+
emitDropDiagnosticsForFindings(rawItems, normalized, validFindings, diag)
|
|
595
|
+
diag.throwIfStrict()
|
|
284
596
|
return emptyAuditState(validFindings)
|
|
285
597
|
}
|
|
286
598
|
|
|
@@ -297,12 +609,8 @@ export function parseAuditState(auditState: string): AuditState {
|
|
|
297
609
|
const validFindings = normalized
|
|
298
610
|
.filter(hasMinimumFindingFields)
|
|
299
611
|
.map((f) => normalizeFinding(f as Record<string, unknown>))
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
logger.warn(
|
|
303
|
-
`parseAuditState: ${dropped}/${rawFindings.length} findings dropped (missing required fields after normalization)`,
|
|
304
|
-
)
|
|
305
|
-
}
|
|
612
|
+
emitDropDiagnosticsForFindings(rawFindings, normalized, validFindings, diag)
|
|
613
|
+
diag.throwIfStrict()
|
|
306
614
|
return {
|
|
307
615
|
...emptyAuditState(),
|
|
308
616
|
...state,
|
|
@@ -313,6 +621,59 @@ export function parseAuditState(auditState: string): AuditState {
|
|
|
313
621
|
return emptyAuditState()
|
|
314
622
|
}
|
|
315
623
|
|
|
624
|
+
export function parseAuditStateWithDiagnostics(
|
|
625
|
+
auditState: string,
|
|
626
|
+
options?: ParseAuditStateOptions,
|
|
627
|
+
): ParseAuditStateResult {
|
|
628
|
+
const policy = options?.dropPolicy ?? "warn"
|
|
629
|
+
const diag = createDropDiagnosticsCollector(policy, "report-generator")
|
|
630
|
+
|
|
631
|
+
let parsed: unknown
|
|
632
|
+
try {
|
|
633
|
+
parsed = JSON.parse(auditState)
|
|
634
|
+
} catch {
|
|
635
|
+
diag.error("MALFORMED_JSON", "audit_state is not valid JSON")
|
|
636
|
+
diag.throwIfStrict()
|
|
637
|
+
return { state: emptyAuditState(), diagnostics: diag.getDiagnostics() }
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (Array.isArray(parsed)) {
|
|
641
|
+
const rawItems = parsed as unknown[]
|
|
642
|
+
const normalized = rawItems
|
|
643
|
+
.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
|
|
644
|
+
.map((item) => normalizeRawFinding(item))
|
|
645
|
+
const validFindings = normalized
|
|
646
|
+
.filter(hasMinimumFindingFields)
|
|
647
|
+
.map((f) => normalizeFinding(f as Record<string, unknown>))
|
|
648
|
+
emitDropDiagnosticsForFindings(rawItems, normalized, validFindings, diag)
|
|
649
|
+
diag.throwIfStrict()
|
|
650
|
+
return { state: emptyAuditState(validFindings), diagnostics: diag.getDiagnostics() }
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (
|
|
654
|
+
typeof parsed === "object" &&
|
|
655
|
+
parsed !== null &&
|
|
656
|
+
Array.isArray((parsed as AuditState).findings)
|
|
657
|
+
) {
|
|
658
|
+
const auditStateObj = parsed as AuditState
|
|
659
|
+
const rawFindings = auditStateObj.findings as unknown[]
|
|
660
|
+
const normalized = rawFindings
|
|
661
|
+
.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
|
|
662
|
+
.map((item) => normalizeRawFinding(item))
|
|
663
|
+
const validFindings = normalized
|
|
664
|
+
.filter(hasMinimumFindingFields)
|
|
665
|
+
.map((f) => normalizeFinding(f as Record<string, unknown>))
|
|
666
|
+
emitDropDiagnosticsForFindings(rawFindings, normalized, validFindings, diag)
|
|
667
|
+
diag.throwIfStrict()
|
|
668
|
+
return {
|
|
669
|
+
state: { ...emptyAuditState(), ...auditStateObj, findings: validFindings },
|
|
670
|
+
diagnostics: diag.getDiagnostics(),
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return { state: emptyAuditState(), diagnostics: diag.getDiagnostics() }
|
|
675
|
+
}
|
|
676
|
+
|
|
316
677
|
function normalizeTitle(check: string): string {
|
|
317
678
|
if (!check || typeof check !== "string") return "Unknown Check"
|
|
318
679
|
return check
|
|
@@ -384,6 +745,146 @@ function genericRecommendation(severity: FindingSeverity): string {
|
|
|
384
745
|
return "Track and resolve during routine code quality and documentation improvements."
|
|
385
746
|
}
|
|
386
747
|
|
|
748
|
+
function getExtendedFinding(finding: Finding): Finding & ReportFindingFields {
|
|
749
|
+
return finding as Finding & ReportFindingFields
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function getFindingImpact(finding: Finding): string {
|
|
753
|
+
const extended = getExtendedFinding(finding)
|
|
754
|
+
if (typeof extended.impact === "string" && extended.impact.trim().length > 0) {
|
|
755
|
+
return extended.impact.trim()
|
|
756
|
+
}
|
|
757
|
+
return MISSING_IMPACT_TEXT
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function getFindingRecommendation(finding: Finding): string {
|
|
761
|
+
const extended = getExtendedFinding(finding)
|
|
762
|
+
if (typeof extended.recommendation === "string" && extended.recommendation.trim().length > 0) {
|
|
763
|
+
return extended.recommendation.trim()
|
|
764
|
+
}
|
|
765
|
+
if (typeof finding.remediation === "string" && finding.remediation.trim().length > 0) {
|
|
766
|
+
return finding.remediation.trim()
|
|
767
|
+
}
|
|
768
|
+
return MISSING_RECOMMENDATION_TEXT
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function getPocEvidence(finding: Finding): string | undefined {
|
|
772
|
+
const extended = getExtendedFinding(finding)
|
|
773
|
+
if (typeof extended.proofOfConcept === "string" && extended.proofOfConcept.trim().length > 0) {
|
|
774
|
+
return extended.proofOfConcept.trim()
|
|
775
|
+
}
|
|
776
|
+
if (typeof finding.exploitReference === "string" && finding.exploitReference.trim().length > 0) {
|
|
777
|
+
return finding.exploitReference.trim()
|
|
778
|
+
}
|
|
779
|
+
return undefined
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function compareFindingsDeterministically(a: Finding, b: Finding): number {
|
|
783
|
+
const severityDelta = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]
|
|
784
|
+
if (severityDelta !== 0) return severityDelta
|
|
785
|
+
|
|
786
|
+
const fileDelta = a.file.localeCompare(b.file)
|
|
787
|
+
if (fileDelta !== 0) return fileDelta
|
|
788
|
+
|
|
789
|
+
const lineDelta = (a.lines[0] ?? 0) - (b.lines[0] ?? 0)
|
|
790
|
+
if (lineDelta !== 0) return lineDelta
|
|
791
|
+
|
|
792
|
+
return a.id.localeCompare(b.id)
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function sortFindingsDeterministically(findings: Finding[]): Finding[] {
|
|
796
|
+
return [...findings].sort(compareFindingsDeterministically)
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
export function validateReportQuality(
|
|
800
|
+
findings: Finding[],
|
|
801
|
+
policy: QualityGatePolicy,
|
|
802
|
+
): ReportQualityValidation {
|
|
803
|
+
const violations: ReportQualityViolation[] = []
|
|
804
|
+
|
|
805
|
+
for (const finding of findings) {
|
|
806
|
+
const findingId = finding.id
|
|
807
|
+
const impact = getFindingImpact(finding)
|
|
808
|
+
const recommendation = getFindingRecommendation(finding)
|
|
809
|
+
const severity = finding.severity
|
|
810
|
+
|
|
811
|
+
if (!finding.id || !finding.check || !finding.file || !Array.isArray(finding.lines)) {
|
|
812
|
+
violations.push({
|
|
813
|
+
findingId,
|
|
814
|
+
code: "schema.missing-required",
|
|
815
|
+
message: "Finding is missing required fields for deterministic report rendering.",
|
|
816
|
+
})
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
if (!finding.description || finding.description.trim().length === 0) {
|
|
820
|
+
violations.push({
|
|
821
|
+
findingId,
|
|
822
|
+
code: "completeness.missing-description",
|
|
823
|
+
message: "Finding description must be non-empty.",
|
|
824
|
+
})
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
if (!finding.source || finding.source.trim().length === 0) {
|
|
828
|
+
violations.push({
|
|
829
|
+
findingId,
|
|
830
|
+
code: "provenance.missing-source",
|
|
831
|
+
message: "Finding source is required for provenance traceability.",
|
|
832
|
+
})
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (severity !== "Critical" && severity !== "High") {
|
|
836
|
+
continue
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
if (
|
|
840
|
+
impact.length === 0 ||
|
|
841
|
+
impact === MISSING_IMPACT_TEXT ||
|
|
842
|
+
impact === genericImpact(severity)
|
|
843
|
+
) {
|
|
844
|
+
violations.push({
|
|
845
|
+
findingId,
|
|
846
|
+
code: "severity-justification.missing-impact",
|
|
847
|
+
message: `${severity} findings must include specific non-generic impact details.`,
|
|
848
|
+
})
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
if (
|
|
852
|
+
recommendation.length === 0 ||
|
|
853
|
+
recommendation === MISSING_RECOMMENDATION_TEXT ||
|
|
854
|
+
recommendation === genericRecommendation(severity)
|
|
855
|
+
) {
|
|
856
|
+
violations.push({
|
|
857
|
+
findingId,
|
|
858
|
+
code: "severity-justification.missing-recommendation",
|
|
859
|
+
message: `${severity} findings must include specific non-generic recommendations.`,
|
|
860
|
+
})
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
if (getPocEvidence(finding) == null) {
|
|
864
|
+
violations.push({
|
|
865
|
+
findingId,
|
|
866
|
+
code: "severity-justification.missing-poc",
|
|
867
|
+
message: `${severity} findings must satisfy PoC policy with exploitReference or proofOfConcept.`,
|
|
868
|
+
})
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (policy === "warn" && violations.length > 0) {
|
|
873
|
+
const logger = createLogger()
|
|
874
|
+
logger.warn(`[report-generator] quality gates failed with ${violations.length} violation(s)`)
|
|
875
|
+
for (const violation of violations) {
|
|
876
|
+
logger.warn(
|
|
877
|
+
`[report-generator] [${violation.code}] finding=${violation.findingId}: ${violation.message}`,
|
|
878
|
+
)
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
return {
|
|
883
|
+
passed: violations.length === 0,
|
|
884
|
+
violations,
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
387
888
|
function buildRecommendations(counts: FindingsCount): string[] {
|
|
388
889
|
const items: string[] = []
|
|
389
890
|
|
|
@@ -434,7 +935,8 @@ function buildFindingsSection(findings: Finding[]): string {
|
|
|
434
935
|
const prefix = SEVERITY_PREFIX[severity]
|
|
435
936
|
const findingId = `[${prefix}-${index + 1}]`
|
|
436
937
|
const title = normalizeTitle(finding.check)
|
|
437
|
-
const recommendation = finding
|
|
938
|
+
const recommendation = getFindingRecommendation(finding)
|
|
939
|
+
const impact = getFindingImpact(finding)
|
|
438
940
|
|
|
439
941
|
lines.push(`### ${findingId} ${title}`)
|
|
440
942
|
lines.push(`**Severity**: ${finding.severity}`)
|
|
@@ -443,9 +945,14 @@ function buildFindingsSection(findings: Finding[]): string {
|
|
|
443
945
|
lines.push("")
|
|
444
946
|
lines.push(`**Description**: ${finding.description}`)
|
|
445
947
|
lines.push("")
|
|
446
|
-
lines.push(`**Impact**: ${
|
|
948
|
+
lines.push(`**Impact**: ${impact}`)
|
|
447
949
|
lines.push("")
|
|
448
950
|
lines.push(`**Recommendation**: ${recommendation}`)
|
|
951
|
+
const pocEvidence = getPocEvidence(finding)
|
|
952
|
+
if (pocEvidence) {
|
|
953
|
+
lines.push("")
|
|
954
|
+
lines.push(`**PoC / Evidence**: ${pocEvidence}`)
|
|
955
|
+
}
|
|
449
956
|
lines.push("")
|
|
450
957
|
})
|
|
451
958
|
}
|
|
@@ -465,7 +972,7 @@ export function buildProvenanceAppendix(
|
|
|
465
972
|
): string {
|
|
466
973
|
const lines: string[] = ["## Appendix: Data Provenance"]
|
|
467
974
|
|
|
468
|
-
lines.push("- Data source: `audit_state`
|
|
975
|
+
lines.push("- Data source: `report_input` payload (legacy `audit_state` supported via adapter)")
|
|
469
976
|
lines.push(`- Severity threshold applied: ${threshold}`)
|
|
470
977
|
lines.push(`- Findings included in report: ${includedCount}`)
|
|
471
978
|
|
|
@@ -479,7 +986,11 @@ export function buildProvenanceAppendix(
|
|
|
479
986
|
lines.push("")
|
|
480
987
|
lines.push("| Source | Count |")
|
|
481
988
|
lines.push("| --- | ---: |")
|
|
482
|
-
for (const [source, count] of Object.entries(sourceCounts).sort((a, b) =>
|
|
989
|
+
for (const [source, count] of Object.entries(sourceCounts).sort((a, b) => {
|
|
990
|
+
const countDelta = b[1] - a[1]
|
|
991
|
+
if (countDelta !== 0) return countDelta
|
|
992
|
+
return a[0].localeCompare(b[0])
|
|
993
|
+
})) {
|
|
483
994
|
lines.push(`| ${source} | ${count} |`)
|
|
484
995
|
}
|
|
485
996
|
}
|
|
@@ -491,9 +1002,25 @@ export function buildProvenanceAppendix(
|
|
|
491
1002
|
lines.push("| Tool | Duration | Status | Findings |")
|
|
492
1003
|
lines.push("| --- | --- | --- | ---: |")
|
|
493
1004
|
for (const exec of state.toolsExecuted) {
|
|
494
|
-
const
|
|
495
|
-
const
|
|
496
|
-
|
|
1005
|
+
const toolName = typeof exec.tool === "string" && exec.tool ? exec.tool : "(unknown tool)"
|
|
1006
|
+
const hasTimes =
|
|
1007
|
+
typeof exec.startTime === "number" &&
|
|
1008
|
+
!Number.isNaN(exec.startTime) &&
|
|
1009
|
+
exec.endTime != null &&
|
|
1010
|
+
typeof exec.endTime === "number" &&
|
|
1011
|
+
!Number.isNaN(exec.endTime)
|
|
1012
|
+
const duration = hasTimes ? formatDuration(exec.endTime! - exec.startTime) : "N/A"
|
|
1013
|
+
const status =
|
|
1014
|
+
typeof exec.success === "boolean"
|
|
1015
|
+
? exec.success
|
|
1016
|
+
? "\u2705 success"
|
|
1017
|
+
: "\u274C failure"
|
|
1018
|
+
: "\u26A0 malformed"
|
|
1019
|
+
const findings =
|
|
1020
|
+
typeof exec.findingsCount === "number" && !Number.isNaN(exec.findingsCount)
|
|
1021
|
+
? exec.findingsCount
|
|
1022
|
+
: "N/A"
|
|
1023
|
+
lines.push(`| ${toolName} | ${duration} | ${status} | ${findings} |`)
|
|
497
1024
|
}
|
|
498
1025
|
}
|
|
499
1026
|
|
|
@@ -506,7 +1033,8 @@ export function buildProvenanceAppendix(
|
|
|
506
1033
|
lines.push(`- Pattern pack version: \`${state.patternVersion}\``)
|
|
507
1034
|
}
|
|
508
1035
|
if (syncExec) {
|
|
509
|
-
|
|
1036
|
+
const syncTime = typeof syncExec.startTime === "number" && !Number.isNaN(syncExec.startTime) ? new Date(syncExec.startTime).toISOString() : "N/A"
|
|
1037
|
+
lines.push(`- SCVD last synced: ${syncTime}`)
|
|
510
1038
|
}
|
|
511
1039
|
}
|
|
512
1040
|
|
|
@@ -562,8 +1090,61 @@ export async function executeReportGeneration(
|
|
|
562
1090
|
): Promise<ReportGenerationResult> {
|
|
563
1091
|
const includeExecutiveSummary = args.include_executive_summary ?? true
|
|
564
1092
|
const threshold = args.severity_threshold ?? "low"
|
|
565
|
-
const
|
|
566
|
-
const
|
|
1093
|
+
const qualityGatePolicy = args.quality_gate_policy ?? "warn"
|
|
1094
|
+
const { reportInput, diagnostics } = parseReportInputPayload(args, context)
|
|
1095
|
+
const preflightPolicy = args.preflight_policy ?? "warn"
|
|
1096
|
+
let preflightWarningSection: string | null = null
|
|
1097
|
+
try {
|
|
1098
|
+
const readEventsFn = deps.readEvents ?? readEvents
|
|
1099
|
+
const events = await readEventsFn(reportInput.run_id, reportInput.projectDir)
|
|
1100
|
+
const preflightResult = checkReportPreflight(events)
|
|
1101
|
+
if (!preflightResult.passed) {
|
|
1102
|
+
if (preflightPolicy === "strict-fail") {
|
|
1103
|
+
const parts: string[] = []
|
|
1104
|
+
if (preflightResult.orphanedTools.length > 0)
|
|
1105
|
+
parts.push(`orphaned tools: ${preflightResult.orphanedTools.join(", ")}`)
|
|
1106
|
+
if (preflightResult.missingLifecycle.length > 0)
|
|
1107
|
+
parts.push(`missing lifecycle: ${preflightResult.missingLifecycle.join(", ")}`)
|
|
1108
|
+
if (preflightResult.missingRequiredTools.length > 0)
|
|
1109
|
+
parts.push(`missing required tools: ${preflightResult.missingRequiredTools.join(", ")}`)
|
|
1110
|
+
throw new Error(`Preflight failed (strict-fail): ${parts.join("; ")}`)
|
|
1111
|
+
}
|
|
1112
|
+
const lines: string[] = [
|
|
1113
|
+
"## \u26A0 Completeness Warning",
|
|
1114
|
+
"",
|
|
1115
|
+
"This report was generated with incomplete orchestration state.",
|
|
1116
|
+
"",
|
|
1117
|
+
]
|
|
1118
|
+
if (preflightResult.orphanedTools.length > 0)
|
|
1119
|
+
lines.push(`- Orphaned tools: ${preflightResult.orphanedTools.join(", ")}`)
|
|
1120
|
+
if (preflightResult.missingLifecycle.length > 0)
|
|
1121
|
+
lines.push(`- Missing lifecycle: ${preflightResult.missingLifecycle.join(", ")}`)
|
|
1122
|
+
if (preflightResult.missingRequiredTools.length > 0)
|
|
1123
|
+
lines.push(`- Missing required tools: ${preflightResult.missingRequiredTools.join(", ")}`)
|
|
1124
|
+
if (preflightResult.warnings.length > 0)
|
|
1125
|
+
lines.push(`- Warnings: ${preflightResult.warnings.join(", ")}`)
|
|
1126
|
+
preflightWarningSection = lines.join("\n")
|
|
1127
|
+
}
|
|
1128
|
+
} catch (err) {
|
|
1129
|
+
if (err instanceof Error && err.message.startsWith("Preflight failed (strict-fail)")) {
|
|
1130
|
+
throw err
|
|
1131
|
+
}
|
|
1132
|
+
if (preflightPolicy === "strict-fail") {
|
|
1133
|
+
throw new Error("Preflight failed: unable to read event stream for completeness check")
|
|
1134
|
+
}
|
|
1135
|
+
// warn mode: skip preflight when events cannot be read
|
|
1136
|
+
}
|
|
1137
|
+
const state = reportInputToAuditState(reportInput)
|
|
1138
|
+
const scope = args.scope.length > 0 ? args.scope : reportInput.scope
|
|
1139
|
+
const findings = sortFindingsDeterministically(
|
|
1140
|
+
state.findings.filter((finding) => shouldIncludeFinding(finding, threshold)),
|
|
1141
|
+
)
|
|
1142
|
+
const qualityGates = validateReportQuality(findings, qualityGatePolicy)
|
|
1143
|
+
if (!qualityGates.passed && qualityGatePolicy === "strict-fail") {
|
|
1144
|
+
throw new Error(
|
|
1145
|
+
`Report quality gates failed: ${JSON.stringify({ passed: false, violations: qualityGates.violations })}`,
|
|
1146
|
+
)
|
|
1147
|
+
}
|
|
567
1148
|
const counts = calculateCounts(findings)
|
|
568
1149
|
const auditDate = new Date().toISOString().slice(0, 10)
|
|
569
1150
|
|
|
@@ -590,10 +1171,10 @@ export async function executeReportGeneration(
|
|
|
590
1171
|
|
|
591
1172
|
sections.push("## Scope")
|
|
592
1173
|
sections.push("Contracts in scope:")
|
|
593
|
-
if (
|
|
1174
|
+
if (scope.length === 0) {
|
|
594
1175
|
sections.push("- None provided")
|
|
595
1176
|
} else {
|
|
596
|
-
for (const contract of
|
|
1177
|
+
for (const contract of scope) {
|
|
597
1178
|
sections.push(`- ${contract}`)
|
|
598
1179
|
}
|
|
599
1180
|
}
|
|
@@ -606,7 +1187,7 @@ export async function executeReportGeneration(
|
|
|
606
1187
|
sections.push("- Pattern Analysis")
|
|
607
1188
|
sections.push("- Solodit research cross-referencing")
|
|
608
1189
|
sections.push(
|
|
609
|
-
"Approach: Findings
|
|
1190
|
+
"Approach: Findings are normalized, deterministically ordered by severity/file/line, and validated against report quality gates before emission.",
|
|
610
1191
|
)
|
|
611
1192
|
|
|
612
1193
|
sections.push(buildFindingsSection(findings))
|
|
@@ -616,24 +1197,52 @@ export async function executeReportGeneration(
|
|
|
616
1197
|
sections.push(`- ${item}`)
|
|
617
1198
|
}
|
|
618
1199
|
|
|
1200
|
+
if (preflightWarningSection) {
|
|
1201
|
+
sections.push(preflightWarningSection)
|
|
1202
|
+
}
|
|
1203
|
+
|
|
619
1204
|
sections.push(buildProvenanceAppendix(state, threshold, findings.length))
|
|
620
1205
|
|
|
1206
|
+
// Embed report metadata for single-writer policy enforcement
|
|
1207
|
+
const runId = reportInput.run_id || state.sessionId || ""
|
|
1208
|
+
if (runId) {
|
|
1209
|
+
sections.push(buildReportMetadataComment(runId))
|
|
1210
|
+
}
|
|
1211
|
+
|
|
621
1212
|
const reportMarkdown = sections.join("\n\n")
|
|
622
|
-
const
|
|
623
|
-
const
|
|
1213
|
+
const contentHash = stableHash(reportMarkdown)
|
|
1214
|
+
const { filename: canonicalFilename } = resolveReportPath({
|
|
1215
|
+
contractName: args.project_name,
|
|
1216
|
+
date: new Date(auditDate),
|
|
1217
|
+
outputDir: ".opencode/reports/",
|
|
1218
|
+
runId: runId || undefined,
|
|
1219
|
+
})
|
|
624
1220
|
|
|
625
1221
|
const result: ReportGenerationResult = {
|
|
626
1222
|
report: reportMarkdown,
|
|
627
1223
|
findingsCount: counts,
|
|
628
|
-
filename:
|
|
1224
|
+
filename: canonicalFilename,
|
|
1225
|
+
contentHash,
|
|
1226
|
+
qualityGates,
|
|
1227
|
+
contractDiagnostics: diagnostics,
|
|
629
1228
|
}
|
|
630
1229
|
|
|
631
1230
|
try {
|
|
632
1231
|
const loadConfig = deps.loadConfig ?? loadArgusConfig
|
|
633
1232
|
const projectDir = resolveProjectDir(context)
|
|
634
1233
|
const config = loadConfig(projectDir)
|
|
635
|
-
const outputDir = config.reporting?.output_dir ?? ".
|
|
636
|
-
const fullPath = path.join(projectDir, outputDir,
|
|
1234
|
+
const outputDir = config.reporting?.output_dir ?? ".argus/reports/"
|
|
1235
|
+
const fullPath = path.join(projectDir, outputDir, canonicalFilename)
|
|
1236
|
+
|
|
1237
|
+
// Single-writer policy: check for duplicate writes with same run_id
|
|
1238
|
+
if (runId) {
|
|
1239
|
+
const duplicateError = await checkDuplicateWrite(fullPath, runId)
|
|
1240
|
+
if (duplicateError) {
|
|
1241
|
+
result.error = duplicateError
|
|
1242
|
+
return result
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
637
1246
|
await Bun.write(fullPath, reportMarkdown)
|
|
638
1247
|
result.filePath = fullPath
|
|
639
1248
|
} catch (err: unknown) {
|
|
@@ -647,7 +1256,7 @@ export async function executeReportGeneration(
|
|
|
647
1256
|
|
|
648
1257
|
export const reportGeneratorTool = tool({
|
|
649
1258
|
description:
|
|
650
|
-
"Generate a professional markdown security audit report from
|
|
1259
|
+
"Generate a professional markdown security audit report from versioned ReportInput payloads with legacy audit_state compatibility.",
|
|
651
1260
|
args: {
|
|
652
1261
|
project_name: tool.schema.string(),
|
|
653
1262
|
scope: tool.schema.array(tool.schema.string()),
|
|
@@ -655,7 +1264,9 @@ export const reportGeneratorTool = tool({
|
|
|
655
1264
|
severity_threshold: tool.schema
|
|
656
1265
|
.enum(["critical", "high", "medium", "low", "informational"])
|
|
657
1266
|
.default("low"),
|
|
658
|
-
|
|
1267
|
+
report_input: tool.schema.string().optional(),
|
|
1268
|
+
audit_state: tool.schema.string().optional(),
|
|
1269
|
+
preflight_policy: tool.schema.enum(["warn", "strict-fail"]).optional(),
|
|
659
1270
|
},
|
|
660
1271
|
async execute(args, context) {
|
|
661
1272
|
const result = await executeReportGeneration(args, context)
|