solidity-argus 0.3.7 → 0.5.7
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 +24 -2
- package/src/agents/scribe-prompt.ts +34 -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/cli/commands/install.ts +74 -33
- 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 +806 -173
- 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 +68 -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 +602 -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 +130 -25
- package/src/tools/report-generator-tool.ts +475 -327
- 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,14 +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"
|
|
15
|
+
import { isNonEmptyString } from "../shared/type-guards"
|
|
16
|
+
import { SEVERITY_RANK } from "../shared/validation-constants"
|
|
12
17
|
import { normalizeToCanonicalFinding } from "../state/adapters"
|
|
13
18
|
import {
|
|
14
19
|
compareIssueFingerprintSets,
|
|
@@ -16,11 +21,13 @@ import {
|
|
|
16
21
|
} from "../state/finding-aggregation"
|
|
17
22
|
import { projectFindings, stableHash } from "../state/projectors"
|
|
18
23
|
import { type ReportInput, SCHEMA_VERSION, validateReportInput } from "../state/schemas"
|
|
19
|
-
import type { AuditState, Finding, FindingSeverity } from "../state/types"
|
|
24
|
+
import type { ArgusAgentName, AuditState, Finding, FindingSeverity } from "../state/types"
|
|
20
25
|
import { checkReportPreflight } from "./report-preflight"
|
|
21
26
|
|
|
22
27
|
type SeverityThreshold = "critical" | "high" | "medium" | "low" | "informational"
|
|
23
28
|
|
|
29
|
+
type ToolCoveragePolicy = "enforce" | "warn" | "skip"
|
|
30
|
+
|
|
24
31
|
type ReportGeneratorArgs = {
|
|
25
32
|
project_name: string
|
|
26
33
|
scope: string[]
|
|
@@ -28,8 +35,9 @@ type ReportGeneratorArgs = {
|
|
|
28
35
|
severity_threshold?: SeverityThreshold
|
|
29
36
|
quality_gate_policy?: QualityGatePolicy
|
|
30
37
|
report_input?: string
|
|
31
|
-
audit_state?: string
|
|
32
38
|
preflight_policy?: PreflightPolicy
|
|
39
|
+
tool_coverage_policy?: ToolCoveragePolicy
|
|
40
|
+
run_id?: string
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
type FindingsCount = {
|
|
@@ -73,6 +81,7 @@ export type ReportGenerationDependencies = {
|
|
|
73
81
|
runId: string,
|
|
74
82
|
projectDir: string,
|
|
75
83
|
) => Promise<import("../state/schemas").AuditEvent[]>
|
|
84
|
+
resolveCanonicalRunId?: (sessionId: string, projectDir: string) => string | null | undefined
|
|
76
85
|
}
|
|
77
86
|
|
|
78
87
|
export const SINGLE_WRITER_POLICY_VERSION = "1.0.0"
|
|
@@ -148,13 +157,8 @@ const FINDING_WEIGHT: Record<FindingSeverity, number> = {
|
|
|
148
157
|
Informational: 1,
|
|
149
158
|
}
|
|
150
159
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
High: 1,
|
|
154
|
-
Medium: 2,
|
|
155
|
-
Low: 3,
|
|
156
|
-
Informational: 4,
|
|
157
|
-
}
|
|
160
|
+
/** Sentinel for missing/unknown tool execution timestamps (schema requires startTime > 0). */
|
|
161
|
+
const UNKNOWN_TIMESTAMP_SENTINEL = 1
|
|
158
162
|
|
|
159
163
|
const MISSING_IMPACT_TEXT = "Impact details were not provided in the finding payload."
|
|
160
164
|
const MISSING_RECOMMENDATION_TEXT =
|
|
@@ -176,19 +180,6 @@ function emptyCounts(): FindingsCount {
|
|
|
176
180
|
}
|
|
177
181
|
}
|
|
178
182
|
|
|
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
183
|
/**
|
|
193
184
|
* Parse a location string like "File.sol:18-22" or "File.sol:18" into { file, lines }.
|
|
194
185
|
* Returns undefined if the string doesn't match a recognized format.
|
|
@@ -237,11 +228,15 @@ export function normalizeRawFinding(raw: Record<string, unknown>): Record<string
|
|
|
237
228
|
}
|
|
238
229
|
}
|
|
239
230
|
|
|
240
|
-
// file + lines: accept location string as alias
|
|
241
|
-
|
|
231
|
+
// file + lines: accept location string as alias.
|
|
232
|
+
// Always attempt to extract lines from location, even when file is already set.
|
|
233
|
+
// LLMs commonly provide both file and location (e.g. file="src/Vault.sol", location="Vault.sol:18-23").
|
|
234
|
+
if (typeof result.location === "string") {
|
|
242
235
|
const parsed = parseLocationString(result.location as string)
|
|
243
236
|
if (parsed) {
|
|
244
|
-
result.file
|
|
237
|
+
if (typeof result.file !== "string" || (result.file as string).length === 0) {
|
|
238
|
+
result.file = parsed.file
|
|
239
|
+
}
|
|
245
240
|
if (!Array.isArray(result.lines) || (result.lines as unknown[]).length !== 2) {
|
|
246
241
|
result.lines = parsed.lines
|
|
247
242
|
}
|
|
@@ -298,84 +293,47 @@ export function normalizeRawFinding(raw: Record<string, unknown>): Record<string
|
|
|
298
293
|
result.description = result.check
|
|
299
294
|
}
|
|
300
295
|
|
|
296
|
+
if (!Array.isArray(result.lines) || (result.lines as unknown[]).length !== 2) {
|
|
297
|
+
result.lines = [0, 0]
|
|
298
|
+
}
|
|
299
|
+
|
|
301
300
|
return result
|
|
302
301
|
}
|
|
303
302
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
)
|
|
303
|
+
type ParseReportInputResult = {
|
|
304
|
+
reportInput: ReportInput
|
|
305
|
+
diagnostics: DropDiagnostic[]
|
|
316
306
|
}
|
|
317
307
|
|
|
318
|
-
const
|
|
319
|
-
"
|
|
320
|
-
"
|
|
321
|
-
"
|
|
322
|
-
"
|
|
323
|
-
"
|
|
324
|
-
])
|
|
325
|
-
const VALID_SOURCES: ReadonlySet<string> = new Set([
|
|
326
|
-
"slither",
|
|
327
|
-
"manual",
|
|
328
|
-
"pattern",
|
|
329
|
-
"scvd",
|
|
330
|
-
"solodit",
|
|
331
|
-
"fuzz",
|
|
308
|
+
const VALID_AGENT_VALUES = new Set<ArgusAgentName>([
|
|
309
|
+
"argus",
|
|
310
|
+
"sentinel",
|
|
311
|
+
"pythia",
|
|
312
|
+
"scribe",
|
|
313
|
+
"unknown",
|
|
332
314
|
])
|
|
333
315
|
|
|
334
|
-
function
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
typeof
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
}
|
|
370
|
-
|
|
371
|
-
export type ParseAuditStateResult = {
|
|
372
|
-
state: AuditState
|
|
373
|
-
diagnostics: DropDiagnostic[]
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
type ParseReportInputResult = {
|
|
377
|
-
reportInput: ReportInput
|
|
378
|
-
diagnostics: DropDiagnostic[]
|
|
316
|
+
function normalizeDedupedFindings(
|
|
317
|
+
rawFindings: unknown[],
|
|
318
|
+
runId: string,
|
|
319
|
+
projectDir: string,
|
|
320
|
+
dedupedBy: string,
|
|
321
|
+
): Record<string, unknown>[] {
|
|
322
|
+
const reportedByAgent: ArgusAgentName = VALID_AGENT_VALUES.has(dedupedBy as ArgusAgentName)
|
|
323
|
+
? (dedupedBy as ArgusAgentName)
|
|
324
|
+
: "scribe"
|
|
325
|
+
return rawFindings.map((raw, index) => {
|
|
326
|
+
const input = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {}
|
|
327
|
+
const normalized = normalizeRawFinding(input)
|
|
328
|
+
const result = normalizeToCanonicalFinding(
|
|
329
|
+
normalized,
|
|
330
|
+
runId,
|
|
331
|
+
index + 1,
|
|
332
|
+
{ reportedByAgent },
|
|
333
|
+
projectDir,
|
|
334
|
+
)
|
|
335
|
+
return result.data as unknown as Record<string, unknown>
|
|
336
|
+
})
|
|
379
337
|
}
|
|
380
338
|
|
|
381
339
|
function diagnosticsSummary(diagnostics: DropDiagnostic[]): string {
|
|
@@ -407,80 +365,193 @@ function reportInputToAuditState(reportInput: ReportInput): AuditState {
|
|
|
407
365
|
proxyContracts: reportInput.proxyContracts,
|
|
408
366
|
patternVersion: reportInput.patternVersion,
|
|
409
367
|
skillsLoaded: reportInput.skillsLoaded,
|
|
368
|
+
unavailableTools: reportInput.unavailableTools,
|
|
410
369
|
}
|
|
411
370
|
}
|
|
412
371
|
|
|
413
|
-
function
|
|
414
|
-
|
|
415
|
-
|
|
372
|
+
function normalizeToolsExecutedDefaults(
|
|
373
|
+
parsed: unknown,
|
|
374
|
+
expectedRunId: string | undefined,
|
|
416
375
|
diagnostics: ReturnType<typeof createDropDiagnosticsCollector>,
|
|
417
|
-
):
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
)
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
const
|
|
376
|
+
): void {
|
|
377
|
+
if (!parsed || typeof parsed !== "object") return
|
|
378
|
+
const obj = parsed as Record<string, unknown>
|
|
379
|
+
if (!Array.isArray(obj.toolsExecuted)) return
|
|
380
|
+
|
|
381
|
+
const runId = (typeof obj.run_id === "string" && obj.run_id) || expectedRunId || "unknown"
|
|
382
|
+
let patched = false
|
|
383
|
+
|
|
384
|
+
for (const entry of obj.toolsExecuted) {
|
|
385
|
+
if (!entry || typeof entry !== "object") continue
|
|
386
|
+
const rec = entry as Record<string, unknown>
|
|
387
|
+
if (typeof rec.startTime !== "number" || rec.startTime <= 0) {
|
|
388
|
+
rec.startTime = UNKNOWN_TIMESTAMP_SENTINEL
|
|
389
|
+
patched = true
|
|
390
|
+
}
|
|
391
|
+
if (typeof rec.success !== "boolean") {
|
|
392
|
+
rec.success = true
|
|
393
|
+
patched = true
|
|
394
|
+
}
|
|
395
|
+
if (typeof rec.findingsCount !== "number" || rec.findingsCount < 0) {
|
|
396
|
+
rec.findingsCount = 0
|
|
397
|
+
patched = true
|
|
398
|
+
}
|
|
399
|
+
if (!isNonEmptyString(rec.run_id)) {
|
|
400
|
+
rec.run_id = runId
|
|
401
|
+
patched = true
|
|
402
|
+
}
|
|
403
|
+
if (!isNonEmptyString(rec.schema_version)) {
|
|
404
|
+
rec.schema_version = SCHEMA_VERSION
|
|
405
|
+
patched = true
|
|
406
|
+
}
|
|
407
|
+
}
|
|
426
408
|
|
|
427
|
-
if (
|
|
409
|
+
if (patched) {
|
|
428
410
|
diagnostics.warn(
|
|
429
|
-
"
|
|
430
|
-
"
|
|
431
|
-
"
|
|
411
|
+
"REPORT_INPUT_TOOLS_EXECUTED_NORMALIZED",
|
|
412
|
+
"toolsExecuted entries were missing canonical fields (startTime, success, findingsCount, run_id, schema_version); defaults applied.",
|
|
413
|
+
"toolsExecuted",
|
|
432
414
|
)
|
|
433
415
|
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function resolveExpectedRunId(
|
|
419
|
+
args: ReportGeneratorArgs,
|
|
420
|
+
context: ToolContext,
|
|
421
|
+
deps: ReportGenerationDependencies,
|
|
422
|
+
): string | undefined {
|
|
423
|
+
// 1. Explicit run_id from LLM args (highest priority)
|
|
424
|
+
if (isNonEmptyString(args.run_id)) {
|
|
425
|
+
return args.run_id.trim()
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// 2. Global run index lookup by session ID
|
|
429
|
+
const sessionId = context.sessionID
|
|
430
|
+
const projectDir = resolveProjectDir(context)
|
|
431
|
+
if (isNonEmptyString(sessionId)) {
|
|
432
|
+
const resolveCanonicalRunId = deps.resolveCanonicalRunId ?? resolveRunIdFromOpencodeSession
|
|
433
|
+
const resolved = resolveCanonicalRunId(sessionId, projectDir)
|
|
434
|
+
if (isNonEmptyString(resolved)) {
|
|
435
|
+
return resolved
|
|
436
|
+
}
|
|
440
437
|
}
|
|
441
438
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
439
|
+
// When caller provides inline report_input, skip filesystem discovery —
|
|
440
|
+
// the caller already has their data and filesystem state may belong to a different run.
|
|
441
|
+
if (isNonEmptyString(args.report_input)) {
|
|
442
|
+
return undefined
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// 3. Per-session state files (per-session managers write to sessions/state-{sessionId}.json)
|
|
446
|
+
const STALE_STATE_TTL_MS = 24 * 60 * 60 * 1000
|
|
447
|
+
const sessionsDir = path.join(projectDir, ".argus", "sessions")
|
|
448
|
+
try {
|
|
449
|
+
const entries = readdirSync(sessionsDir)
|
|
450
|
+
const stateFiles = entries.filter((e) => e.startsWith("state-") && e.endsWith(".json"))
|
|
451
|
+
const ranked = stateFiles
|
|
452
|
+
.map((name) => {
|
|
453
|
+
const filePath = path.join(sessionsDir, name)
|
|
454
|
+
try {
|
|
455
|
+
return { name, path: filePath, mtime: statSync(filePath).mtimeMs }
|
|
456
|
+
} catch {
|
|
457
|
+
return null
|
|
458
|
+
}
|
|
459
|
+
})
|
|
460
|
+
.filter((entry): entry is NonNullable<typeof entry> => entry !== null)
|
|
461
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
462
|
+
|
|
463
|
+
for (const entry of ranked) {
|
|
464
|
+
try {
|
|
465
|
+
const stateRaw = JSON.parse(readFileSync(entry.path, "utf-8")) as Record<string, unknown>
|
|
466
|
+
const stateSessionId = stateRaw.sessionId
|
|
467
|
+
const savedAt = typeof stateRaw.savedAt === "number" ? stateRaw.savedAt : 0
|
|
468
|
+
const isFresh = Date.now() - savedAt < STALE_STATE_TTL_MS
|
|
469
|
+
if (
|
|
470
|
+
typeof stateSessionId === "string" &&
|
|
471
|
+
stateSessionId.trim().length > 0 &&
|
|
472
|
+
!stateSessionId.startsWith("ses_") &&
|
|
473
|
+
isFresh
|
|
474
|
+
) {
|
|
475
|
+
const resolver = createAuditArtifactResolver(stateSessionId, projectDir)
|
|
476
|
+
const hasArtifacts =
|
|
477
|
+
existsSync(resolver.paths().reportInputFile) || existsSync(resolver.paths().journalFile)
|
|
478
|
+
if (hasArtifacts) {
|
|
479
|
+
return stateSessionId
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
} catch {
|
|
483
|
+
/* skip unreadable session file */
|
|
451
484
|
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
485
|
+
}
|
|
486
|
+
} catch {
|
|
487
|
+
/* sessions dir doesn't exist */
|
|
488
|
+
}
|
|
455
489
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
490
|
+
// 4. Shared audit state (legacy fallback)
|
|
491
|
+
try {
|
|
492
|
+
const sharedStatePath = path.join(projectDir, ".argus", "argus-state.json")
|
|
493
|
+
if (existsSync(sharedStatePath)) {
|
|
494
|
+
const stateRaw = JSON.parse(readFileSync(sharedStatePath, "utf-8")) as Record<string, unknown>
|
|
495
|
+
const stateSessionId = stateRaw.sessionId
|
|
496
|
+
const savedAt = typeof stateRaw.savedAt === "number" ? stateRaw.savedAt : 0
|
|
497
|
+
const isFresh = Date.now() - savedAt < STALE_STATE_TTL_MS
|
|
498
|
+
if (
|
|
499
|
+
typeof stateSessionId === "string" &&
|
|
500
|
+
stateSessionId.trim().length > 0 &&
|
|
501
|
+
!stateSessionId.startsWith("ses_") &&
|
|
502
|
+
isFresh
|
|
503
|
+
) {
|
|
504
|
+
const resolver = createAuditArtifactResolver(stateSessionId, projectDir)
|
|
505
|
+
const hasArtifacts =
|
|
506
|
+
existsSync(resolver.paths().reportInputFile) || existsSync(resolver.paths().journalFile)
|
|
507
|
+
if (hasArtifacts) {
|
|
508
|
+
return stateSessionId
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
} catch {
|
|
513
|
+
/* fallback path */
|
|
478
514
|
}
|
|
515
|
+
|
|
516
|
+
return undefined
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function finalizeReportInputSelection(
|
|
520
|
+
reportInput: ReportInput,
|
|
521
|
+
diagnostics: ReturnType<typeof createDropDiagnosticsCollector>,
|
|
522
|
+
expectedRunId?: string,
|
|
523
|
+
): ParseReportInputResult {
|
|
524
|
+
if (reportInput.run_id.startsWith("ses_")) {
|
|
525
|
+
diagnostics.error(
|
|
526
|
+
"REPORT_INPUT_RUN_ID_MISMATCH",
|
|
527
|
+
"ReportInput run_id must be a canonical run identifier, not an OpenCode session id (ses_*).",
|
|
528
|
+
"run_id",
|
|
529
|
+
)
|
|
530
|
+
throwContractMismatch(
|
|
531
|
+
"ReportInput contract mismatch: run_id/session_id conflation detected",
|
|
532
|
+
diagnostics.getDiagnostics(),
|
|
533
|
+
)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (expectedRunId && reportInput.run_id !== expectedRunId) {
|
|
537
|
+
diagnostics.error(
|
|
538
|
+
"REPORT_INPUT_CANONICAL_RUN_MISMATCH",
|
|
539
|
+
`ReportInput run_id ${reportInput.run_id} does not match canonical run_id ${expectedRunId}.`,
|
|
540
|
+
"run_id",
|
|
541
|
+
)
|
|
542
|
+
throwContractMismatch(
|
|
543
|
+
"ReportInput contract mismatch: report_input run_id diverges from canonical run_id",
|
|
544
|
+
diagnostics.getDiagnostics(),
|
|
545
|
+
)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return { reportInput, diagnostics: diagnostics.getDiagnostics() }
|
|
479
549
|
}
|
|
480
550
|
|
|
481
551
|
function parseReportInputPayload(
|
|
482
552
|
args: ReportGeneratorArgs,
|
|
483
553
|
context: ToolContext,
|
|
554
|
+
expectedRunId: string | undefined,
|
|
484
555
|
): ParseReportInputResult {
|
|
485
556
|
const diagnostics = createDropDiagnosticsCollector(
|
|
486
557
|
"warn",
|
|
@@ -488,7 +559,7 @@ function parseReportInputPayload(
|
|
|
488
559
|
"argus_generate_report",
|
|
489
560
|
)
|
|
490
561
|
|
|
491
|
-
if (
|
|
562
|
+
if (isNonEmptyString(args.report_input)) {
|
|
492
563
|
let parsed: unknown
|
|
493
564
|
try {
|
|
494
565
|
parsed = JSON.parse(args.report_input)
|
|
@@ -504,44 +575,159 @@ function parseReportInputPayload(
|
|
|
504
575
|
)
|
|
505
576
|
}
|
|
506
577
|
|
|
578
|
+
normalizeToolsExecutedDefaults(parsed, expectedRunId, diagnostics)
|
|
579
|
+
|
|
507
580
|
const validation = validateReportInput(parsed)
|
|
508
581
|
if (!validation.success) {
|
|
509
582
|
for (const error of validation.errors) {
|
|
510
|
-
diagnostics.
|
|
511
|
-
"
|
|
583
|
+
diagnostics.warn(
|
|
584
|
+
"REPORT_INPUT_INLINE_VALIDATION_FAILED",
|
|
512
585
|
`${error.field}: ${error.message}`,
|
|
513
586
|
error.field,
|
|
514
587
|
)
|
|
515
588
|
}
|
|
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
589
|
diagnostics.warn(
|
|
524
|
-
"
|
|
525
|
-
|
|
526
|
-
"
|
|
590
|
+
"REPORT_INPUT_INLINE_FALLTHROUGH",
|
|
591
|
+
`Inline report_input failed validation (${validation.errors.length} errors). Falling back to disk artifact.`,
|
|
592
|
+
"report_input",
|
|
527
593
|
)
|
|
594
|
+
} else {
|
|
595
|
+
return finalizeReportInputSelection(validation.data, diagnostics, expectedRunId)
|
|
528
596
|
}
|
|
529
|
-
|
|
530
|
-
return { reportInput: validation.data, diagnostics: diagnostics.getDiagnostics() }
|
|
531
597
|
}
|
|
532
598
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
599
|
+
const effectiveRunId =
|
|
600
|
+
(isNonEmptyString(args.run_id) ? args.run_id.trim() : undefined) ?? expectedRunId
|
|
601
|
+
|
|
602
|
+
if (isNonEmptyString(effectiveRunId)) {
|
|
603
|
+
const projectDir = resolveProjectDir(context)
|
|
604
|
+
const resolver = createAuditArtifactResolver(effectiveRunId, projectDir)
|
|
605
|
+
|
|
606
|
+
const dedupedFile = resolver.paths().dedupedFindingsFile
|
|
607
|
+
if (existsSync(dedupedFile)) {
|
|
608
|
+
try {
|
|
609
|
+
const dedupedArtifact = JSON.parse(readFileSync(dedupedFile, "utf-8")) as {
|
|
610
|
+
findings?: unknown[]
|
|
611
|
+
deduped_by?: string
|
|
612
|
+
}
|
|
613
|
+
if (Array.isArray(dedupedArtifact.findings) && dedupedArtifact.findings.length > 0) {
|
|
614
|
+
const reportInputFile = resolver.paths().reportInputFile
|
|
615
|
+
let baseInput: Record<string, unknown> = {}
|
|
616
|
+
if (existsSync(reportInputFile)) {
|
|
617
|
+
try {
|
|
618
|
+
baseInput = JSON.parse(readFileSync(reportInputFile, "utf-8")) as Record<
|
|
619
|
+
string,
|
|
620
|
+
unknown
|
|
621
|
+
>
|
|
622
|
+
} catch {
|
|
623
|
+
/* use empty base */
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
const normalizedFindings = normalizeDedupedFindings(
|
|
627
|
+
dedupedArtifact.findings,
|
|
628
|
+
effectiveRunId,
|
|
629
|
+
projectDir,
|
|
630
|
+
typeof dedupedArtifact.deduped_by === "string"
|
|
631
|
+
? dedupedArtifact.deduped_by
|
|
632
|
+
: "scribe",
|
|
633
|
+
)
|
|
634
|
+
const merged: Record<string, unknown> = {
|
|
635
|
+
...baseInput,
|
|
636
|
+
run_id: effectiveRunId,
|
|
637
|
+
findings: normalizedFindings,
|
|
638
|
+
}
|
|
639
|
+
normalizeToolsExecutedDefaults(merged, effectiveRunId, diagnostics)
|
|
640
|
+
if (typeof merged.seq !== "number" || (merged.seq as number) < 0) {
|
|
641
|
+
merged.seq = 0
|
|
642
|
+
}
|
|
643
|
+
if (typeof merged.session_id !== "string" || (merged.session_id as string).length === 0) {
|
|
644
|
+
merged.session_id = "unknown"
|
|
645
|
+
}
|
|
646
|
+
if (
|
|
647
|
+
typeof merged.tool_call_id !== "string" ||
|
|
648
|
+
(merged.tool_call_id as string).length === 0
|
|
649
|
+
) {
|
|
650
|
+
merged.tool_call_id = `deduped:${effectiveRunId}`
|
|
651
|
+
}
|
|
652
|
+
if (typeof merged.source !== "string" || (merged.source as string).length === 0) {
|
|
653
|
+
merged.source = "deduped-findings"
|
|
654
|
+
}
|
|
655
|
+
if (
|
|
656
|
+
typeof merged.schema_version !== "string" ||
|
|
657
|
+
merged.schema_version !== SCHEMA_VERSION
|
|
658
|
+
) {
|
|
659
|
+
merged.schema_version = SCHEMA_VERSION
|
|
660
|
+
}
|
|
661
|
+
if (
|
|
662
|
+
typeof merged.projectDir !== "string" ||
|
|
663
|
+
(merged.projectDir as string).length === 0
|
|
664
|
+
) {
|
|
665
|
+
merged.projectDir = projectDir
|
|
666
|
+
}
|
|
667
|
+
if (!Array.isArray(merged.scope)) {
|
|
668
|
+
merged.scope = []
|
|
669
|
+
}
|
|
670
|
+
if (!Array.isArray(merged.toolsExecuted)) {
|
|
671
|
+
merged.toolsExecuted = []
|
|
672
|
+
}
|
|
673
|
+
const validation = validateReportInput(merged)
|
|
674
|
+
if (validation.success) {
|
|
675
|
+
return finalizeReportInputSelection(validation.data, diagnostics, expectedRunId)
|
|
676
|
+
}
|
|
677
|
+
for (const error of validation.errors) {
|
|
678
|
+
diagnostics.warn(
|
|
679
|
+
"REPORT_INPUT_DEDUPED_VALIDATION_FAILED",
|
|
680
|
+
`${error.field}: ${error.message}`,
|
|
681
|
+
error.field,
|
|
682
|
+
)
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
} catch {
|
|
686
|
+
/* deduped file unreadable — fall through to report-input.json */
|
|
687
|
+
}
|
|
537
688
|
}
|
|
538
|
-
const reportInput = buildLegacyCompatibleReportInput(legacy.state, context, diagnostics)
|
|
539
|
-
return { reportInput, diagnostics: diagnostics.getDiagnostics() }
|
|
540
|
-
}
|
|
541
689
|
|
|
690
|
+
const reportInputFile = resolver.paths().reportInputFile
|
|
691
|
+
if (existsSync(reportInputFile)) {
|
|
692
|
+
diagnostics.warn(
|
|
693
|
+
"REPORT_INPUT_DISK_FALLBACK",
|
|
694
|
+
`No report_input provided; reading materialized report-input.json from disk for run ${effectiveRunId}.`,
|
|
695
|
+
"report_input",
|
|
696
|
+
)
|
|
697
|
+
let parsed: unknown
|
|
698
|
+
try {
|
|
699
|
+
parsed = JSON.parse(readFileSync(reportInputFile, "utf-8"))
|
|
700
|
+
} catch {
|
|
701
|
+
diagnostics.error(
|
|
702
|
+
"REPORT_INPUT_DISK_CORRUPT",
|
|
703
|
+
`Materialized report-input.json for run ${effectiveRunId} is not valid JSON.`,
|
|
704
|
+
"report_input",
|
|
705
|
+
)
|
|
706
|
+
throwContractMismatch(
|
|
707
|
+
"ReportInput contract mismatch: corrupted disk artifact",
|
|
708
|
+
diagnostics.getDiagnostics(),
|
|
709
|
+
)
|
|
710
|
+
}
|
|
711
|
+
const validation = validateReportInput(parsed)
|
|
712
|
+
if (!validation.success) {
|
|
713
|
+
for (const error of validation.errors) {
|
|
714
|
+
diagnostics.error(
|
|
715
|
+
"REPORT_INPUT_DISK_VALIDATION_FAILED",
|
|
716
|
+
`${error.field}: ${error.message}`,
|
|
717
|
+
error.field,
|
|
718
|
+
)
|
|
719
|
+
}
|
|
720
|
+
throwContractMismatch(
|
|
721
|
+
"ReportInput contract mismatch: disk artifact failed schema validation",
|
|
722
|
+
diagnostics.getDiagnostics(),
|
|
723
|
+
)
|
|
724
|
+
}
|
|
725
|
+
return finalizeReportInputSelection(validation.data, diagnostics, expectedRunId)
|
|
726
|
+
}
|
|
727
|
+
}
|
|
542
728
|
diagnostics.error(
|
|
543
729
|
"REPORT_INPUT_MISSING",
|
|
544
|
-
|
|
730
|
+
`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
731
|
"report_input",
|
|
546
732
|
)
|
|
547
733
|
throwContractMismatch(
|
|
@@ -550,135 +736,6 @@ function parseReportInputPayload(
|
|
|
550
736
|
)
|
|
551
737
|
}
|
|
552
738
|
|
|
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
739
|
function normalizeTitle(check: string): string {
|
|
683
740
|
if (!check || typeof check !== "string") return "Unknown Check"
|
|
684
741
|
return check
|
|
@@ -756,7 +813,7 @@ function getExtendedFinding(finding: Finding): Finding & ReportFindingFields {
|
|
|
756
813
|
|
|
757
814
|
function getFindingImpact(finding: Finding): string {
|
|
758
815
|
const extended = getExtendedFinding(finding)
|
|
759
|
-
if (
|
|
816
|
+
if (isNonEmptyString(extended.impact)) {
|
|
760
817
|
return extended.impact.trim()
|
|
761
818
|
}
|
|
762
819
|
return MISSING_IMPACT_TEXT
|
|
@@ -764,10 +821,10 @@ function getFindingImpact(finding: Finding): string {
|
|
|
764
821
|
|
|
765
822
|
function getFindingRecommendation(finding: Finding): string {
|
|
766
823
|
const extended = getExtendedFinding(finding)
|
|
767
|
-
if (
|
|
824
|
+
if (isNonEmptyString(extended.recommendation)) {
|
|
768
825
|
return extended.recommendation.trim()
|
|
769
826
|
}
|
|
770
|
-
if (
|
|
827
|
+
if (isNonEmptyString(finding.remediation)) {
|
|
771
828
|
return finding.remediation.trim()
|
|
772
829
|
}
|
|
773
830
|
return MISSING_RECOMMENDATION_TEXT
|
|
@@ -775,10 +832,10 @@ function getFindingRecommendation(finding: Finding): string {
|
|
|
775
832
|
|
|
776
833
|
function getPocEvidence(finding: Finding): string | undefined {
|
|
777
834
|
const extended = getExtendedFinding(finding)
|
|
778
|
-
if (
|
|
835
|
+
if (isNonEmptyString(extended.proofOfConcept)) {
|
|
779
836
|
return extended.proofOfConcept.trim()
|
|
780
837
|
}
|
|
781
|
-
if (
|
|
838
|
+
if (isNonEmptyString(finding.exploitReference)) {
|
|
782
839
|
return finding.exploitReference.trim()
|
|
783
840
|
}
|
|
784
841
|
return undefined
|
|
@@ -973,17 +1030,17 @@ function formatDuration(ms: number): string {
|
|
|
973
1030
|
export function buildProvenanceAppendix(
|
|
974
1031
|
state: AuditState,
|
|
975
1032
|
threshold: SeverityThreshold,
|
|
976
|
-
|
|
1033
|
+
reportFindings: Finding[],
|
|
977
1034
|
): string {
|
|
978
1035
|
const lines: string[] = ["## Appendix: Data Provenance"]
|
|
979
1036
|
|
|
980
|
-
lines.push("- Data source: `report_input` payload
|
|
1037
|
+
lines.push("- Data source: `report_input` payload")
|
|
981
1038
|
lines.push(`- Severity threshold applied: ${threshold}`)
|
|
982
|
-
lines.push(`- Findings included in report: ${
|
|
1039
|
+
lines.push(`- Findings included in report: ${reportFindings.length}`)
|
|
983
1040
|
|
|
984
|
-
if (
|
|
1041
|
+
if (reportFindings.length > 0) {
|
|
985
1042
|
const sourceCounts: Record<string, number> = {}
|
|
986
|
-
for (const f of
|
|
1043
|
+
for (const f of reportFindings) {
|
|
987
1044
|
sourceCounts[f.source] = (sourceCounts[f.source] ?? 0) + 1
|
|
988
1045
|
}
|
|
989
1046
|
lines.push("")
|
|
@@ -1099,10 +1156,51 @@ export async function executeReportGeneration(
|
|
|
1099
1156
|
const includeExecutiveSummary = args.include_executive_summary ?? true
|
|
1100
1157
|
const threshold = args.severity_threshold ?? "low"
|
|
1101
1158
|
const qualityGatePolicy = args.quality_gate_policy ?? "warn"
|
|
1102
|
-
const
|
|
1159
|
+
const toolCoveragePolicy = args.tool_coverage_policy ?? "enforce"
|
|
1160
|
+
const expectedRunId = resolveExpectedRunId(args, context, deps)
|
|
1161
|
+
|
|
1162
|
+
// Ensure report-input.json is materialized before attempting disk lookup.
|
|
1163
|
+
// Scribe may call generate_report without calling read_findings first,
|
|
1164
|
+
// or read_findings may have materialized under a different run_id.
|
|
1165
|
+
if (typeof expectedRunId === "string" && expectedRunId.length > 0) {
|
|
1166
|
+
const projectDir = resolveProjectDir(context)
|
|
1167
|
+
const resolver = createAuditArtifactResolver(expectedRunId, projectDir)
|
|
1168
|
+
if (!existsSync(resolver.paths().reportInputFile)) {
|
|
1169
|
+
try {
|
|
1170
|
+
const { materializeReportInput } = await import(
|
|
1171
|
+
"../features/persistent-state/findings-materializer"
|
|
1172
|
+
)
|
|
1173
|
+
await materializeReportInput(expectedRunId, projectDir, context.sessionID)
|
|
1174
|
+
} catch {
|
|
1175
|
+
/* Best-effort: parseReportInputPayload will produce a clear error if the file is still missing */
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
const { reportInput, diagnostics } = parseReportInputPayload(args, context, expectedRunId)
|
|
1181
|
+
|
|
1103
1182
|
const preflightPolicy = args.preflight_policy ?? "warn"
|
|
1104
1183
|
let preflightWarningSection: string | null = null
|
|
1105
1184
|
const warningBullets: string[] = []
|
|
1185
|
+
|
|
1186
|
+
// Hard gate: refuse to generate a report if key audit tools have not been executed
|
|
1187
|
+
if (toolCoveragePolicy !== "skip") {
|
|
1188
|
+
const missingTools = computeMissingKeyTools(
|
|
1189
|
+
reportInput.toolsExecuted,
|
|
1190
|
+
reportInput.unavailableTools,
|
|
1191
|
+
)
|
|
1192
|
+
if (missingTools.length > 0) {
|
|
1193
|
+
const toolList = missingTools.join(", ")
|
|
1194
|
+
if (toolCoveragePolicy === "enforce") {
|
|
1195
|
+
throw new Error(
|
|
1196
|
+
`Tool coverage gate failed: the following key audit tools have not been executed: ${toolList}. ` +
|
|
1197
|
+
'Run the missing tools before generating a report, or pass tool_coverage_policy: "warn" to override.',
|
|
1198
|
+
)
|
|
1199
|
+
}
|
|
1200
|
+
warningBullets.push(`- Tool coverage incomplete: ${toolList} not executed`)
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1106
1204
|
try {
|
|
1107
1205
|
const readEventsFn = deps.readEvents ?? readEvents
|
|
1108
1206
|
const events = await readEventsFn(reportInput.run_id, reportInput.projectDir)
|
|
@@ -1183,7 +1281,22 @@ export async function executeReportGeneration(
|
|
|
1183
1281
|
)
|
|
1184
1282
|
}
|
|
1185
1283
|
const counts = calculateCounts(findings)
|
|
1186
|
-
|
|
1284
|
+
// Derive audit date from the run's start time for deterministic output.
|
|
1285
|
+
// Falls back to the earliest toolsExecuted timestamp, then current date as last resort.
|
|
1286
|
+
// Exclude UNKNOWN_TIMESTAMP_SENTINEL (patched-in value for missing timestamps).
|
|
1287
|
+
const runStartTime = reportInput.toolsExecuted.reduce(
|
|
1288
|
+
(earliest, exec) =>
|
|
1289
|
+
typeof exec.startTime === "number" &&
|
|
1290
|
+
exec.startTime > UNKNOWN_TIMESTAMP_SENTINEL &&
|
|
1291
|
+
exec.startTime < earliest
|
|
1292
|
+
? exec.startTime
|
|
1293
|
+
: earliest,
|
|
1294
|
+
Number.MAX_SAFE_INTEGER,
|
|
1295
|
+
)
|
|
1296
|
+
const auditDate =
|
|
1297
|
+
runStartTime < Number.MAX_SAFE_INTEGER
|
|
1298
|
+
? new Date(runStartTime).toISOString().slice(0, 10)
|
|
1299
|
+
: new Date().toISOString().slice(0, 10)
|
|
1187
1300
|
|
|
1188
1301
|
context.metadata({ title: `Generate audit report: ${args.project_name}` })
|
|
1189
1302
|
|
|
@@ -1238,10 +1351,13 @@ export async function executeReportGeneration(
|
|
|
1238
1351
|
sections.push(preflightWarningSection)
|
|
1239
1352
|
}
|
|
1240
1353
|
|
|
1241
|
-
sections.push(buildProvenanceAppendix(state, threshold, findings
|
|
1354
|
+
sections.push(buildProvenanceAppendix(state, threshold, findings))
|
|
1242
1355
|
|
|
1243
1356
|
// Embed report metadata for single-writer policy enforcement
|
|
1244
|
-
const runId = reportInput.run_id
|
|
1357
|
+
const runId = expectedRunId ?? reportInput.run_id
|
|
1358
|
+
if (runId.startsWith("ses_")) {
|
|
1359
|
+
throw new Error("Report generation requires canonical run_id; received OpenCode session id")
|
|
1360
|
+
}
|
|
1245
1361
|
if (runId) {
|
|
1246
1362
|
sections.push(buildReportMetadataComment(runId))
|
|
1247
1363
|
}
|
|
@@ -1269,8 +1385,17 @@ export async function executeReportGeneration(
|
|
|
1269
1385
|
const loadConfig = deps.loadConfig ?? loadArgusConfig
|
|
1270
1386
|
const projectDir = resolveProjectDir(context)
|
|
1271
1387
|
const config = loadConfig(projectDir)
|
|
1272
|
-
const
|
|
1273
|
-
const
|
|
1388
|
+
const rawOutputDir = config.reporting?.output_dir ?? ".argus/reports/"
|
|
1389
|
+
const resolvedOutput = path.resolve(projectDir, rawOutputDir)
|
|
1390
|
+
const projectRoot = projectDir.endsWith(path.sep) ? projectDir : projectDir + path.sep
|
|
1391
|
+
if (resolvedOutput !== projectDir && !resolvedOutput.startsWith(projectRoot)) {
|
|
1392
|
+
result.error = {
|
|
1393
|
+
code: "OUTPUT_DIR_TRAVERSAL",
|
|
1394
|
+
message: `output_dir "${rawOutputDir}" resolves outside the project root. Report not written.`,
|
|
1395
|
+
}
|
|
1396
|
+
return result
|
|
1397
|
+
}
|
|
1398
|
+
const fullPath = path.join(resolvedOutput, canonicalFilename)
|
|
1274
1399
|
|
|
1275
1400
|
// Single-writer policy: check for duplicate writes with same run_id
|
|
1276
1401
|
if (runId) {
|
|
@@ -1287,6 +1412,10 @@ export async function executeReportGeneration(
|
|
|
1287
1412
|
const logger = createLogger()
|
|
1288
1413
|
const message = err instanceof Error ? err.message : String(err)
|
|
1289
1414
|
logger.warn(`Failed to write report to disk: ${message}`)
|
|
1415
|
+
result.error = {
|
|
1416
|
+
code: "WRITE_FAILED",
|
|
1417
|
+
message,
|
|
1418
|
+
}
|
|
1290
1419
|
}
|
|
1291
1420
|
|
|
1292
1421
|
return result
|
|
@@ -1294,20 +1423,39 @@ export async function executeReportGeneration(
|
|
|
1294
1423
|
|
|
1295
1424
|
export const reportGeneratorTool = tool({
|
|
1296
1425
|
description:
|
|
1297
|
-
"Generate a professional markdown security audit report
|
|
1426
|
+
"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
1427
|
args: {
|
|
1299
1428
|
project_name: tool.schema.string(),
|
|
1300
1429
|
scope: tool.schema.array(tool.schema.string()),
|
|
1301
1430
|
include_executive_summary: tool.schema.boolean().default(true),
|
|
1302
1431
|
severity_threshold: tool.schema
|
|
1303
1432
|
.enum(["critical", "high", "medium", "low", "informational"])
|
|
1304
|
-
.default("
|
|
1305
|
-
report_input: tool.schema.string().optional(),
|
|
1306
|
-
audit_state: tool.schema.string().optional(),
|
|
1433
|
+
.default("informational"),
|
|
1307
1434
|
preflight_policy: tool.schema.enum(["warn", "strict-fail"]).optional(),
|
|
1435
|
+
tool_coverage_policy: tool.schema
|
|
1436
|
+
.enum(["enforce", "warn", "skip"])
|
|
1437
|
+
.optional()
|
|
1438
|
+
.describe(
|
|
1439
|
+
"Controls whether report generation requires key audit tools to have been executed. " +
|
|
1440
|
+
"Defaults to 'enforce'.",
|
|
1441
|
+
),
|
|
1442
|
+
run_id: tool.schema
|
|
1443
|
+
.string()
|
|
1444
|
+
.optional()
|
|
1445
|
+
.describe(
|
|
1446
|
+
"The canonical run ID from <argus-context>. The tool reads the materialized report-input.json from disk using this ID.",
|
|
1447
|
+
),
|
|
1308
1448
|
},
|
|
1309
1449
|
async execute(args, context) {
|
|
1310
1450
|
const result = await executeReportGeneration(args, context)
|
|
1311
|
-
|
|
1451
|
+
// Return a slim payload to avoid OpenCode truncating large tool results.
|
|
1452
|
+
// The full markdown is already written to disk at result.filePath.
|
|
1453
|
+
// Truncated JSON breaks tool-tracking-hook parsing, which prevents
|
|
1454
|
+
// reportGenerated from being set and blocks run finalization.
|
|
1455
|
+
const { report, ...slimResult } = result
|
|
1456
|
+
return JSON.stringify({
|
|
1457
|
+
...slimResult,
|
|
1458
|
+
reportSummary: `Report written to disk (${report.length} bytes, ${report.split("\n").length} lines). See filePath.`,
|
|
1459
|
+
})
|
|
1312
1460
|
},
|
|
1313
1461
|
})
|