solidity-argus 0.3.6 → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +13 -6
- package/README.md +24 -12
- package/package.json +7 -3
- package/skills/checklists/cyfrin-best-practices-runtime/SKILL.md +1 -0
- package/skills/checklists/cyfrin-best-practices-upgrades/SKILL.md +1 -0
- package/skills/checklists/cyfrin-defi-core/SKILL.md +1 -0
- package/skills/checklists/cyfrin-defi-integrations/SKILL.md +1 -0
- package/skills/checklists/cyfrin-gas/SKILL.md +1 -0
- package/skills/checklists/general-audit/SKILL.md +1 -0
- package/skills/methodology/audit-workflow/SKILL.md +1 -0
- package/skills/methodology/report-template/SKILL.md +1 -0
- package/skills/methodology/severity-classification/SKILL.md +1 -0
- package/skills/protocol-patterns/amm-dex/SKILL.md +1 -0
- package/skills/protocol-patterns/bridges-cross-chain/SKILL.md +1 -0
- package/skills/protocol-patterns/dao-governance/SKILL.md +1 -0
- package/skills/protocol-patterns/lending-borrowing/SKILL.md +1 -0
- package/skills/protocol-patterns/staking-vesting/SKILL.md +1 -0
- package/skills/vulnerability-patterns/flash-loan-attacks/SKILL.md +0 -50
- package/skills/vulnerability-patterns/oracle-manipulation/SKILL.md +0 -63
- package/src/agents/argus-prompt.ts +98 -33
- package/src/agents/pythia-prompt.ts +18 -1
- package/src/agents/scribe-prompt.ts +32 -10
- package/src/agents/sentinel-prompt.ts +19 -0
- package/src/agents/themis-prompt.ts +110 -0
- package/src/cli/commands/doctor.ts +29 -17
- package/src/config/loader.ts +29 -5
- package/src/config/schema.ts +45 -45
- package/src/constants/defaults.ts +1 -0
- package/src/create-hooks.ts +851 -142
- package/src/create-managers.ts +4 -2
- package/src/create-tools.ts +5 -1
- package/src/features/audit-enforcer/audit-enforcer.ts +1 -11
- package/src/features/background-agent/background-manager.ts +32 -5
- package/src/features/error-recovery/tool-error-recovery.ts +1 -0
- package/src/features/persistent-state/audit-state-manager.ts +272 -29
- package/src/features/persistent-state/event-sink.ts +96 -25
- package/src/features/persistent-state/findings-materializer.ts +57 -3
- package/src/features/persistent-state/global-run-index.ts +86 -8
- package/src/features/persistent-state/index.ts +7 -1
- package/src/features/persistent-state/run-finalizer.ts +116 -7
- package/src/features/persistent-state/run-pruner.ts +93 -0
- package/src/hooks/agent-tracker.ts +14 -2
- package/src/hooks/compaction-hook.ts +7 -16
- package/src/hooks/config-handler.ts +83 -29
- package/src/hooks/context-budget.ts +4 -5
- package/src/hooks/event-hook.ts +213 -57
- package/src/hooks/knowledge-sync-hook.ts +2 -3
- package/src/hooks/safe-create-hook.ts +13 -1
- package/src/hooks/system-prompt-hook.ts +20 -39
- package/src/hooks/tool-tracking-hook.ts +606 -326
- package/src/index.ts +15 -1
- package/src/knowledge/scvd-client.ts +2 -4
- package/src/knowledge/scvd-errors.ts +25 -2
- package/src/knowledge/scvd-index.ts +7 -5
- package/src/knowledge/scvd-sync.ts +6 -6
- package/src/managers/types.ts +20 -2
- package/src/shared/agent-names.ts +23 -0
- package/src/shared/audit-artifact-resolver.ts +8 -3
- package/src/shared/audit-phases.ts +12 -0
- package/src/shared/cache-paths.ts +41 -0
- package/src/shared/drop-diagnostics.ts +2 -2
- package/src/shared/forge-errors.ts +31 -0
- package/src/shared/forge-runner.ts +30 -0
- package/src/shared/format-error.ts +3 -0
- package/src/shared/index.ts +9 -0
- package/src/shared/key-tools.ts +39 -0
- package/src/shared/logger.ts +7 -7
- package/src/shared/path-containment.ts +25 -0
- package/src/shared/path-utils.ts +11 -0
- package/src/shared/report-path-resolver.ts +4 -2
- package/src/shared/safe-emit.ts +24 -0
- package/src/shared/token-utils.ts +5 -0
- package/src/shared/type-guards.ts +8 -0
- package/src/shared/validation-constants.ts +52 -0
- package/src/skills/analysis/cluster.ts +1 -114
- package/src/skills/analysis/normalize.ts +2 -114
- package/src/skills/analysis/stopwords.ts +109 -0
- package/src/skills/argus-skill-resolver.ts +6 -3
- package/src/solodit-lifecycle.ts +153 -37
- package/src/state/adapters.ts +60 -66
- package/src/state/finding-aggregation.ts +6 -8
- package/src/state/finding-fingerprint.ts +1 -1
- package/src/state/finding-store.ts +31 -9
- package/src/state/index.ts +1 -1
- package/src/state/projectors.ts +27 -19
- package/src/state/schemas.ts +8 -32
- package/src/state/types.ts +3 -0
- package/src/tools/contract-analyzer-tool.ts +4 -6
- package/src/tools/forge-coverage-tool.ts +10 -35
- package/src/tools/forge-fuzz-tool.ts +21 -51
- package/src/tools/forge-test-tool.ts +25 -47
- package/src/tools/gas-analysis-tool.ts +12 -41
- package/src/tools/pattern-checker-tool.ts +37 -15
- package/src/tools/pattern-loader.ts +18 -4
- package/src/tools/persist-deduped-tool.ts +94 -0
- package/src/tools/proxy-detection-tool.ts +35 -34
- package/src/tools/read-findings-tool.ts +390 -0
- package/src/tools/record-finding-tool.ts +120 -25
- package/src/tools/report-generator-tool.ts +396 -328
- package/src/tools/report-preflight.ts +5 -1
- package/src/tools/slither-tool.ts +55 -16
- package/src/tools/solodit-search-tool.ts +260 -112
- package/src/tools/sync-knowledge-tool.ts +2 -3
- package/src/utils/solidity-parser.ts +39 -24
- package/src/features/migration/index.ts +0 -14
- package/src/features/migration/migration-adapter.ts +0 -151
- package/src/features/migration/parity-telemetry.ts +0 -133
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
import { existsSync } from "node:fs"
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
|
|
2
2
|
import path from "node:path"
|
|
3
3
|
import { type ToolContext, tool } from "@opencode-ai/plugin"
|
|
4
4
|
import { loadArgusConfig } from "../config/loader"
|
|
5
5
|
import type { ArgusConfig } from "../config/types"
|
|
6
6
|
import { readEvents } from "../features/persistent-state/event-sink"
|
|
7
|
-
import
|
|
7
|
+
import { resolveRunIdFromOpencodeSession } from "../features/persistent-state/global-run-index"
|
|
8
|
+
import { createAuditArtifactResolver } from "../shared/audit-artifact-resolver"
|
|
9
|
+
import type { DropDiagnostic } from "../shared/drop-diagnostics"
|
|
8
10
|
import { createDropDiagnosticsCollector } from "../shared/drop-diagnostics"
|
|
11
|
+
import { computeMissingKeyTools } from "../shared/key-tools"
|
|
9
12
|
import { createLogger } from "../shared/logger"
|
|
10
13
|
import { resolveProjectDir } from "../shared/project-utils"
|
|
11
14
|
import { resolveReportPath } from "../shared/report-path-resolver"
|
|
12
|
-
import {
|
|
15
|
+
import { isNonEmptyString } from "../shared/type-guards"
|
|
16
|
+
import { SEVERITY_RANK } from "../shared/validation-constants"
|
|
13
17
|
import {
|
|
14
18
|
compareIssueFingerprintSets,
|
|
15
19
|
dedupeFindingsForFinalOutput,
|
|
@@ -21,6 +25,8 @@ import { checkReportPreflight } from "./report-preflight"
|
|
|
21
25
|
|
|
22
26
|
type SeverityThreshold = "critical" | "high" | "medium" | "low" | "informational"
|
|
23
27
|
|
|
28
|
+
type ToolCoveragePolicy = "enforce" | "warn" | "skip"
|
|
29
|
+
|
|
24
30
|
type ReportGeneratorArgs = {
|
|
25
31
|
project_name: string
|
|
26
32
|
scope: string[]
|
|
@@ -28,8 +34,9 @@ type ReportGeneratorArgs = {
|
|
|
28
34
|
severity_threshold?: SeverityThreshold
|
|
29
35
|
quality_gate_policy?: QualityGatePolicy
|
|
30
36
|
report_input?: string
|
|
31
|
-
audit_state?: string
|
|
32
37
|
preflight_policy?: PreflightPolicy
|
|
38
|
+
tool_coverage_policy?: ToolCoveragePolicy
|
|
39
|
+
run_id?: string
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
type FindingsCount = {
|
|
@@ -44,6 +51,7 @@ export type ReportGenerationResult = {
|
|
|
44
51
|
report: string
|
|
45
52
|
findingsCount: FindingsCount
|
|
46
53
|
filename: string
|
|
54
|
+
run_id: string
|
|
47
55
|
contentHash: string
|
|
48
56
|
qualityGates: ReportQualityValidation
|
|
49
57
|
contractDiagnostics: DropDiagnostic[]
|
|
@@ -72,6 +80,7 @@ export type ReportGenerationDependencies = {
|
|
|
72
80
|
runId: string,
|
|
73
81
|
projectDir: string,
|
|
74
82
|
) => Promise<import("../state/schemas").AuditEvent[]>
|
|
83
|
+
resolveCanonicalRunId?: (sessionId: string, projectDir: string) => string | null | undefined
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
export const SINGLE_WRITER_POLICY_VERSION = "1.0.0"
|
|
@@ -147,13 +156,8 @@ const FINDING_WEIGHT: Record<FindingSeverity, number> = {
|
|
|
147
156
|
Informational: 1,
|
|
148
157
|
}
|
|
149
158
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
High: 1,
|
|
153
|
-
Medium: 2,
|
|
154
|
-
Low: 3,
|
|
155
|
-
Informational: 4,
|
|
156
|
-
}
|
|
159
|
+
/** Sentinel for missing/unknown tool execution timestamps (schema requires startTime > 0). */
|
|
160
|
+
const UNKNOWN_TIMESTAMP_SENTINEL = 1
|
|
157
161
|
|
|
158
162
|
const MISSING_IMPACT_TEXT = "Impact details were not provided in the finding payload."
|
|
159
163
|
const MISSING_RECOMMENDATION_TEXT =
|
|
@@ -175,19 +179,6 @@ function emptyCounts(): FindingsCount {
|
|
|
175
179
|
}
|
|
176
180
|
}
|
|
177
181
|
|
|
178
|
-
function emptyAuditState(findings: Finding[] = []): AuditState {
|
|
179
|
-
return {
|
|
180
|
-
sessionId: "",
|
|
181
|
-
projectDir: "",
|
|
182
|
-
contractsReviewed: [],
|
|
183
|
-
findings,
|
|
184
|
-
toolsExecuted: [],
|
|
185
|
-
currentPhase: "complete",
|
|
186
|
-
scope: [],
|
|
187
|
-
startTime: 0,
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
182
|
/**
|
|
192
183
|
* Parse a location string like "File.sol:18-22" or "File.sol:18" into { file, lines }.
|
|
193
184
|
* Returns undefined if the string doesn't match a recognized format.
|
|
@@ -236,11 +227,15 @@ export function normalizeRawFinding(raw: Record<string, unknown>): Record<string
|
|
|
236
227
|
}
|
|
237
228
|
}
|
|
238
229
|
|
|
239
|
-
// file + lines: accept location string as alias
|
|
240
|
-
|
|
230
|
+
// file + lines: accept location string as alias.
|
|
231
|
+
// Always attempt to extract lines from location, even when file is already set.
|
|
232
|
+
// LLMs commonly provide both file and location (e.g. file="src/Vault.sol", location="Vault.sol:18-23").
|
|
233
|
+
if (typeof result.location === "string") {
|
|
241
234
|
const parsed = parseLocationString(result.location as string)
|
|
242
235
|
if (parsed) {
|
|
243
|
-
result.file
|
|
236
|
+
if (typeof result.file !== "string" || (result.file as string).length === 0) {
|
|
237
|
+
result.file = parsed.file
|
|
238
|
+
}
|
|
244
239
|
if (!Array.isArray(result.lines) || (result.lines as unknown[]).length !== 2) {
|
|
245
240
|
result.lines = parsed.lines
|
|
246
241
|
}
|
|
@@ -297,79 +292,11 @@ export function normalizeRawFinding(raw: Record<string, unknown>): Record<string
|
|
|
297
292
|
result.description = result.check
|
|
298
293
|
}
|
|
299
294
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
function hasMinimumFindingFields(
|
|
304
|
-
f: unknown,
|
|
305
|
-
): f is { check: string; file: string; lines: [number, number] } {
|
|
306
|
-
if (typeof f !== "object" || f === null) return false
|
|
307
|
-
const obj = f as Record<string, unknown>
|
|
308
|
-
return (
|
|
309
|
-
typeof obj.check === "string" &&
|
|
310
|
-
obj.check.length > 0 &&
|
|
311
|
-
typeof obj.file === "string" &&
|
|
312
|
-
Array.isArray(obj.lines) &&
|
|
313
|
-
obj.lines.length === 2
|
|
314
|
-
)
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
const VALID_SEVERITIES: ReadonlySet<string> = new Set([
|
|
318
|
-
"Critical",
|
|
319
|
-
"High",
|
|
320
|
-
"Medium",
|
|
321
|
-
"Low",
|
|
322
|
-
"Informational",
|
|
323
|
-
])
|
|
324
|
-
const VALID_SOURCES: ReadonlySet<string> = new Set([
|
|
325
|
-
"slither",
|
|
326
|
-
"manual",
|
|
327
|
-
"pattern",
|
|
328
|
-
"scvd",
|
|
329
|
-
"solodit",
|
|
330
|
-
"fuzz",
|
|
331
|
-
])
|
|
332
|
-
|
|
333
|
-
function normalizeFinding(f: Record<string, unknown>): Finding {
|
|
334
|
-
const severity =
|
|
335
|
-
typeof f.severity === "string" && VALID_SEVERITIES.has(f.severity)
|
|
336
|
-
? (f.severity as Finding["severity"])
|
|
337
|
-
: "Informational"
|
|
338
|
-
const confidence =
|
|
339
|
-
typeof f.confidence === "string" && ["High", "Medium", "Low"].includes(f.confidence)
|
|
340
|
-
? (f.confidence as Finding["confidence"])
|
|
341
|
-
: "Low"
|
|
342
|
-
const source =
|
|
343
|
-
typeof f.source === "string" && VALID_SOURCES.has(f.source)
|
|
344
|
-
? (f.source as Finding["source"])
|
|
345
|
-
: "manual"
|
|
346
|
-
const description = typeof f.description === "string" ? f.description : (f.check as string)
|
|
347
|
-
const id = typeof f.id === "string" ? f.id : `${f.check}:${f.file}:${(f.lines as number[])[0]}`
|
|
348
|
-
return {
|
|
349
|
-
id,
|
|
350
|
-
check: f.check as string,
|
|
351
|
-
severity,
|
|
352
|
-
confidence,
|
|
353
|
-
description,
|
|
354
|
-
file: f.file as string,
|
|
355
|
-
lines: f.lines as [number, number],
|
|
356
|
-
source,
|
|
357
|
-
remediation: typeof f.remediation === "string" ? f.remediation : undefined,
|
|
358
|
-
exploitReference: typeof f.exploitReference === "string" ? f.exploitReference : undefined,
|
|
359
|
-
...(typeof f.impact === "string" ? { impact: f.impact } : {}),
|
|
360
|
-
...(typeof f.recommendation === "string" ? { recommendation: f.recommendation } : {}),
|
|
361
|
-
...(typeof f.proofOfConcept === "string" ? { proofOfConcept: f.proofOfConcept } : {}),
|
|
362
|
-
...(typeof f.proof_of_concept === "string" ? { proofOfConcept: f.proof_of_concept } : {}),
|
|
363
|
-
} as Finding
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
export type ParseAuditStateOptions = {
|
|
367
|
-
dropPolicy?: DropPolicy
|
|
368
|
-
}
|
|
295
|
+
if (!Array.isArray(result.lines) || (result.lines as unknown[]).length !== 2) {
|
|
296
|
+
result.lines = [0, 0]
|
|
297
|
+
}
|
|
369
298
|
|
|
370
|
-
|
|
371
|
-
state: AuditState
|
|
372
|
-
diagnostics: DropDiagnostic[]
|
|
299
|
+
return result
|
|
373
300
|
}
|
|
374
301
|
|
|
375
302
|
type ParseReportInputResult = {
|
|
@@ -406,80 +333,193 @@ function reportInputToAuditState(reportInput: ReportInput): AuditState {
|
|
|
406
333
|
proxyContracts: reportInput.proxyContracts,
|
|
407
334
|
patternVersion: reportInput.patternVersion,
|
|
408
335
|
skillsLoaded: reportInput.skillsLoaded,
|
|
336
|
+
unavailableTools: reportInput.unavailableTools,
|
|
409
337
|
}
|
|
410
338
|
}
|
|
411
339
|
|
|
412
|
-
function
|
|
413
|
-
|
|
414
|
-
|
|
340
|
+
function normalizeToolsExecutedDefaults(
|
|
341
|
+
parsed: unknown,
|
|
342
|
+
expectedRunId: string | undefined,
|
|
415
343
|
diagnostics: ReturnType<typeof createDropDiagnosticsCollector>,
|
|
416
|
-
):
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
)
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
const
|
|
344
|
+
): void {
|
|
345
|
+
if (!parsed || typeof parsed !== "object") return
|
|
346
|
+
const obj = parsed as Record<string, unknown>
|
|
347
|
+
if (!Array.isArray(obj.toolsExecuted)) return
|
|
348
|
+
|
|
349
|
+
const runId = (typeof obj.run_id === "string" && obj.run_id) || expectedRunId || "unknown"
|
|
350
|
+
let patched = false
|
|
351
|
+
|
|
352
|
+
for (const entry of obj.toolsExecuted) {
|
|
353
|
+
if (!entry || typeof entry !== "object") continue
|
|
354
|
+
const rec = entry as Record<string, unknown>
|
|
355
|
+
if (typeof rec.startTime !== "number" || rec.startTime <= 0) {
|
|
356
|
+
rec.startTime = UNKNOWN_TIMESTAMP_SENTINEL
|
|
357
|
+
patched = true
|
|
358
|
+
}
|
|
359
|
+
if (typeof rec.success !== "boolean") {
|
|
360
|
+
rec.success = true
|
|
361
|
+
patched = true
|
|
362
|
+
}
|
|
363
|
+
if (typeof rec.findingsCount !== "number" || rec.findingsCount < 0) {
|
|
364
|
+
rec.findingsCount = 0
|
|
365
|
+
patched = true
|
|
366
|
+
}
|
|
367
|
+
if (!isNonEmptyString(rec.run_id)) {
|
|
368
|
+
rec.run_id = runId
|
|
369
|
+
patched = true
|
|
370
|
+
}
|
|
371
|
+
if (!isNonEmptyString(rec.schema_version)) {
|
|
372
|
+
rec.schema_version = SCHEMA_VERSION
|
|
373
|
+
patched = true
|
|
374
|
+
}
|
|
375
|
+
}
|
|
425
376
|
|
|
426
|
-
if (
|
|
377
|
+
if (patched) {
|
|
427
378
|
diagnostics.warn(
|
|
428
|
-
"
|
|
429
|
-
"
|
|
430
|
-
"
|
|
379
|
+
"REPORT_INPUT_TOOLS_EXECUTED_NORMALIZED",
|
|
380
|
+
"toolsExecuted entries were missing canonical fields (startTime, success, findingsCount, run_id, schema_version); defaults applied.",
|
|
381
|
+
"toolsExecuted",
|
|
431
382
|
)
|
|
432
383
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function resolveExpectedRunId(
|
|
387
|
+
args: ReportGeneratorArgs,
|
|
388
|
+
context: ToolContext,
|
|
389
|
+
deps: ReportGenerationDependencies,
|
|
390
|
+
): string | undefined {
|
|
391
|
+
// 1. Explicit run_id from LLM args (highest priority)
|
|
392
|
+
if (isNonEmptyString(args.run_id)) {
|
|
393
|
+
return args.run_id.trim()
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// 2. Global run index lookup by session ID
|
|
397
|
+
const sessionId = context.sessionID
|
|
398
|
+
const projectDir = resolveProjectDir(context)
|
|
399
|
+
if (isNonEmptyString(sessionId)) {
|
|
400
|
+
const resolveCanonicalRunId = deps.resolveCanonicalRunId ?? resolveRunIdFromOpencodeSession
|
|
401
|
+
const resolved = resolveCanonicalRunId(sessionId, projectDir)
|
|
402
|
+
if (isNonEmptyString(resolved)) {
|
|
403
|
+
return resolved
|
|
404
|
+
}
|
|
439
405
|
}
|
|
440
406
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
407
|
+
// When caller provides inline report_input, skip filesystem discovery —
|
|
408
|
+
// the caller already has their data and filesystem state may belong to a different run.
|
|
409
|
+
if (isNonEmptyString(args.report_input)) {
|
|
410
|
+
return undefined
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// 3. Per-session state files (per-session managers write to sessions/state-{sessionId}.json)
|
|
414
|
+
const STALE_STATE_TTL_MS = 24 * 60 * 60 * 1000
|
|
415
|
+
const sessionsDir = path.join(projectDir, ".argus", "sessions")
|
|
416
|
+
try {
|
|
417
|
+
const entries = readdirSync(sessionsDir)
|
|
418
|
+
const stateFiles = entries.filter((e) => e.startsWith("state-") && e.endsWith(".json"))
|
|
419
|
+
const ranked = stateFiles
|
|
420
|
+
.map((name) => {
|
|
421
|
+
const filePath = path.join(sessionsDir, name)
|
|
422
|
+
try {
|
|
423
|
+
return { name, path: filePath, mtime: statSync(filePath).mtimeMs }
|
|
424
|
+
} catch {
|
|
425
|
+
return null
|
|
426
|
+
}
|
|
427
|
+
})
|
|
428
|
+
.filter((entry): entry is NonNullable<typeof entry> => entry !== null)
|
|
429
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
430
|
+
|
|
431
|
+
for (const entry of ranked) {
|
|
432
|
+
try {
|
|
433
|
+
const stateRaw = JSON.parse(readFileSync(entry.path, "utf-8")) as Record<string, unknown>
|
|
434
|
+
const stateSessionId = stateRaw.sessionId
|
|
435
|
+
const savedAt = typeof stateRaw.savedAt === "number" ? stateRaw.savedAt : 0
|
|
436
|
+
const isFresh = Date.now() - savedAt < STALE_STATE_TTL_MS
|
|
437
|
+
if (
|
|
438
|
+
typeof stateSessionId === "string" &&
|
|
439
|
+
stateSessionId.trim().length > 0 &&
|
|
440
|
+
!stateSessionId.startsWith("ses_") &&
|
|
441
|
+
isFresh
|
|
442
|
+
) {
|
|
443
|
+
const resolver = createAuditArtifactResolver(stateSessionId, projectDir)
|
|
444
|
+
const hasArtifacts =
|
|
445
|
+
existsSync(resolver.paths().reportInputFile) || existsSync(resolver.paths().journalFile)
|
|
446
|
+
if (hasArtifacts) {
|
|
447
|
+
return stateSessionId
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
} catch {
|
|
451
|
+
/* skip unreadable session file */
|
|
450
452
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
453
|
+
}
|
|
454
|
+
} catch {
|
|
455
|
+
/* sessions dir doesn't exist */
|
|
456
|
+
}
|
|
454
457
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
458
|
+
// 4. Shared audit state (legacy fallback)
|
|
459
|
+
try {
|
|
460
|
+
const sharedStatePath = path.join(projectDir, ".argus", "argus-state.json")
|
|
461
|
+
if (existsSync(sharedStatePath)) {
|
|
462
|
+
const stateRaw = JSON.parse(readFileSync(sharedStatePath, "utf-8")) as Record<string, unknown>
|
|
463
|
+
const stateSessionId = stateRaw.sessionId
|
|
464
|
+
const savedAt = typeof stateRaw.savedAt === "number" ? stateRaw.savedAt : 0
|
|
465
|
+
const isFresh = Date.now() - savedAt < STALE_STATE_TTL_MS
|
|
466
|
+
if (
|
|
467
|
+
typeof stateSessionId === "string" &&
|
|
468
|
+
stateSessionId.trim().length > 0 &&
|
|
469
|
+
!stateSessionId.startsWith("ses_") &&
|
|
470
|
+
isFresh
|
|
471
|
+
) {
|
|
472
|
+
const resolver = createAuditArtifactResolver(stateSessionId, projectDir)
|
|
473
|
+
const hasArtifacts =
|
|
474
|
+
existsSync(resolver.paths().reportInputFile) || existsSync(resolver.paths().journalFile)
|
|
475
|
+
if (hasArtifacts) {
|
|
476
|
+
return stateSessionId
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
} catch {
|
|
481
|
+
/* fallback path */
|
|
477
482
|
}
|
|
483
|
+
|
|
484
|
+
return undefined
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function finalizeReportInputSelection(
|
|
488
|
+
reportInput: ReportInput,
|
|
489
|
+
diagnostics: ReturnType<typeof createDropDiagnosticsCollector>,
|
|
490
|
+
expectedRunId?: string,
|
|
491
|
+
): ParseReportInputResult {
|
|
492
|
+
if (reportInput.run_id.startsWith("ses_")) {
|
|
493
|
+
diagnostics.error(
|
|
494
|
+
"REPORT_INPUT_RUN_ID_MISMATCH",
|
|
495
|
+
"ReportInput run_id must be a canonical run identifier, not an OpenCode session id (ses_*).",
|
|
496
|
+
"run_id",
|
|
497
|
+
)
|
|
498
|
+
throwContractMismatch(
|
|
499
|
+
"ReportInput contract mismatch: run_id/session_id conflation detected",
|
|
500
|
+
diagnostics.getDiagnostics(),
|
|
501
|
+
)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (expectedRunId && reportInput.run_id !== expectedRunId) {
|
|
505
|
+
diagnostics.error(
|
|
506
|
+
"REPORT_INPUT_CANONICAL_RUN_MISMATCH",
|
|
507
|
+
`ReportInput run_id ${reportInput.run_id} does not match canonical run_id ${expectedRunId}.`,
|
|
508
|
+
"run_id",
|
|
509
|
+
)
|
|
510
|
+
throwContractMismatch(
|
|
511
|
+
"ReportInput contract mismatch: report_input run_id diverges from canonical run_id",
|
|
512
|
+
diagnostics.getDiagnostics(),
|
|
513
|
+
)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return { reportInput, diagnostics: diagnostics.getDiagnostics() }
|
|
478
517
|
}
|
|
479
518
|
|
|
480
519
|
function parseReportInputPayload(
|
|
481
520
|
args: ReportGeneratorArgs,
|
|
482
521
|
context: ToolContext,
|
|
522
|
+
expectedRunId: string | undefined,
|
|
483
523
|
): ParseReportInputResult {
|
|
484
524
|
const diagnostics = createDropDiagnosticsCollector(
|
|
485
525
|
"warn",
|
|
@@ -487,7 +527,7 @@ function parseReportInputPayload(
|
|
|
487
527
|
"argus_generate_report",
|
|
488
528
|
)
|
|
489
529
|
|
|
490
|
-
if (
|
|
530
|
+
if (isNonEmptyString(args.report_input)) {
|
|
491
531
|
let parsed: unknown
|
|
492
532
|
try {
|
|
493
533
|
parsed = JSON.parse(args.report_input)
|
|
@@ -503,44 +543,109 @@ function parseReportInputPayload(
|
|
|
503
543
|
)
|
|
504
544
|
}
|
|
505
545
|
|
|
546
|
+
normalizeToolsExecutedDefaults(parsed, expectedRunId, diagnostics)
|
|
547
|
+
|
|
506
548
|
const validation = validateReportInput(parsed)
|
|
507
549
|
if (!validation.success) {
|
|
508
550
|
for (const error of validation.errors) {
|
|
509
|
-
diagnostics.
|
|
510
|
-
"
|
|
551
|
+
diagnostics.warn(
|
|
552
|
+
"REPORT_INPUT_INLINE_VALIDATION_FAILED",
|
|
511
553
|
`${error.field}: ${error.message}`,
|
|
512
554
|
error.field,
|
|
513
555
|
)
|
|
514
556
|
}
|
|
515
|
-
throwContractMismatch(
|
|
516
|
-
"ReportInput contract mismatch: report_input failed schema validation",
|
|
517
|
-
diagnostics.getDiagnostics(),
|
|
518
|
-
)
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
if (typeof args.audit_state === "string" && args.audit_state.trim().length > 0) {
|
|
522
557
|
diagnostics.warn(
|
|
523
|
-
"
|
|
524
|
-
|
|
525
|
-
"
|
|
558
|
+
"REPORT_INPUT_INLINE_FALLTHROUGH",
|
|
559
|
+
`Inline report_input failed validation (${validation.errors.length} errors). Falling back to disk artifact.`,
|
|
560
|
+
"report_input",
|
|
526
561
|
)
|
|
562
|
+
} else {
|
|
563
|
+
return finalizeReportInputSelection(validation.data, diagnostics, expectedRunId)
|
|
527
564
|
}
|
|
528
|
-
|
|
529
|
-
return { reportInput: validation.data, diagnostics: diagnostics.getDiagnostics() }
|
|
530
565
|
}
|
|
531
566
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
567
|
+
const effectiveRunId =
|
|
568
|
+
(isNonEmptyString(args.run_id) ? args.run_id.trim() : undefined) ?? expectedRunId
|
|
569
|
+
|
|
570
|
+
if (isNonEmptyString(effectiveRunId)) {
|
|
571
|
+
const projectDir = resolveProjectDir(context)
|
|
572
|
+
const resolver = createAuditArtifactResolver(effectiveRunId, projectDir)
|
|
573
|
+
|
|
574
|
+
const dedupedFile = resolver.paths().dedupedFindingsFile
|
|
575
|
+
if (existsSync(dedupedFile)) {
|
|
576
|
+
try {
|
|
577
|
+
const dedupedArtifact = JSON.parse(readFileSync(dedupedFile, "utf-8")) as {
|
|
578
|
+
findings?: unknown[]
|
|
579
|
+
}
|
|
580
|
+
if (Array.isArray(dedupedArtifact.findings) && dedupedArtifact.findings.length > 0) {
|
|
581
|
+
const reportInputFile = resolver.paths().reportInputFile
|
|
582
|
+
let baseInput: Record<string, unknown> = {}
|
|
583
|
+
if (existsSync(reportInputFile)) {
|
|
584
|
+
try {
|
|
585
|
+
baseInput = JSON.parse(readFileSync(reportInputFile, "utf-8")) as Record<
|
|
586
|
+
string,
|
|
587
|
+
unknown
|
|
588
|
+
>
|
|
589
|
+
} catch {
|
|
590
|
+
/* use empty base */
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
const merged = {
|
|
594
|
+
...baseInput,
|
|
595
|
+
run_id: effectiveRunId,
|
|
596
|
+
findings: dedupedArtifact.findings,
|
|
597
|
+
}
|
|
598
|
+
const validation = validateReportInput(merged)
|
|
599
|
+
if (validation.success) {
|
|
600
|
+
return finalizeReportInputSelection(validation.data, diagnostics, expectedRunId)
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
} catch {
|
|
604
|
+
/* deduped file unreadable — fall through to report-input.json */
|
|
605
|
+
}
|
|
536
606
|
}
|
|
537
|
-
const reportInput = buildLegacyCompatibleReportInput(legacy.state, context, diagnostics)
|
|
538
|
-
return { reportInput, diagnostics: diagnostics.getDiagnostics() }
|
|
539
|
-
}
|
|
540
607
|
|
|
608
|
+
const reportInputFile = resolver.paths().reportInputFile
|
|
609
|
+
if (existsSync(reportInputFile)) {
|
|
610
|
+
diagnostics.warn(
|
|
611
|
+
"REPORT_INPUT_DISK_FALLBACK",
|
|
612
|
+
`No report_input provided; reading materialized report-input.json from disk for run ${effectiveRunId}.`,
|
|
613
|
+
"report_input",
|
|
614
|
+
)
|
|
615
|
+
let parsed: unknown
|
|
616
|
+
try {
|
|
617
|
+
parsed = JSON.parse(readFileSync(reportInputFile, "utf-8"))
|
|
618
|
+
} catch {
|
|
619
|
+
diagnostics.error(
|
|
620
|
+
"REPORT_INPUT_DISK_CORRUPT",
|
|
621
|
+
`Materialized report-input.json for run ${effectiveRunId} is not valid JSON.`,
|
|
622
|
+
"report_input",
|
|
623
|
+
)
|
|
624
|
+
throwContractMismatch(
|
|
625
|
+
"ReportInput contract mismatch: corrupted disk artifact",
|
|
626
|
+
diagnostics.getDiagnostics(),
|
|
627
|
+
)
|
|
628
|
+
}
|
|
629
|
+
const validation = validateReportInput(parsed)
|
|
630
|
+
if (!validation.success) {
|
|
631
|
+
for (const error of validation.errors) {
|
|
632
|
+
diagnostics.error(
|
|
633
|
+
"REPORT_INPUT_DISK_VALIDATION_FAILED",
|
|
634
|
+
`${error.field}: ${error.message}`,
|
|
635
|
+
error.field,
|
|
636
|
+
)
|
|
637
|
+
}
|
|
638
|
+
throwContractMismatch(
|
|
639
|
+
"ReportInput contract mismatch: disk artifact failed schema validation",
|
|
640
|
+
diagnostics.getDiagnostics(),
|
|
641
|
+
)
|
|
642
|
+
}
|
|
643
|
+
return finalizeReportInputSelection(validation.data, diagnostics, expectedRunId)
|
|
644
|
+
}
|
|
645
|
+
}
|
|
541
646
|
diagnostics.error(
|
|
542
647
|
"REPORT_INPUT_MISSING",
|
|
543
|
-
|
|
648
|
+
`Missing report_input payload. args.run_id=${args.run_id ?? "undefined"}, expectedRunId=${expectedRunId ?? "undefined"}. Provide report_input (preferred) or run_id for disk fallback.`,
|
|
544
649
|
"report_input",
|
|
545
650
|
)
|
|
546
651
|
throwContractMismatch(
|
|
@@ -549,135 +654,6 @@ function parseReportInputPayload(
|
|
|
549
654
|
)
|
|
550
655
|
}
|
|
551
656
|
|
|
552
|
-
function emitDropDiagnosticsForFindings(
|
|
553
|
-
rawItems: unknown[],
|
|
554
|
-
normalized: Record<string, unknown>[],
|
|
555
|
-
validFindings: Finding[],
|
|
556
|
-
diag: ReturnType<typeof createDropDiagnosticsCollector>,
|
|
557
|
-
): void {
|
|
558
|
-
const droppedCount = rawItems.length - validFindings.length
|
|
559
|
-
if (droppedCount <= 0) return
|
|
560
|
-
|
|
561
|
-
for (const item of normalized) {
|
|
562
|
-
if (hasMinimumFindingFields(item)) continue
|
|
563
|
-
const missing: string[] = []
|
|
564
|
-
if (typeof item.check !== "string" || (item.check as string).length === 0) missing.push("check")
|
|
565
|
-
if (typeof item.file !== "string") missing.push("file")
|
|
566
|
-
if (!Array.isArray(item.lines) || (item.lines as unknown[]).length !== 2) missing.push("lines")
|
|
567
|
-
diag.error(
|
|
568
|
-
"MISSING_REQUIRED_FIELD",
|
|
569
|
-
`Finding dropped: missing ${missing.join(", ") || "unknown fields"} after normalization`,
|
|
570
|
-
missing[0],
|
|
571
|
-
)
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
export function parseAuditState(auditState: string, options?: ParseAuditStateOptions): AuditState {
|
|
576
|
-
const policy = options?.dropPolicy ?? "warn"
|
|
577
|
-
const diag = createDropDiagnosticsCollector(policy, "report-generator")
|
|
578
|
-
|
|
579
|
-
let parsed: unknown
|
|
580
|
-
try {
|
|
581
|
-
parsed = JSON.parse(auditState)
|
|
582
|
-
} catch {
|
|
583
|
-
diag.error("MALFORMED_JSON", "audit_state is not valid JSON")
|
|
584
|
-
diag.throwIfStrict()
|
|
585
|
-
throw new Error(
|
|
586
|
-
"audit_state is not valid JSON — expected an AuditState object or Finding[] array",
|
|
587
|
-
)
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
if (Array.isArray(parsed)) {
|
|
591
|
-
const rawItems = parsed as unknown[]
|
|
592
|
-
const normalized = rawItems
|
|
593
|
-
.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
|
|
594
|
-
.map((item) => normalizeRawFinding(item))
|
|
595
|
-
const validFindings = normalized
|
|
596
|
-
.filter(hasMinimumFindingFields)
|
|
597
|
-
.map((f) => normalizeFinding(f as Record<string, unknown>))
|
|
598
|
-
emitDropDiagnosticsForFindings(rawItems, normalized, validFindings, diag)
|
|
599
|
-
diag.throwIfStrict()
|
|
600
|
-
return emptyAuditState(validFindings)
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
if (
|
|
604
|
-
typeof parsed === "object" &&
|
|
605
|
-
parsed !== null &&
|
|
606
|
-
Array.isArray((parsed as AuditState).findings)
|
|
607
|
-
) {
|
|
608
|
-
const state = parsed as AuditState
|
|
609
|
-
const rawFindings = state.findings as unknown[]
|
|
610
|
-
const normalized = rawFindings
|
|
611
|
-
.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
|
|
612
|
-
.map((item) => normalizeRawFinding(item))
|
|
613
|
-
const validFindings = normalized
|
|
614
|
-
.filter(hasMinimumFindingFields)
|
|
615
|
-
.map((f) => normalizeFinding(f as Record<string, unknown>))
|
|
616
|
-
emitDropDiagnosticsForFindings(rawFindings, normalized, validFindings, diag)
|
|
617
|
-
diag.throwIfStrict()
|
|
618
|
-
return {
|
|
619
|
-
...emptyAuditState(),
|
|
620
|
-
...state,
|
|
621
|
-
findings: validFindings,
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
return emptyAuditState()
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
export function parseAuditStateWithDiagnostics(
|
|
629
|
-
auditState: string,
|
|
630
|
-
options?: ParseAuditStateOptions,
|
|
631
|
-
): ParseAuditStateResult {
|
|
632
|
-
const policy = options?.dropPolicy ?? "warn"
|
|
633
|
-
const diag = createDropDiagnosticsCollector(policy, "report-generator")
|
|
634
|
-
|
|
635
|
-
let parsed: unknown
|
|
636
|
-
try {
|
|
637
|
-
parsed = JSON.parse(auditState)
|
|
638
|
-
} catch {
|
|
639
|
-
diag.error("MALFORMED_JSON", "audit_state is not valid JSON")
|
|
640
|
-
diag.throwIfStrict()
|
|
641
|
-
return { state: emptyAuditState(), diagnostics: diag.getDiagnostics() }
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
if (Array.isArray(parsed)) {
|
|
645
|
-
const rawItems = parsed as unknown[]
|
|
646
|
-
const normalized = rawItems
|
|
647
|
-
.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
|
|
648
|
-
.map((item) => normalizeRawFinding(item))
|
|
649
|
-
const validFindings = normalized
|
|
650
|
-
.filter(hasMinimumFindingFields)
|
|
651
|
-
.map((f) => normalizeFinding(f as Record<string, unknown>))
|
|
652
|
-
emitDropDiagnosticsForFindings(rawItems, normalized, validFindings, diag)
|
|
653
|
-
diag.throwIfStrict()
|
|
654
|
-
return { state: emptyAuditState(validFindings), diagnostics: diag.getDiagnostics() }
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
if (
|
|
658
|
-
typeof parsed === "object" &&
|
|
659
|
-
parsed !== null &&
|
|
660
|
-
Array.isArray((parsed as AuditState).findings)
|
|
661
|
-
) {
|
|
662
|
-
const auditStateObj = parsed as AuditState
|
|
663
|
-
const rawFindings = auditStateObj.findings as unknown[]
|
|
664
|
-
const normalized = rawFindings
|
|
665
|
-
.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
|
|
666
|
-
.map((item) => normalizeRawFinding(item))
|
|
667
|
-
const validFindings = normalized
|
|
668
|
-
.filter(hasMinimumFindingFields)
|
|
669
|
-
.map((f) => normalizeFinding(f as Record<string, unknown>))
|
|
670
|
-
emitDropDiagnosticsForFindings(rawFindings, normalized, validFindings, diag)
|
|
671
|
-
diag.throwIfStrict()
|
|
672
|
-
return {
|
|
673
|
-
state: { ...emptyAuditState(), ...auditStateObj, findings: validFindings },
|
|
674
|
-
diagnostics: diag.getDiagnostics(),
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
return { state: emptyAuditState(), diagnostics: diag.getDiagnostics() }
|
|
679
|
-
}
|
|
680
|
-
|
|
681
657
|
function normalizeTitle(check: string): string {
|
|
682
658
|
if (!check || typeof check !== "string") return "Unknown Check"
|
|
683
659
|
return check
|
|
@@ -755,7 +731,7 @@ function getExtendedFinding(finding: Finding): Finding & ReportFindingFields {
|
|
|
755
731
|
|
|
756
732
|
function getFindingImpact(finding: Finding): string {
|
|
757
733
|
const extended = getExtendedFinding(finding)
|
|
758
|
-
if (
|
|
734
|
+
if (isNonEmptyString(extended.impact)) {
|
|
759
735
|
return extended.impact.trim()
|
|
760
736
|
}
|
|
761
737
|
return MISSING_IMPACT_TEXT
|
|
@@ -763,10 +739,10 @@ function getFindingImpact(finding: Finding): string {
|
|
|
763
739
|
|
|
764
740
|
function getFindingRecommendation(finding: Finding): string {
|
|
765
741
|
const extended = getExtendedFinding(finding)
|
|
766
|
-
if (
|
|
742
|
+
if (isNonEmptyString(extended.recommendation)) {
|
|
767
743
|
return extended.recommendation.trim()
|
|
768
744
|
}
|
|
769
|
-
if (
|
|
745
|
+
if (isNonEmptyString(finding.remediation)) {
|
|
770
746
|
return finding.remediation.trim()
|
|
771
747
|
}
|
|
772
748
|
return MISSING_RECOMMENDATION_TEXT
|
|
@@ -774,10 +750,10 @@ function getFindingRecommendation(finding: Finding): string {
|
|
|
774
750
|
|
|
775
751
|
function getPocEvidence(finding: Finding): string | undefined {
|
|
776
752
|
const extended = getExtendedFinding(finding)
|
|
777
|
-
if (
|
|
753
|
+
if (isNonEmptyString(extended.proofOfConcept)) {
|
|
778
754
|
return extended.proofOfConcept.trim()
|
|
779
755
|
}
|
|
780
|
-
if (
|
|
756
|
+
if (isNonEmptyString(finding.exploitReference)) {
|
|
781
757
|
return finding.exploitReference.trim()
|
|
782
758
|
}
|
|
783
759
|
return undefined
|
|
@@ -972,17 +948,17 @@ function formatDuration(ms: number): string {
|
|
|
972
948
|
export function buildProvenanceAppendix(
|
|
973
949
|
state: AuditState,
|
|
974
950
|
threshold: SeverityThreshold,
|
|
975
|
-
|
|
951
|
+
reportFindings: Finding[],
|
|
976
952
|
): string {
|
|
977
953
|
const lines: string[] = ["## Appendix: Data Provenance"]
|
|
978
954
|
|
|
979
|
-
lines.push("- Data source: `report_input` payload
|
|
955
|
+
lines.push("- Data source: `report_input` payload")
|
|
980
956
|
lines.push(`- Severity threshold applied: ${threshold}`)
|
|
981
|
-
lines.push(`- Findings included in report: ${
|
|
957
|
+
lines.push(`- Findings included in report: ${reportFindings.length}`)
|
|
982
958
|
|
|
983
|
-
if (
|
|
959
|
+
if (reportFindings.length > 0) {
|
|
984
960
|
const sourceCounts: Record<string, number> = {}
|
|
985
|
-
for (const f of
|
|
961
|
+
for (const f of reportFindings) {
|
|
986
962
|
sourceCounts[f.source] = (sourceCounts[f.source] ?? 0) + 1
|
|
987
963
|
}
|
|
988
964
|
lines.push("")
|
|
@@ -1098,10 +1074,51 @@ export async function executeReportGeneration(
|
|
|
1098
1074
|
const includeExecutiveSummary = args.include_executive_summary ?? true
|
|
1099
1075
|
const threshold = args.severity_threshold ?? "low"
|
|
1100
1076
|
const qualityGatePolicy = args.quality_gate_policy ?? "warn"
|
|
1101
|
-
const
|
|
1077
|
+
const toolCoveragePolicy = args.tool_coverage_policy ?? "enforce"
|
|
1078
|
+
const expectedRunId = resolveExpectedRunId(args, context, deps)
|
|
1079
|
+
|
|
1080
|
+
// Ensure report-input.json is materialized before attempting disk lookup.
|
|
1081
|
+
// Scribe may call generate_report without calling read_findings first,
|
|
1082
|
+
// or read_findings may have materialized under a different run_id.
|
|
1083
|
+
if (typeof expectedRunId === "string" && expectedRunId.length > 0) {
|
|
1084
|
+
const projectDir = resolveProjectDir(context)
|
|
1085
|
+
const resolver = createAuditArtifactResolver(expectedRunId, projectDir)
|
|
1086
|
+
if (!existsSync(resolver.paths().reportInputFile)) {
|
|
1087
|
+
try {
|
|
1088
|
+
const { materializeReportInput } = await import(
|
|
1089
|
+
"../features/persistent-state/findings-materializer"
|
|
1090
|
+
)
|
|
1091
|
+
await materializeReportInput(expectedRunId, projectDir, context.sessionID)
|
|
1092
|
+
} catch {
|
|
1093
|
+
/* Best-effort: parseReportInputPayload will produce a clear error if the file is still missing */
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
const { reportInput, diagnostics } = parseReportInputPayload(args, context, expectedRunId)
|
|
1099
|
+
|
|
1102
1100
|
const preflightPolicy = args.preflight_policy ?? "warn"
|
|
1103
1101
|
let preflightWarningSection: string | null = null
|
|
1104
1102
|
const warningBullets: string[] = []
|
|
1103
|
+
|
|
1104
|
+
// Hard gate: refuse to generate a report if key audit tools have not been executed
|
|
1105
|
+
if (toolCoveragePolicy !== "skip") {
|
|
1106
|
+
const missingTools = computeMissingKeyTools(
|
|
1107
|
+
reportInput.toolsExecuted,
|
|
1108
|
+
reportInput.unavailableTools,
|
|
1109
|
+
)
|
|
1110
|
+
if (missingTools.length > 0) {
|
|
1111
|
+
const toolList = missingTools.join(", ")
|
|
1112
|
+
if (toolCoveragePolicy === "enforce") {
|
|
1113
|
+
throw new Error(
|
|
1114
|
+
`Tool coverage gate failed: the following key audit tools have not been executed: ${toolList}. ` +
|
|
1115
|
+
'Run the missing tools before generating a report, or pass tool_coverage_policy: "warn" to override.',
|
|
1116
|
+
)
|
|
1117
|
+
}
|
|
1118
|
+
warningBullets.push(`- Tool coverage incomplete: ${toolList} not executed`)
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1105
1122
|
try {
|
|
1106
1123
|
const readEventsFn = deps.readEvents ?? readEvents
|
|
1107
1124
|
const events = await readEventsFn(reportInput.run_id, reportInput.projectDir)
|
|
@@ -1182,7 +1199,22 @@ export async function executeReportGeneration(
|
|
|
1182
1199
|
)
|
|
1183
1200
|
}
|
|
1184
1201
|
const counts = calculateCounts(findings)
|
|
1185
|
-
|
|
1202
|
+
// Derive audit date from the run's start time for deterministic output.
|
|
1203
|
+
// Falls back to the earliest toolsExecuted timestamp, then current date as last resort.
|
|
1204
|
+
// Exclude UNKNOWN_TIMESTAMP_SENTINEL (patched-in value for missing timestamps).
|
|
1205
|
+
const runStartTime = reportInput.toolsExecuted.reduce(
|
|
1206
|
+
(earliest, exec) =>
|
|
1207
|
+
typeof exec.startTime === "number" &&
|
|
1208
|
+
exec.startTime > UNKNOWN_TIMESTAMP_SENTINEL &&
|
|
1209
|
+
exec.startTime < earliest
|
|
1210
|
+
? exec.startTime
|
|
1211
|
+
: earliest,
|
|
1212
|
+
Number.MAX_SAFE_INTEGER,
|
|
1213
|
+
)
|
|
1214
|
+
const auditDate =
|
|
1215
|
+
runStartTime < Number.MAX_SAFE_INTEGER
|
|
1216
|
+
? new Date(runStartTime).toISOString().slice(0, 10)
|
|
1217
|
+
: new Date().toISOString().slice(0, 10)
|
|
1186
1218
|
|
|
1187
1219
|
context.metadata({ title: `Generate audit report: ${args.project_name}` })
|
|
1188
1220
|
|
|
@@ -1237,10 +1269,13 @@ export async function executeReportGeneration(
|
|
|
1237
1269
|
sections.push(preflightWarningSection)
|
|
1238
1270
|
}
|
|
1239
1271
|
|
|
1240
|
-
sections.push(buildProvenanceAppendix(state, threshold, findings
|
|
1272
|
+
sections.push(buildProvenanceAppendix(state, threshold, findings))
|
|
1241
1273
|
|
|
1242
1274
|
// Embed report metadata for single-writer policy enforcement
|
|
1243
|
-
const runId = reportInput.run_id
|
|
1275
|
+
const runId = expectedRunId ?? reportInput.run_id
|
|
1276
|
+
if (runId.startsWith("ses_")) {
|
|
1277
|
+
throw new Error("Report generation requires canonical run_id; received OpenCode session id")
|
|
1278
|
+
}
|
|
1244
1279
|
if (runId) {
|
|
1245
1280
|
sections.push(buildReportMetadataComment(runId))
|
|
1246
1281
|
}
|
|
@@ -1258,6 +1293,7 @@ export async function executeReportGeneration(
|
|
|
1258
1293
|
report: reportMarkdown,
|
|
1259
1294
|
findingsCount: counts,
|
|
1260
1295
|
filename: canonicalFilename,
|
|
1296
|
+
run_id: runId,
|
|
1261
1297
|
contentHash,
|
|
1262
1298
|
qualityGates,
|
|
1263
1299
|
contractDiagnostics: diagnostics,
|
|
@@ -1267,8 +1303,17 @@ export async function executeReportGeneration(
|
|
|
1267
1303
|
const loadConfig = deps.loadConfig ?? loadArgusConfig
|
|
1268
1304
|
const projectDir = resolveProjectDir(context)
|
|
1269
1305
|
const config = loadConfig(projectDir)
|
|
1270
|
-
const
|
|
1271
|
-
const
|
|
1306
|
+
const rawOutputDir = config.reporting?.output_dir ?? ".argus/reports/"
|
|
1307
|
+
const resolvedOutput = path.resolve(projectDir, rawOutputDir)
|
|
1308
|
+
const projectRoot = projectDir.endsWith(path.sep) ? projectDir : projectDir + path.sep
|
|
1309
|
+
if (resolvedOutput !== projectDir && !resolvedOutput.startsWith(projectRoot)) {
|
|
1310
|
+
result.error = {
|
|
1311
|
+
code: "OUTPUT_DIR_TRAVERSAL",
|
|
1312
|
+
message: `output_dir "${rawOutputDir}" resolves outside the project root. Report not written.`,
|
|
1313
|
+
}
|
|
1314
|
+
return result
|
|
1315
|
+
}
|
|
1316
|
+
const fullPath = path.join(resolvedOutput, canonicalFilename)
|
|
1272
1317
|
|
|
1273
1318
|
// Single-writer policy: check for duplicate writes with same run_id
|
|
1274
1319
|
if (runId) {
|
|
@@ -1285,6 +1330,10 @@ export async function executeReportGeneration(
|
|
|
1285
1330
|
const logger = createLogger()
|
|
1286
1331
|
const message = err instanceof Error ? err.message : String(err)
|
|
1287
1332
|
logger.warn(`Failed to write report to disk: ${message}`)
|
|
1333
|
+
result.error = {
|
|
1334
|
+
code: "WRITE_FAILED",
|
|
1335
|
+
message,
|
|
1336
|
+
}
|
|
1288
1337
|
}
|
|
1289
1338
|
|
|
1290
1339
|
return result
|
|
@@ -1292,20 +1341,39 @@ export async function executeReportGeneration(
|
|
|
1292
1341
|
|
|
1293
1342
|
export const reportGeneratorTool = tool({
|
|
1294
1343
|
description:
|
|
1295
|
-
"Generate a professional markdown security audit report
|
|
1344
|
+
"Generate a professional markdown security audit report. Pass project_name, scope, and run_id — the tool reads the materialized ReportInput artifact from disk automatically.",
|
|
1296
1345
|
args: {
|
|
1297
1346
|
project_name: tool.schema.string(),
|
|
1298
1347
|
scope: tool.schema.array(tool.schema.string()),
|
|
1299
1348
|
include_executive_summary: tool.schema.boolean().default(true),
|
|
1300
1349
|
severity_threshold: tool.schema
|
|
1301
1350
|
.enum(["critical", "high", "medium", "low", "informational"])
|
|
1302
|
-
.default("
|
|
1303
|
-
report_input: tool.schema.string().optional(),
|
|
1304
|
-
audit_state: tool.schema.string().optional(),
|
|
1351
|
+
.default("informational"),
|
|
1305
1352
|
preflight_policy: tool.schema.enum(["warn", "strict-fail"]).optional(),
|
|
1353
|
+
tool_coverage_policy: tool.schema
|
|
1354
|
+
.enum(["enforce", "warn", "skip"])
|
|
1355
|
+
.optional()
|
|
1356
|
+
.describe(
|
|
1357
|
+
"Controls whether report generation requires key audit tools to have been executed. " +
|
|
1358
|
+
"Defaults to 'enforce'.",
|
|
1359
|
+
),
|
|
1360
|
+
run_id: tool.schema
|
|
1361
|
+
.string()
|
|
1362
|
+
.optional()
|
|
1363
|
+
.describe(
|
|
1364
|
+
"The canonical run ID from <argus-context>. The tool reads the materialized report-input.json from disk using this ID.",
|
|
1365
|
+
),
|
|
1306
1366
|
},
|
|
1307
1367
|
async execute(args, context) {
|
|
1308
1368
|
const result = await executeReportGeneration(args, context)
|
|
1309
|
-
|
|
1369
|
+
// Return a slim payload to avoid OpenCode truncating large tool results.
|
|
1370
|
+
// The full markdown is already written to disk at result.filePath.
|
|
1371
|
+
// Truncated JSON breaks tool-tracking-hook parsing, which prevents
|
|
1372
|
+
// reportGenerated from being set and blocks run finalization.
|
|
1373
|
+
const { report, ...slimResult } = result
|
|
1374
|
+
return JSON.stringify({
|
|
1375
|
+
...slimResult,
|
|
1376
|
+
reportSummary: `Report written to disk (${report.length} bytes, ${report.split("\n").length} lines). See filePath.`,
|
|
1377
|
+
})
|
|
1310
1378
|
},
|
|
1311
1379
|
})
|