solidity-argus 0.3.7 → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +797 -148
- 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 +34 -2
- 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 +597 -323
- 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 +394 -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 = {
|
|
@@ -73,6 +80,7 @@ export type ReportGenerationDependencies = {
|
|
|
73
80
|
runId: string,
|
|
74
81
|
projectDir: string,
|
|
75
82
|
) => Promise<import("../state/schemas").AuditEvent[]>
|
|
83
|
+
resolveCanonicalRunId?: (sessionId: string, projectDir: string) => string | null | undefined
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
export const SINGLE_WRITER_POLICY_VERSION = "1.0.0"
|
|
@@ -148,13 +156,8 @@ const FINDING_WEIGHT: Record<FindingSeverity, number> = {
|
|
|
148
156
|
Informational: 1,
|
|
149
157
|
}
|
|
150
158
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
High: 1,
|
|
154
|
-
Medium: 2,
|
|
155
|
-
Low: 3,
|
|
156
|
-
Informational: 4,
|
|
157
|
-
}
|
|
159
|
+
/** Sentinel for missing/unknown tool execution timestamps (schema requires startTime > 0). */
|
|
160
|
+
const UNKNOWN_TIMESTAMP_SENTINEL = 1
|
|
158
161
|
|
|
159
162
|
const MISSING_IMPACT_TEXT = "Impact details were not provided in the finding payload."
|
|
160
163
|
const MISSING_RECOMMENDATION_TEXT =
|
|
@@ -176,19 +179,6 @@ function emptyCounts(): FindingsCount {
|
|
|
176
179
|
}
|
|
177
180
|
}
|
|
178
181
|
|
|
179
|
-
function emptyAuditState(findings: Finding[] = []): AuditState {
|
|
180
|
-
return {
|
|
181
|
-
sessionId: "",
|
|
182
|
-
projectDir: "",
|
|
183
|
-
contractsReviewed: [],
|
|
184
|
-
findings,
|
|
185
|
-
toolsExecuted: [],
|
|
186
|
-
currentPhase: "complete",
|
|
187
|
-
scope: [],
|
|
188
|
-
startTime: 0,
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
182
|
/**
|
|
193
183
|
* Parse a location string like "File.sol:18-22" or "File.sol:18" into { file, lines }.
|
|
194
184
|
* Returns undefined if the string doesn't match a recognized format.
|
|
@@ -237,11 +227,15 @@ export function normalizeRawFinding(raw: Record<string, unknown>): Record<string
|
|
|
237
227
|
}
|
|
238
228
|
}
|
|
239
229
|
|
|
240
|
-
// file + lines: accept location string as alias
|
|
241
|
-
|
|
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") {
|
|
242
234
|
const parsed = parseLocationString(result.location as string)
|
|
243
235
|
if (parsed) {
|
|
244
|
-
result.file
|
|
236
|
+
if (typeof result.file !== "string" || (result.file as string).length === 0) {
|
|
237
|
+
result.file = parsed.file
|
|
238
|
+
}
|
|
245
239
|
if (!Array.isArray(result.lines) || (result.lines as unknown[]).length !== 2) {
|
|
246
240
|
result.lines = parsed.lines
|
|
247
241
|
}
|
|
@@ -298,79 +292,11 @@ export function normalizeRawFinding(raw: Record<string, unknown>): Record<string
|
|
|
298
292
|
result.description = result.check
|
|
299
293
|
}
|
|
300
294
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
function hasMinimumFindingFields(
|
|
305
|
-
f: unknown,
|
|
306
|
-
): f is { check: string; file: string; lines: [number, number] } {
|
|
307
|
-
if (typeof f !== "object" || f === null) return false
|
|
308
|
-
const obj = f as Record<string, unknown>
|
|
309
|
-
return (
|
|
310
|
-
typeof obj.check === "string" &&
|
|
311
|
-
obj.check.length > 0 &&
|
|
312
|
-
typeof obj.file === "string" &&
|
|
313
|
-
Array.isArray(obj.lines) &&
|
|
314
|
-
obj.lines.length === 2
|
|
315
|
-
)
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
const VALID_SEVERITIES: ReadonlySet<string> = new Set([
|
|
319
|
-
"Critical",
|
|
320
|
-
"High",
|
|
321
|
-
"Medium",
|
|
322
|
-
"Low",
|
|
323
|
-
"Informational",
|
|
324
|
-
])
|
|
325
|
-
const VALID_SOURCES: ReadonlySet<string> = new Set([
|
|
326
|
-
"slither",
|
|
327
|
-
"manual",
|
|
328
|
-
"pattern",
|
|
329
|
-
"scvd",
|
|
330
|
-
"solodit",
|
|
331
|
-
"fuzz",
|
|
332
|
-
])
|
|
333
|
-
|
|
334
|
-
function normalizeFinding(f: Record<string, unknown>): Finding {
|
|
335
|
-
const severity =
|
|
336
|
-
typeof f.severity === "string" && VALID_SEVERITIES.has(f.severity)
|
|
337
|
-
? (f.severity as Finding["severity"])
|
|
338
|
-
: "Informational"
|
|
339
|
-
const confidence =
|
|
340
|
-
typeof f.confidence === "string" && ["High", "Medium", "Low"].includes(f.confidence)
|
|
341
|
-
? (f.confidence as Finding["confidence"])
|
|
342
|
-
: "Low"
|
|
343
|
-
const source =
|
|
344
|
-
typeof f.source === "string" && VALID_SOURCES.has(f.source)
|
|
345
|
-
? (f.source as Finding["source"])
|
|
346
|
-
: "manual"
|
|
347
|
-
const description = typeof f.description === "string" ? f.description : (f.check as string)
|
|
348
|
-
const id = typeof f.id === "string" ? f.id : `${f.check}:${f.file}:${(f.lines as number[])[0]}`
|
|
349
|
-
return {
|
|
350
|
-
id,
|
|
351
|
-
check: f.check as string,
|
|
352
|
-
severity,
|
|
353
|
-
confidence,
|
|
354
|
-
description,
|
|
355
|
-
file: f.file as string,
|
|
356
|
-
lines: f.lines as [number, number],
|
|
357
|
-
source,
|
|
358
|
-
remediation: typeof f.remediation === "string" ? f.remediation : undefined,
|
|
359
|
-
exploitReference: typeof f.exploitReference === "string" ? f.exploitReference : undefined,
|
|
360
|
-
...(typeof f.impact === "string" ? { impact: f.impact } : {}),
|
|
361
|
-
...(typeof f.recommendation === "string" ? { recommendation: f.recommendation } : {}),
|
|
362
|
-
...(typeof f.proofOfConcept === "string" ? { proofOfConcept: f.proofOfConcept } : {}),
|
|
363
|
-
...(typeof f.proof_of_concept === "string" ? { proofOfConcept: f.proof_of_concept } : {}),
|
|
364
|
-
} as Finding
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
export type ParseAuditStateOptions = {
|
|
368
|
-
dropPolicy?: DropPolicy
|
|
369
|
-
}
|
|
295
|
+
if (!Array.isArray(result.lines) || (result.lines as unknown[]).length !== 2) {
|
|
296
|
+
result.lines = [0, 0]
|
|
297
|
+
}
|
|
370
298
|
|
|
371
|
-
|
|
372
|
-
state: AuditState
|
|
373
|
-
diagnostics: DropDiagnostic[]
|
|
299
|
+
return result
|
|
374
300
|
}
|
|
375
301
|
|
|
376
302
|
type ParseReportInputResult = {
|
|
@@ -407,80 +333,193 @@ function reportInputToAuditState(reportInput: ReportInput): AuditState {
|
|
|
407
333
|
proxyContracts: reportInput.proxyContracts,
|
|
408
334
|
patternVersion: reportInput.patternVersion,
|
|
409
335
|
skillsLoaded: reportInput.skillsLoaded,
|
|
336
|
+
unavailableTools: reportInput.unavailableTools,
|
|
410
337
|
}
|
|
411
338
|
}
|
|
412
339
|
|
|
413
|
-
function
|
|
414
|
-
|
|
415
|
-
|
|
340
|
+
function normalizeToolsExecutedDefaults(
|
|
341
|
+
parsed: unknown,
|
|
342
|
+
expectedRunId: string | undefined,
|
|
416
343
|
diagnostics: ReturnType<typeof createDropDiagnosticsCollector>,
|
|
417
|
-
):
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
)
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
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
|
+
}
|
|
426
376
|
|
|
427
|
-
if (
|
|
377
|
+
if (patched) {
|
|
428
378
|
diagnostics.warn(
|
|
429
|
-
"
|
|
430
|
-
"
|
|
431
|
-
"
|
|
379
|
+
"REPORT_INPUT_TOOLS_EXECUTED_NORMALIZED",
|
|
380
|
+
"toolsExecuted entries were missing canonical fields (startTime, success, findingsCount, run_id, schema_version); defaults applied.",
|
|
381
|
+
"toolsExecuted",
|
|
432
382
|
)
|
|
433
383
|
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
+
}
|
|
440
405
|
}
|
|
441
406
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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 */
|
|
451
452
|
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
453
|
+
}
|
|
454
|
+
} catch {
|
|
455
|
+
/* sessions dir doesn't exist */
|
|
456
|
+
}
|
|
455
457
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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 */
|
|
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
|
+
)
|
|
478
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() }
|
|
479
517
|
}
|
|
480
518
|
|
|
481
519
|
function parseReportInputPayload(
|
|
482
520
|
args: ReportGeneratorArgs,
|
|
483
521
|
context: ToolContext,
|
|
522
|
+
expectedRunId: string | undefined,
|
|
484
523
|
): ParseReportInputResult {
|
|
485
524
|
const diagnostics = createDropDiagnosticsCollector(
|
|
486
525
|
"warn",
|
|
@@ -488,7 +527,7 @@ function parseReportInputPayload(
|
|
|
488
527
|
"argus_generate_report",
|
|
489
528
|
)
|
|
490
529
|
|
|
491
|
-
if (
|
|
530
|
+
if (isNonEmptyString(args.report_input)) {
|
|
492
531
|
let parsed: unknown
|
|
493
532
|
try {
|
|
494
533
|
parsed = JSON.parse(args.report_input)
|
|
@@ -504,44 +543,109 @@ function parseReportInputPayload(
|
|
|
504
543
|
)
|
|
505
544
|
}
|
|
506
545
|
|
|
546
|
+
normalizeToolsExecutedDefaults(parsed, expectedRunId, diagnostics)
|
|
547
|
+
|
|
507
548
|
const validation = validateReportInput(parsed)
|
|
508
549
|
if (!validation.success) {
|
|
509
550
|
for (const error of validation.errors) {
|
|
510
|
-
diagnostics.
|
|
511
|
-
"
|
|
551
|
+
diagnostics.warn(
|
|
552
|
+
"REPORT_INPUT_INLINE_VALIDATION_FAILED",
|
|
512
553
|
`${error.field}: ${error.message}`,
|
|
513
554
|
error.field,
|
|
514
555
|
)
|
|
515
556
|
}
|
|
516
|
-
throwContractMismatch(
|
|
517
|
-
"ReportInput contract mismatch: report_input failed schema validation",
|
|
518
|
-
diagnostics.getDiagnostics(),
|
|
519
|
-
)
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
if (typeof args.audit_state === "string" && args.audit_state.trim().length > 0) {
|
|
523
557
|
diagnostics.warn(
|
|
524
|
-
"
|
|
525
|
-
|
|
526
|
-
"
|
|
558
|
+
"REPORT_INPUT_INLINE_FALLTHROUGH",
|
|
559
|
+
`Inline report_input failed validation (${validation.errors.length} errors). Falling back to disk artifact.`,
|
|
560
|
+
"report_input",
|
|
527
561
|
)
|
|
562
|
+
} else {
|
|
563
|
+
return finalizeReportInputSelection(validation.data, diagnostics, expectedRunId)
|
|
528
564
|
}
|
|
529
|
-
|
|
530
|
-
return { reportInput: validation.data, diagnostics: diagnostics.getDiagnostics() }
|
|
531
565
|
}
|
|
532
566
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
+
}
|
|
537
606
|
}
|
|
538
|
-
const reportInput = buildLegacyCompatibleReportInput(legacy.state, context, diagnostics)
|
|
539
|
-
return { reportInput, diagnostics: diagnostics.getDiagnostics() }
|
|
540
|
-
}
|
|
541
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
|
+
}
|
|
542
646
|
diagnostics.error(
|
|
543
647
|
"REPORT_INPUT_MISSING",
|
|
544
|
-
|
|
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.`,
|
|
545
649
|
"report_input",
|
|
546
650
|
)
|
|
547
651
|
throwContractMismatch(
|
|
@@ -550,135 +654,6 @@ function parseReportInputPayload(
|
|
|
550
654
|
)
|
|
551
655
|
}
|
|
552
656
|
|
|
553
|
-
function emitDropDiagnosticsForFindings(
|
|
554
|
-
rawItems: unknown[],
|
|
555
|
-
normalized: Record<string, unknown>[],
|
|
556
|
-
validFindings: Finding[],
|
|
557
|
-
diag: ReturnType<typeof createDropDiagnosticsCollector>,
|
|
558
|
-
): void {
|
|
559
|
-
const droppedCount = rawItems.length - validFindings.length
|
|
560
|
-
if (droppedCount <= 0) return
|
|
561
|
-
|
|
562
|
-
for (const item of normalized) {
|
|
563
|
-
if (hasMinimumFindingFields(item)) continue
|
|
564
|
-
const missing: string[] = []
|
|
565
|
-
if (typeof item.check !== "string" || (item.check as string).length === 0) missing.push("check")
|
|
566
|
-
if (typeof item.file !== "string") missing.push("file")
|
|
567
|
-
if (!Array.isArray(item.lines) || (item.lines as unknown[]).length !== 2) missing.push("lines")
|
|
568
|
-
diag.error(
|
|
569
|
-
"MISSING_REQUIRED_FIELD",
|
|
570
|
-
`Finding dropped: missing ${missing.join(", ") || "unknown fields"} after normalization`,
|
|
571
|
-
missing[0],
|
|
572
|
-
)
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
export function parseAuditState(auditState: string, options?: ParseAuditStateOptions): AuditState {
|
|
577
|
-
const policy = options?.dropPolicy ?? "warn"
|
|
578
|
-
const diag = createDropDiagnosticsCollector(policy, "report-generator")
|
|
579
|
-
|
|
580
|
-
let parsed: unknown
|
|
581
|
-
try {
|
|
582
|
-
parsed = JSON.parse(auditState)
|
|
583
|
-
} catch {
|
|
584
|
-
diag.error("MALFORMED_JSON", "audit_state is not valid JSON")
|
|
585
|
-
diag.throwIfStrict()
|
|
586
|
-
throw new Error(
|
|
587
|
-
"audit_state is not valid JSON — expected an AuditState object or Finding[] array",
|
|
588
|
-
)
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
if (Array.isArray(parsed)) {
|
|
592
|
-
const rawItems = parsed as unknown[]
|
|
593
|
-
const normalized = rawItems
|
|
594
|
-
.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
|
|
595
|
-
.map((item) => normalizeRawFinding(item))
|
|
596
|
-
const validFindings = normalized
|
|
597
|
-
.filter(hasMinimumFindingFields)
|
|
598
|
-
.map((f) => normalizeFinding(f as Record<string, unknown>))
|
|
599
|
-
emitDropDiagnosticsForFindings(rawItems, normalized, validFindings, diag)
|
|
600
|
-
diag.throwIfStrict()
|
|
601
|
-
return emptyAuditState(validFindings)
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
if (
|
|
605
|
-
typeof parsed === "object" &&
|
|
606
|
-
parsed !== null &&
|
|
607
|
-
Array.isArray((parsed as AuditState).findings)
|
|
608
|
-
) {
|
|
609
|
-
const state = parsed as AuditState
|
|
610
|
-
const rawFindings = state.findings as unknown[]
|
|
611
|
-
const normalized = rawFindings
|
|
612
|
-
.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
|
|
613
|
-
.map((item) => normalizeRawFinding(item))
|
|
614
|
-
const validFindings = normalized
|
|
615
|
-
.filter(hasMinimumFindingFields)
|
|
616
|
-
.map((f) => normalizeFinding(f as Record<string, unknown>))
|
|
617
|
-
emitDropDiagnosticsForFindings(rawFindings, normalized, validFindings, diag)
|
|
618
|
-
diag.throwIfStrict()
|
|
619
|
-
return {
|
|
620
|
-
...emptyAuditState(),
|
|
621
|
-
...state,
|
|
622
|
-
findings: validFindings,
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
return emptyAuditState()
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
export function parseAuditStateWithDiagnostics(
|
|
630
|
-
auditState: string,
|
|
631
|
-
options?: ParseAuditStateOptions,
|
|
632
|
-
): ParseAuditStateResult {
|
|
633
|
-
const policy = options?.dropPolicy ?? "warn"
|
|
634
|
-
const diag = createDropDiagnosticsCollector(policy, "report-generator")
|
|
635
|
-
|
|
636
|
-
let parsed: unknown
|
|
637
|
-
try {
|
|
638
|
-
parsed = JSON.parse(auditState)
|
|
639
|
-
} catch {
|
|
640
|
-
diag.error("MALFORMED_JSON", "audit_state is not valid JSON")
|
|
641
|
-
diag.throwIfStrict()
|
|
642
|
-
return { state: emptyAuditState(), diagnostics: diag.getDiagnostics() }
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
if (Array.isArray(parsed)) {
|
|
646
|
-
const rawItems = parsed as unknown[]
|
|
647
|
-
const normalized = rawItems
|
|
648
|
-
.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
|
|
649
|
-
.map((item) => normalizeRawFinding(item))
|
|
650
|
-
const validFindings = normalized
|
|
651
|
-
.filter(hasMinimumFindingFields)
|
|
652
|
-
.map((f) => normalizeFinding(f as Record<string, unknown>))
|
|
653
|
-
emitDropDiagnosticsForFindings(rawItems, normalized, validFindings, diag)
|
|
654
|
-
diag.throwIfStrict()
|
|
655
|
-
return { state: emptyAuditState(validFindings), diagnostics: diag.getDiagnostics() }
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
if (
|
|
659
|
-
typeof parsed === "object" &&
|
|
660
|
-
parsed !== null &&
|
|
661
|
-
Array.isArray((parsed as AuditState).findings)
|
|
662
|
-
) {
|
|
663
|
-
const auditStateObj = parsed as AuditState
|
|
664
|
-
const rawFindings = auditStateObj.findings as unknown[]
|
|
665
|
-
const normalized = rawFindings
|
|
666
|
-
.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
|
|
667
|
-
.map((item) => normalizeRawFinding(item))
|
|
668
|
-
const validFindings = normalized
|
|
669
|
-
.filter(hasMinimumFindingFields)
|
|
670
|
-
.map((f) => normalizeFinding(f as Record<string, unknown>))
|
|
671
|
-
emitDropDiagnosticsForFindings(rawFindings, normalized, validFindings, diag)
|
|
672
|
-
diag.throwIfStrict()
|
|
673
|
-
return {
|
|
674
|
-
state: { ...emptyAuditState(), ...auditStateObj, findings: validFindings },
|
|
675
|
-
diagnostics: diag.getDiagnostics(),
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
return { state: emptyAuditState(), diagnostics: diag.getDiagnostics() }
|
|
680
|
-
}
|
|
681
|
-
|
|
682
657
|
function normalizeTitle(check: string): string {
|
|
683
658
|
if (!check || typeof check !== "string") return "Unknown Check"
|
|
684
659
|
return check
|
|
@@ -756,7 +731,7 @@ function getExtendedFinding(finding: Finding): Finding & ReportFindingFields {
|
|
|
756
731
|
|
|
757
732
|
function getFindingImpact(finding: Finding): string {
|
|
758
733
|
const extended = getExtendedFinding(finding)
|
|
759
|
-
if (
|
|
734
|
+
if (isNonEmptyString(extended.impact)) {
|
|
760
735
|
return extended.impact.trim()
|
|
761
736
|
}
|
|
762
737
|
return MISSING_IMPACT_TEXT
|
|
@@ -764,10 +739,10 @@ function getFindingImpact(finding: Finding): string {
|
|
|
764
739
|
|
|
765
740
|
function getFindingRecommendation(finding: Finding): string {
|
|
766
741
|
const extended = getExtendedFinding(finding)
|
|
767
|
-
if (
|
|
742
|
+
if (isNonEmptyString(extended.recommendation)) {
|
|
768
743
|
return extended.recommendation.trim()
|
|
769
744
|
}
|
|
770
|
-
if (
|
|
745
|
+
if (isNonEmptyString(finding.remediation)) {
|
|
771
746
|
return finding.remediation.trim()
|
|
772
747
|
}
|
|
773
748
|
return MISSING_RECOMMENDATION_TEXT
|
|
@@ -775,10 +750,10 @@ function getFindingRecommendation(finding: Finding): string {
|
|
|
775
750
|
|
|
776
751
|
function getPocEvidence(finding: Finding): string | undefined {
|
|
777
752
|
const extended = getExtendedFinding(finding)
|
|
778
|
-
if (
|
|
753
|
+
if (isNonEmptyString(extended.proofOfConcept)) {
|
|
779
754
|
return extended.proofOfConcept.trim()
|
|
780
755
|
}
|
|
781
|
-
if (
|
|
756
|
+
if (isNonEmptyString(finding.exploitReference)) {
|
|
782
757
|
return finding.exploitReference.trim()
|
|
783
758
|
}
|
|
784
759
|
return undefined
|
|
@@ -973,17 +948,17 @@ function formatDuration(ms: number): string {
|
|
|
973
948
|
export function buildProvenanceAppendix(
|
|
974
949
|
state: AuditState,
|
|
975
950
|
threshold: SeverityThreshold,
|
|
976
|
-
|
|
951
|
+
reportFindings: Finding[],
|
|
977
952
|
): string {
|
|
978
953
|
const lines: string[] = ["## Appendix: Data Provenance"]
|
|
979
954
|
|
|
980
|
-
lines.push("- Data source: `report_input` payload
|
|
955
|
+
lines.push("- Data source: `report_input` payload")
|
|
981
956
|
lines.push(`- Severity threshold applied: ${threshold}`)
|
|
982
|
-
lines.push(`- Findings included in report: ${
|
|
957
|
+
lines.push(`- Findings included in report: ${reportFindings.length}`)
|
|
983
958
|
|
|
984
|
-
if (
|
|
959
|
+
if (reportFindings.length > 0) {
|
|
985
960
|
const sourceCounts: Record<string, number> = {}
|
|
986
|
-
for (const f of
|
|
961
|
+
for (const f of reportFindings) {
|
|
987
962
|
sourceCounts[f.source] = (sourceCounts[f.source] ?? 0) + 1
|
|
988
963
|
}
|
|
989
964
|
lines.push("")
|
|
@@ -1099,10 +1074,51 @@ export async function executeReportGeneration(
|
|
|
1099
1074
|
const includeExecutiveSummary = args.include_executive_summary ?? true
|
|
1100
1075
|
const threshold = args.severity_threshold ?? "low"
|
|
1101
1076
|
const qualityGatePolicy = args.quality_gate_policy ?? "warn"
|
|
1102
|
-
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
|
+
|
|
1103
1100
|
const preflightPolicy = args.preflight_policy ?? "warn"
|
|
1104
1101
|
let preflightWarningSection: string | null = null
|
|
1105
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
|
+
|
|
1106
1122
|
try {
|
|
1107
1123
|
const readEventsFn = deps.readEvents ?? readEvents
|
|
1108
1124
|
const events = await readEventsFn(reportInput.run_id, reportInput.projectDir)
|
|
@@ -1183,7 +1199,22 @@ export async function executeReportGeneration(
|
|
|
1183
1199
|
)
|
|
1184
1200
|
}
|
|
1185
1201
|
const counts = calculateCounts(findings)
|
|
1186
|
-
|
|
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)
|
|
1187
1218
|
|
|
1188
1219
|
context.metadata({ title: `Generate audit report: ${args.project_name}` })
|
|
1189
1220
|
|
|
@@ -1238,10 +1269,13 @@ export async function executeReportGeneration(
|
|
|
1238
1269
|
sections.push(preflightWarningSection)
|
|
1239
1270
|
}
|
|
1240
1271
|
|
|
1241
|
-
sections.push(buildProvenanceAppendix(state, threshold, findings
|
|
1272
|
+
sections.push(buildProvenanceAppendix(state, threshold, findings))
|
|
1242
1273
|
|
|
1243
1274
|
// Embed report metadata for single-writer policy enforcement
|
|
1244
|
-
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
|
+
}
|
|
1245
1279
|
if (runId) {
|
|
1246
1280
|
sections.push(buildReportMetadataComment(runId))
|
|
1247
1281
|
}
|
|
@@ -1269,8 +1303,17 @@ export async function executeReportGeneration(
|
|
|
1269
1303
|
const loadConfig = deps.loadConfig ?? loadArgusConfig
|
|
1270
1304
|
const projectDir = resolveProjectDir(context)
|
|
1271
1305
|
const config = loadConfig(projectDir)
|
|
1272
|
-
const
|
|
1273
|
-
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)
|
|
1274
1317
|
|
|
1275
1318
|
// Single-writer policy: check for duplicate writes with same run_id
|
|
1276
1319
|
if (runId) {
|
|
@@ -1287,6 +1330,10 @@ export async function executeReportGeneration(
|
|
|
1287
1330
|
const logger = createLogger()
|
|
1288
1331
|
const message = err instanceof Error ? err.message : String(err)
|
|
1289
1332
|
logger.warn(`Failed to write report to disk: ${message}`)
|
|
1333
|
+
result.error = {
|
|
1334
|
+
code: "WRITE_FAILED",
|
|
1335
|
+
message,
|
|
1336
|
+
}
|
|
1290
1337
|
}
|
|
1291
1338
|
|
|
1292
1339
|
return result
|
|
@@ -1294,20 +1341,39 @@ export async function executeReportGeneration(
|
|
|
1294
1341
|
|
|
1295
1342
|
export const reportGeneratorTool = tool({
|
|
1296
1343
|
description:
|
|
1297
|
-
"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.",
|
|
1298
1345
|
args: {
|
|
1299
1346
|
project_name: tool.schema.string(),
|
|
1300
1347
|
scope: tool.schema.array(tool.schema.string()),
|
|
1301
1348
|
include_executive_summary: tool.schema.boolean().default(true),
|
|
1302
1349
|
severity_threshold: tool.schema
|
|
1303
1350
|
.enum(["critical", "high", "medium", "low", "informational"])
|
|
1304
|
-
.default("
|
|
1305
|
-
report_input: tool.schema.string().optional(),
|
|
1306
|
-
audit_state: tool.schema.string().optional(),
|
|
1351
|
+
.default("informational"),
|
|
1307
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
|
+
),
|
|
1308
1366
|
},
|
|
1309
1367
|
async execute(args, context) {
|
|
1310
1368
|
const result = await executeReportGeneration(args, context)
|
|
1311
|
-
|
|
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
|
+
})
|
|
1312
1378
|
},
|
|
1313
1379
|
})
|