solidity-argus 0.5.10 → 0.6.1
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 +8 -1
- package/README.md +27 -21
- package/package.json +2 -2
- package/skills/INVENTORY.md +14 -1
- package/skills/README.md +4 -2
- package/skills/references/attack-vector-deck/SKILL.md +62 -0
- package/skills/specialist-profiles/access-control-specialist/SKILL.md +31 -0
- package/skills/specialist-profiles/economic-security/SKILL.md +31 -0
- package/skills/specialist-profiles/execution-trace/SKILL.md +31 -0
- package/skills/specialist-profiles/first-principles/SKILL.md +31 -0
- package/skills/specialist-profiles/invariant/SKILL.md +31 -0
- package/skills/specialist-profiles/math-precision/SKILL.md +31 -0
- package/skills/specialist-profiles/periphery/SKILL.md +31 -0
- package/skills/specialist-profiles/vector-scan/SKILL.md +28 -0
- package/src/agents/argus-prompt.ts +59 -6
- package/src/agents/audit-specialist-prompt.ts +94 -0
- package/src/agents/pythia-prompt.ts +7 -4
- package/src/agents/scribe-prompt.ts +9 -0
- package/src/agents/sentinel-prompt.ts +12 -0
- package/src/agents/themis-prompt.ts +4 -0
- package/src/config/schema.ts +2 -0
- package/src/constants/defaults.ts +1 -0
- package/src/create-hooks.ts +9 -1
- package/src/features/background-agent/background-manager.ts +85 -2
- package/src/features/persistent-state/run-finalizer.ts +37 -3
- package/src/hooks/config-handler.ts +23 -0
- package/src/hooks/system-prompt-hook.ts +72 -2
- package/src/hooks/tool-tracking-hook.ts +50 -6
- package/src/managers/types.ts +21 -0
- package/src/shared/agent-names.ts +1 -0
- package/src/shared/lineage-validator.ts +96 -0
- package/src/shared/report-path-resolver.ts +8 -2
- package/src/state/adapters.ts +1 -1
- package/src/state/projectors.ts +50 -0
- package/src/state/schemas.ts +86 -1
- package/src/state/types.ts +25 -1
- package/src/tools/forge-coverage-tool.ts +41 -5
- package/src/tools/persist-deduped-tool.ts +45 -1
- package/src/tools/read-findings-tool.ts +46 -5
- package/src/tools/record-finding-tool.ts +10 -30
- package/src/tools/report-generator-tool.ts +135 -37
- package/src/tools/slither-tool.ts +62 -2
|
@@ -40,6 +40,8 @@ type ForgeCoverageResult = {
|
|
|
40
40
|
report: ForgeCoverageReport
|
|
41
41
|
executionTime: number
|
|
42
42
|
error?: string
|
|
43
|
+
hint?: string
|
|
44
|
+
suggested_command?: string
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
export type ForgeCommandRunner = (
|
|
@@ -73,6 +75,36 @@ function isStackTooDeep(stderr: string): boolean {
|
|
|
73
75
|
return /stack too deep/i.test(stderr)
|
|
74
76
|
}
|
|
75
77
|
|
|
78
|
+
function classifyCoverageFailure(
|
|
79
|
+
stderr: string,
|
|
80
|
+
args: NormalizedForgeCoverageArgs,
|
|
81
|
+
): Pick<ForgeCoverageResult, "hint" | "suggested_command"> | undefined {
|
|
82
|
+
if (
|
|
83
|
+
!/(optimizerSteps|unsupported optimizer|config parse|failed to parse|instrumentation)/i.test(
|
|
84
|
+
stderr,
|
|
85
|
+
)
|
|
86
|
+
) {
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const command = buildCoverageCommand({ ...args, ir_minimum: true }).join(" ")
|
|
91
|
+
return {
|
|
92
|
+
hint:
|
|
93
|
+
`Forge coverage failed for ${args.target} while parsing or instrumenting project configuration. ` +
|
|
94
|
+
"If foundry.toml uses optimizerSteps or unsupported optimizer settings, run a scoped coverage command or temporarily adjust coverage-only config manually; Argus will not edit foundry.toml.",
|
|
95
|
+
suggested_command: command,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function shouldRetryWithIrMinimum(stderr: string): boolean {
|
|
100
|
+
return (
|
|
101
|
+
isStackTooDeep(stderr) ||
|
|
102
|
+
/(optimizerSteps|unsupported optimizer|config parse|failed to parse|instrumentation)/i.test(
|
|
103
|
+
stderr,
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
76
108
|
function parsePercent(input: string): number {
|
|
77
109
|
const match = input.match(/(\d+(?:\.\d+)?)%/)
|
|
78
110
|
if (!match?.[1]) {
|
|
@@ -165,11 +197,15 @@ export async function executeForgeCoverage(
|
|
|
165
197
|
const normalizedArgs = normalizeArgs(args, context)
|
|
166
198
|
context.metadata({ title: `Run forge coverage: ${normalizedArgs.target}` })
|
|
167
199
|
|
|
168
|
-
const fail = (
|
|
200
|
+
const fail = (
|
|
201
|
+
error: string,
|
|
202
|
+
diagnostics?: Pick<ForgeCoverageResult, "hint" | "suggested_command">,
|
|
203
|
+
): ForgeCoverageResult => ({
|
|
169
204
|
success: false,
|
|
170
205
|
report: { files: [], summary: { ...EMPTY_SUMMARY } },
|
|
171
206
|
executionTime: Date.now() - startedAt,
|
|
172
207
|
error,
|
|
208
|
+
...diagnostics,
|
|
173
209
|
})
|
|
174
210
|
|
|
175
211
|
try {
|
|
@@ -181,7 +217,7 @@ export async function executeForgeCoverage(
|
|
|
181
217
|
if (
|
|
182
218
|
runResult.exitCode !== 0 &&
|
|
183
219
|
!normalizedArgs.ir_minimum &&
|
|
184
|
-
|
|
220
|
+
shouldRetryWithIrMinimum(runResult.stderr)
|
|
185
221
|
) {
|
|
186
222
|
runResult = await runCommand(buildCoverageCommand(normalizedArgs, true), {
|
|
187
223
|
signal: context.abort,
|
|
@@ -190,9 +226,9 @@ export async function executeForgeCoverage(
|
|
|
190
226
|
}
|
|
191
227
|
|
|
192
228
|
if (runResult.exitCode !== 0) {
|
|
193
|
-
|
|
194
|
-
runResult.stderr.trim() || `forge coverage exited with code ${runResult.exitCode}
|
|
195
|
-
)
|
|
229
|
+
const error =
|
|
230
|
+
runResult.stderr.trim() || `forge coverage exited with code ${runResult.exitCode}`
|
|
231
|
+
return fail(error, classifyCoverageFailure(error, normalizedArgs))
|
|
196
232
|
}
|
|
197
233
|
|
|
198
234
|
let report: ForgeCoverageReport
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { mkdir, writeFile } from "node:fs/promises"
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises"
|
|
2
2
|
import { dirname } from "node:path"
|
|
3
3
|
import { type ToolContext, tool } from "@opencode-ai/plugin"
|
|
4
4
|
import { createAuditArtifactResolver } from "../shared/audit-artifact-resolver"
|
|
5
|
+
import { validateFindingLineage } from "../shared/lineage-validator"
|
|
5
6
|
import { createLogger } from "../shared/logger"
|
|
6
7
|
import { resolveProjectDir } from "../shared/project-utils"
|
|
7
8
|
import { isNonEmptyString } from "../shared/type-guards"
|
|
@@ -22,6 +23,28 @@ export interface DedupedFindingsArtifact {
|
|
|
22
23
|
findings: CanonicalFinding[]
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
async function loadRawFindings(
|
|
27
|
+
runId: string,
|
|
28
|
+
projectDir: string,
|
|
29
|
+
): Promise<CanonicalFinding[] | null> {
|
|
30
|
+
const findingsFile = createAuditArtifactResolver(runId, projectDir).paths().findingsFile
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(await readFile(findingsFile, "utf8"))
|
|
33
|
+
if (!parsed || !Array.isArray(parsed.findings)) return null
|
|
34
|
+
return parsed.findings
|
|
35
|
+
} catch {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function missingRawFindings(runId: string): string {
|
|
41
|
+
return JSON.stringify({
|
|
42
|
+
success: false,
|
|
43
|
+
error: "MissingRawFindingsError",
|
|
44
|
+
message: `Cannot verify deduped lineage because .argus/runs/${runId}/findings.json is missing or invalid`,
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
25
48
|
export async function executePersistDeduped(
|
|
26
49
|
args: PersistDedupedArgs,
|
|
27
50
|
context: ToolContext,
|
|
@@ -55,6 +78,27 @@ export async function executePersistDeduped(
|
|
|
55
78
|
const projectDir = resolveProjectDir(context)
|
|
56
79
|
const resolver = createAuditArtifactResolver(args.run_id, projectDir)
|
|
57
80
|
const dedupedPath = resolver.paths().dedupedFindingsFile
|
|
81
|
+
const rawFindings = await loadRawFindings(args.run_id, projectDir)
|
|
82
|
+
|
|
83
|
+
if (!rawFindings) {
|
|
84
|
+
return missingRawFindings(args.run_id)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const lineage = validateFindingLineage(rawFindings, findings)
|
|
88
|
+
if (!lineage.valid) {
|
|
89
|
+
return JSON.stringify({
|
|
90
|
+
success: false,
|
|
91
|
+
error: "LineageError",
|
|
92
|
+
lineage: {
|
|
93
|
+
raw_count: lineage.raw_count,
|
|
94
|
+
mapped_count: lineage.mapped_count,
|
|
95
|
+
duplicate_observation_ids: lineage.duplicate_observation_ids,
|
|
96
|
+
phantom_observation_ids: lineage.phantom_observation_ids,
|
|
97
|
+
missing_observation_ids: lineage.missing_observation_ids,
|
|
98
|
+
count_mismatches: lineage.count_mismatches,
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
}
|
|
58
102
|
|
|
59
103
|
const artifact: DedupedFindingsArtifact = {
|
|
60
104
|
run_id: args.run_id,
|
|
@@ -12,6 +12,8 @@ import type { AuditState } from "../state/types"
|
|
|
12
12
|
|
|
13
13
|
type ReadFindingsArgs = {
|
|
14
14
|
run_id: string
|
|
15
|
+
findings_offset?: number
|
|
16
|
+
findings_limit?: number
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
type ReportFinding = Omit<
|
|
@@ -42,6 +44,11 @@ type CompactReportInput = Omit<
|
|
|
42
44
|
run_id: string
|
|
43
45
|
findings: ReportFinding[]
|
|
44
46
|
toolsExecuted: ReportToolExecution[]
|
|
47
|
+
findingsPage?: {
|
|
48
|
+
offset: number
|
|
49
|
+
limit: number
|
|
50
|
+
total: number
|
|
51
|
+
}
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
type ReadFindingsInlineResult = {
|
|
@@ -98,13 +105,31 @@ function stripInternalKeys(obj: object, keysToStrip: ReadonlySet<string>): Recor
|
|
|
98
105
|
return result
|
|
99
106
|
}
|
|
100
107
|
|
|
101
|
-
function
|
|
108
|
+
function normalizePageArgs(args: ReadFindingsArgs): { offset: number; limit: number } | null {
|
|
109
|
+
if (args.findings_offset == null && args.findings_limit == null) return null
|
|
110
|
+
|
|
111
|
+
const offset = args.findings_offset ?? 0
|
|
112
|
+
const limit = args.findings_limit ?? 50
|
|
113
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
114
|
+
throw new Error("findings_offset must be a non-negative integer")
|
|
115
|
+
}
|
|
116
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 500) {
|
|
117
|
+
throw new Error("findings_limit must be an integer between 1 and 500")
|
|
118
|
+
}
|
|
119
|
+
return { offset, limit }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function buildCompactInput(
|
|
123
|
+
reportInput: ReportInput,
|
|
124
|
+
page: { offset: number; limit: number } | null = null,
|
|
125
|
+
): CompactReportInput {
|
|
126
|
+
const rawFindings = page
|
|
127
|
+
? reportInput.findings.slice(page.offset, page.offset + page.limit)
|
|
128
|
+
: reportInput.findings
|
|
102
129
|
return {
|
|
103
130
|
run_id: reportInput.run_id,
|
|
104
131
|
projectDir: reportInput.projectDir,
|
|
105
|
-
findings:
|
|
106
|
-
(f) => stripInternalKeys(f, FINDING_INTERNAL_KEYS) as ReportFinding,
|
|
107
|
-
),
|
|
132
|
+
findings: rawFindings.map((f) => stripInternalKeys(f, FINDING_INTERNAL_KEYS) as ReportFinding),
|
|
108
133
|
toolsExecuted: reportInput.toolsExecuted.map(
|
|
109
134
|
(t) => stripInternalKeys(t, TOOL_EXECUTION_INTERNAL_KEYS) as ReportToolExecution,
|
|
110
135
|
),
|
|
@@ -118,6 +143,13 @@ function buildCompactInput(reportInput: ReportInput): CompactReportInput {
|
|
|
118
143
|
...(reportInput.proxyContracts && { proxyContracts: reportInput.proxyContracts }),
|
|
119
144
|
...(reportInput.patternVersion && { patternVersion: reportInput.patternVersion }),
|
|
120
145
|
...(reportInput.skillsLoaded && { skillsLoaded: reportInput.skillsLoaded }),
|
|
146
|
+
...(page && {
|
|
147
|
+
findingsPage: {
|
|
148
|
+
offset: page.offset,
|
|
149
|
+
limit: page.limit,
|
|
150
|
+
total: reportInput.findings.length,
|
|
151
|
+
},
|
|
152
|
+
}),
|
|
121
153
|
}
|
|
122
154
|
}
|
|
123
155
|
|
|
@@ -339,7 +371,8 @@ export async function executeReadFindings(
|
|
|
339
371
|
|
|
340
372
|
const projectDir = resolveProjectDir(context)
|
|
341
373
|
const reportInput = readAuditStateAsReportInput(projectDir, runId)
|
|
342
|
-
const
|
|
374
|
+
const page = normalizePageArgs(args)
|
|
375
|
+
const compactInput = buildCompactInput(reportInput, page)
|
|
343
376
|
|
|
344
377
|
const inlineJson = JSON.stringify({
|
|
345
378
|
success: true,
|
|
@@ -383,6 +416,14 @@ export const readFindingsTool = tool({
|
|
|
383
416
|
"Read the materialized ReportInput artifact from disk for a given run. Returns the canonical findings, tools executed, scope, and all enrichment data. Scribe should call this before generating the report.",
|
|
384
417
|
args: {
|
|
385
418
|
run_id: tool.schema.string().describe("The run ID to read findings for."),
|
|
419
|
+
findings_offset: tool.schema
|
|
420
|
+
.number()
|
|
421
|
+
.optional()
|
|
422
|
+
.describe("Optional zero-based finding offset for paged inline retrieval."),
|
|
423
|
+
findings_limit: tool.schema
|
|
424
|
+
.number()
|
|
425
|
+
.optional()
|
|
426
|
+
.describe("Optional finding page size for inline retrieval (1-500)."),
|
|
386
427
|
},
|
|
387
428
|
async execute(args, context) {
|
|
388
429
|
return executeReadFindings(args, context)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ToolContext, tool } from "@opencode-ai/plugin"
|
|
2
2
|
import { isNonEmptyString } from "../shared/type-guards"
|
|
3
3
|
import { normalizeToCanonicalFinding } from "../state/adapters"
|
|
4
|
-
import { SCHEMA_VERSION } from "../state/schemas"
|
|
4
|
+
import { type CanonicalFinding, SCHEMA_VERSION } from "../state/schemas"
|
|
5
5
|
import type { ArgusAgentName } from "../state/types"
|
|
6
6
|
|
|
7
7
|
type RecordFindingArgs = {
|
|
@@ -12,20 +12,7 @@ type RecordFindingArgs = {
|
|
|
12
12
|
type RecordFindingResponse = {
|
|
13
13
|
success: boolean
|
|
14
14
|
count: number
|
|
15
|
-
findings:
|
|
16
|
-
id: string
|
|
17
|
-
check: string
|
|
18
|
-
severity: string
|
|
19
|
-
confidence: string
|
|
20
|
-
file: string
|
|
21
|
-
description: string
|
|
22
|
-
lines: [number, number]
|
|
23
|
-
source: string
|
|
24
|
-
reported_by_agent: string
|
|
25
|
-
impact?: string
|
|
26
|
-
recommendation?: string
|
|
27
|
-
proofOfConcept?: string
|
|
28
|
-
}>
|
|
15
|
+
findings: CanonicalFinding[]
|
|
29
16
|
schema_version: string
|
|
30
17
|
note: string
|
|
31
18
|
enrichment_warnings?: string[]
|
|
@@ -63,7 +50,13 @@ function parseFindingObject(raw: string, label: "finding" | "findings"): ParseRe
|
|
|
63
50
|
}
|
|
64
51
|
|
|
65
52
|
function normalizeAgent(value: string): ArgusAgentName {
|
|
66
|
-
if (
|
|
53
|
+
if (
|
|
54
|
+
value === "argus" ||
|
|
55
|
+
value === "sentinel" ||
|
|
56
|
+
value === "pythia" ||
|
|
57
|
+
value === "audit-specialist" ||
|
|
58
|
+
value === "scribe"
|
|
59
|
+
) {
|
|
67
60
|
return value
|
|
68
61
|
}
|
|
69
62
|
|
|
@@ -196,20 +189,7 @@ export async function executeRecordFinding(
|
|
|
196
189
|
const response: RecordFindingResponse = {
|
|
197
190
|
success: true,
|
|
198
191
|
count: findings.length,
|
|
199
|
-
findings
|
|
200
|
-
id: f.id,
|
|
201
|
-
check: f.check,
|
|
202
|
-
severity: f.severity,
|
|
203
|
-
confidence: f.confidence,
|
|
204
|
-
file: f.file,
|
|
205
|
-
description: f.description,
|
|
206
|
-
lines: f.lines,
|
|
207
|
-
source: f.source,
|
|
208
|
-
reported_by_agent: f.reported_by_agent,
|
|
209
|
-
...(f.impact !== undefined ? { impact: f.impact } : {}),
|
|
210
|
-
...(f.recommendation !== undefined ? { recommendation: f.recommendation } : {}),
|
|
211
|
-
...(f.proofOfConcept !== undefined ? { proofOfConcept: f.proofOfConcept } : {}),
|
|
212
|
-
})),
|
|
192
|
+
findings,
|
|
213
193
|
schema_version: SCHEMA_VERSION,
|
|
214
194
|
note: "Findings recorded to event journal. The system assigns the canonical run_id automatically — use the run_id from <argus-context> for Scribe dispatch.",
|
|
215
195
|
...(enrichmentWarnings.length > 0
|
|
@@ -9,6 +9,7 @@ import { createAuditArtifactResolver } from "../shared/audit-artifact-resolver"
|
|
|
9
9
|
import type { DropDiagnostic } from "../shared/drop-diagnostics"
|
|
10
10
|
import { createDropDiagnosticsCollector } from "../shared/drop-diagnostics"
|
|
11
11
|
import { computeMissingKeyTools } from "../shared/key-tools"
|
|
12
|
+
import { validateFindingLineage } from "../shared/lineage-validator"
|
|
12
13
|
import { createLogger } from "../shared/logger"
|
|
13
14
|
import { resolveProjectDir } from "../shared/project-utils"
|
|
14
15
|
import { resolveReportPath } from "../shared/report-path-resolver"
|
|
@@ -38,6 +39,8 @@ type ReportGeneratorArgs = {
|
|
|
38
39
|
preflight_policy?: PreflightPolicy
|
|
39
40
|
tool_coverage_policy?: ToolCoveragePolicy
|
|
40
41
|
run_id?: string
|
|
42
|
+
revision?: number
|
|
43
|
+
force?: boolean
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
type FindingsCount = {
|
|
@@ -131,6 +134,30 @@ async function checkDuplicateWrite(
|
|
|
131
134
|
return null
|
|
132
135
|
}
|
|
133
136
|
|
|
137
|
+
async function checkSafeForceOverwrite(
|
|
138
|
+
filePath: string,
|
|
139
|
+
runId: string,
|
|
140
|
+
): Promise<{ code: string; message: string } | null> {
|
|
141
|
+
if (!existsSync(filePath)) return null
|
|
142
|
+
try {
|
|
143
|
+
const existingContent = await Bun.file(filePath).text()
|
|
144
|
+
const existingRunId = extractReportRunId(existingContent)
|
|
145
|
+
if (existingRunId === runId) return null
|
|
146
|
+
return {
|
|
147
|
+
code: "INSECURE_OVERWRITE_REFUSED",
|
|
148
|
+
message:
|
|
149
|
+
existingRunId == null
|
|
150
|
+
? `Refusing to force overwrite ${filePath}: existing file has no Argus report metadata.`
|
|
151
|
+
: `Refusing to force overwrite ${filePath}: existing report belongs to run_id "${existingRunId}", not "${runId}".`,
|
|
152
|
+
}
|
|
153
|
+
} catch (err) {
|
|
154
|
+
return {
|
|
155
|
+
code: "INSECURE_OVERWRITE_REFUSED",
|
|
156
|
+
message: `Refusing to force overwrite ${filePath}: existing file could not be read (${err instanceof Error ? err.message : String(err)}).`,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
134
161
|
const SEVERITY_ORDER: FindingSeverity[] = ["Critical", "High", "Medium", "Low", "Informational"]
|
|
135
162
|
|
|
136
163
|
const SEVERITY_PREFIX: Record<FindingSeverity, string> = {
|
|
@@ -309,6 +336,7 @@ const VALID_AGENT_VALUES = new Set<ArgusAgentName>([
|
|
|
309
336
|
"argus",
|
|
310
337
|
"sentinel",
|
|
311
338
|
"pythia",
|
|
339
|
+
"audit-specialist",
|
|
312
340
|
"scribe",
|
|
313
341
|
"unknown",
|
|
314
342
|
])
|
|
@@ -766,6 +794,23 @@ function shouldIncludeFinding(finding: Finding, threshold: SeverityThreshold): b
|
|
|
766
794
|
return FINDING_WEIGHT[finding.severity] >= THRESHOLD_WEIGHT[threshold]
|
|
767
795
|
}
|
|
768
796
|
|
|
797
|
+
function normalizeScopePath(value: string): string {
|
|
798
|
+
return value.replace(/^\.\//, "").replace(/\/+$|\\+$/g, "")
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function isFindingInScope(finding: Finding, scope: string[]): boolean {
|
|
802
|
+
if (scope.length === 0) return true
|
|
803
|
+
const file = normalizeScopePath(finding.file)
|
|
804
|
+
return scope.some((entry) => {
|
|
805
|
+
const scoped = normalizeScopePath(entry)
|
|
806
|
+
return file === scoped || file.startsWith(`${scoped}/`)
|
|
807
|
+
})
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function collectOutOfScopeFindings(findings: Finding[], scope: string[]): Finding[] {
|
|
811
|
+
return findings.filter((finding) => !isFindingInScope(finding, scope))
|
|
812
|
+
}
|
|
813
|
+
|
|
769
814
|
function calculateCounts(findings: Finding[]): FindingsCount {
|
|
770
815
|
const counts = emptyCounts()
|
|
771
816
|
|
|
@@ -876,31 +921,6 @@ function hasDedupLineage(findings: Finding[]): boolean {
|
|
|
876
921
|
})
|
|
877
922
|
}
|
|
878
923
|
|
|
879
|
-
function observationIdsForFinding(finding: Finding): string[] {
|
|
880
|
-
const observationIds = (finding as { observation_ids?: unknown }).observation_ids
|
|
881
|
-
if (Array.isArray(observationIds)) {
|
|
882
|
-
return observationIds.filter((id): id is string => typeof id === "string" && id.length > 0)
|
|
883
|
-
}
|
|
884
|
-
return typeof finding.observation_id === "string" && finding.observation_id.length > 0
|
|
885
|
-
? [finding.observation_id]
|
|
886
|
-
: []
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
function compareObservationLineage(
|
|
890
|
-
eventFindings: Finding[],
|
|
891
|
-
reportFindings: Finding[],
|
|
892
|
-
): { missing: string[]; extra: string[]; matches: boolean } {
|
|
893
|
-
const expected = new Set(eventFindings.flatMap(observationIdsForFinding))
|
|
894
|
-
const actual = new Set(reportFindings.flatMap(observationIdsForFinding))
|
|
895
|
-
const missing = Array.from(expected)
|
|
896
|
-
.filter((id) => !actual.has(id))
|
|
897
|
-
.sort((a, b) => a.localeCompare(b))
|
|
898
|
-
const extra = Array.from(actual)
|
|
899
|
-
.filter((id) => !expected.has(id))
|
|
900
|
-
.sort((a, b) => a.localeCompare(b))
|
|
901
|
-
return { missing, extra, matches: missing.length === 0 && extra.length === 0 }
|
|
902
|
-
}
|
|
903
|
-
|
|
904
924
|
export function validateReportQuality(
|
|
905
925
|
findings: Finding[],
|
|
906
926
|
policy: QualityGatePolicy,
|
|
@@ -1210,6 +1230,18 @@ export async function executeReportGeneration(
|
|
|
1210
1230
|
const qualityGatePolicy = args.quality_gate_policy ?? "warn"
|
|
1211
1231
|
const toolCoveragePolicy = args.tool_coverage_policy ?? "enforce"
|
|
1212
1232
|
const expectedRunId = resolveExpectedRunId(args, context, deps)
|
|
1233
|
+
const invalidRegenerationOptions =
|
|
1234
|
+
args.force === true && args.revision != null
|
|
1235
|
+
? {
|
|
1236
|
+
code: "INVALID_REGENERATION_OPTIONS",
|
|
1237
|
+
message: "force and revision must not both be set.",
|
|
1238
|
+
}
|
|
1239
|
+
: args.revision != null && (!Number.isInteger(args.revision) || args.revision < 2)
|
|
1240
|
+
? {
|
|
1241
|
+
code: "INVALID_REGENERATION_OPTIONS",
|
|
1242
|
+
message: "revision must be an integer greater than or equal to 2.",
|
|
1243
|
+
}
|
|
1244
|
+
: null
|
|
1213
1245
|
|
|
1214
1246
|
// Ensure report-input.json is materialized before attempting disk lookup.
|
|
1215
1247
|
// Scribe may call generate_report without calling read_findings first,
|
|
@@ -1234,6 +1266,18 @@ export async function executeReportGeneration(
|
|
|
1234
1266
|
const preflightPolicy = args.preflight_policy ?? "warn"
|
|
1235
1267
|
let preflightWarningSection: string | null = null
|
|
1236
1268
|
const warningBullets: string[] = []
|
|
1269
|
+
const state = reportInputToAuditState(reportInput)
|
|
1270
|
+
const scope = args.scope.length > 0 ? args.scope : reportInput.scope
|
|
1271
|
+
const finalFindings = dedupeFindingsForFinalOutput(reportInput.findings)
|
|
1272
|
+
const outOfScopeFindings = collectOutOfScopeFindings(finalFindings, scope)
|
|
1273
|
+
if (outOfScopeFindings.length > 0) {
|
|
1274
|
+
const locations = outOfScopeFindings.map(formatLocation).join(", ")
|
|
1275
|
+
const message = `findings outside audited scope: ${locations}`
|
|
1276
|
+
if (preflightPolicy === "strict-fail") {
|
|
1277
|
+
throw new Error(`Preflight failed (strict-fail): ${message}`)
|
|
1278
|
+
}
|
|
1279
|
+
warningBullets.push(`- ${message}`)
|
|
1280
|
+
}
|
|
1237
1281
|
|
|
1238
1282
|
// Hard gate: refuse to generate a report if key audit tools have not been executed
|
|
1239
1283
|
if (toolCoveragePolicy !== "skip") {
|
|
@@ -1284,11 +1328,24 @@ export async function executeReportGeneration(
|
|
|
1284
1328
|
const inputFindings = dedupeFindingsForFinalOutput(reportInput.findings)
|
|
1285
1329
|
const hasLineage = hasDedupLineage(reportInput.findings)
|
|
1286
1330
|
const shouldCheckParity = eventFindings.length === inputFindings.length || hasLineage
|
|
1331
|
+
const lineage = hasLineage
|
|
1332
|
+
? validateFindingLineage(projectFindings(events), reportInput.findings)
|
|
1333
|
+
: null
|
|
1287
1334
|
const parity = shouldCheckParity
|
|
1288
|
-
?
|
|
1289
|
-
?
|
|
1290
|
-
|
|
1291
|
-
|
|
1335
|
+
? lineage
|
|
1336
|
+
? {
|
|
1337
|
+
missing: lineage.missing_observation_ids,
|
|
1338
|
+
extra: lineage.phantom_observation_ids,
|
|
1339
|
+
duplicates: lineage.duplicate_observation_ids,
|
|
1340
|
+
countMismatches: lineage.count_mismatches,
|
|
1341
|
+
matches: lineage.valid,
|
|
1342
|
+
}
|
|
1343
|
+
: {
|
|
1344
|
+
...compareIssueFingerprintSets(eventFindings, inputFindings),
|
|
1345
|
+
duplicates: [],
|
|
1346
|
+
countMismatches: [],
|
|
1347
|
+
}
|
|
1348
|
+
: { missing: [], extra: [], duplicates: [], countMismatches: [], matches: true }
|
|
1292
1349
|
|
|
1293
1350
|
if (!shouldCheckParity) {
|
|
1294
1351
|
const unverifiableSummary = `event_findings=${eventFindings.length}, report_findings=${inputFindings.length}`
|
|
@@ -1319,6 +1376,14 @@ export async function executeReportGeneration(
|
|
|
1319
1376
|
if (parity.extra.length > 0) {
|
|
1320
1377
|
warningBullets.push(`- Extra ${parityLabel}: ${parity.extra.join(", ")}`)
|
|
1321
1378
|
}
|
|
1379
|
+
if (parity.duplicates.length > 0) {
|
|
1380
|
+
warningBullets.push(`- Duplicate ${parityLabel}: ${parity.duplicates.join(", ")}`)
|
|
1381
|
+
}
|
|
1382
|
+
if (parity.countMismatches.length > 0) {
|
|
1383
|
+
warningBullets.push(
|
|
1384
|
+
`- Observation count mismatches: ${parity.countMismatches.map((item) => item.check).join(", ")}`,
|
|
1385
|
+
)
|
|
1386
|
+
}
|
|
1322
1387
|
}
|
|
1323
1388
|
} catch (err) {
|
|
1324
1389
|
if (err instanceof Error && err.message.startsWith("Preflight failed (strict-fail)")) {
|
|
@@ -1340,9 +1405,6 @@ export async function executeReportGeneration(
|
|
|
1340
1405
|
].join("\n")
|
|
1341
1406
|
}
|
|
1342
1407
|
|
|
1343
|
-
const state = reportInputToAuditState(reportInput)
|
|
1344
|
-
const scope = args.scope.length > 0 ? args.scope : reportInput.scope
|
|
1345
|
-
const finalFindings = dedupeFindingsForFinalOutput(reportInput.findings)
|
|
1346
1408
|
const findings = sortFindingsDeterministically(
|
|
1347
1409
|
finalFindings.filter((finding) => shouldIncludeFinding(finding, threshold)),
|
|
1348
1410
|
)
|
|
@@ -1441,6 +1503,7 @@ export async function executeReportGeneration(
|
|
|
1441
1503
|
date: new Date(auditDate),
|
|
1442
1504
|
outputDir: ".opencode/reports/",
|
|
1443
1505
|
runId: runId || undefined,
|
|
1506
|
+
revision: args.revision,
|
|
1444
1507
|
})
|
|
1445
1508
|
|
|
1446
1509
|
const result: ReportGenerationResult = {
|
|
@@ -1453,6 +1516,11 @@ export async function executeReportGeneration(
|
|
|
1453
1516
|
contractDiagnostics: diagnostics,
|
|
1454
1517
|
}
|
|
1455
1518
|
|
|
1519
|
+
if (invalidRegenerationOptions) {
|
|
1520
|
+
result.error = invalidRegenerationOptions
|
|
1521
|
+
return result
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1456
1524
|
try {
|
|
1457
1525
|
const loadConfig = deps.loadConfig ?? loadArgusConfig
|
|
1458
1526
|
const projectDir = resolveProjectDir(context)
|
|
@@ -1467,14 +1535,28 @@ export async function executeReportGeneration(
|
|
|
1467
1535
|
}
|
|
1468
1536
|
return result
|
|
1469
1537
|
}
|
|
1470
|
-
const fullPath =
|
|
1538
|
+
const { filePath: fullPath } = resolveReportPath({
|
|
1539
|
+
contractName: args.project_name,
|
|
1540
|
+
date: new Date(auditDate),
|
|
1541
|
+
outputDir: resolvedOutput,
|
|
1542
|
+
runId: runId || undefined,
|
|
1543
|
+
revision: args.revision,
|
|
1544
|
+
})
|
|
1471
1545
|
|
|
1472
1546
|
// Single-writer policy: check for duplicate writes with same run_id
|
|
1473
1547
|
if (runId) {
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1548
|
+
if (args.force === true) {
|
|
1549
|
+
const forceError = await checkSafeForceOverwrite(fullPath, runId)
|
|
1550
|
+
if (forceError) {
|
|
1551
|
+
result.error = forceError
|
|
1552
|
+
return result
|
|
1553
|
+
}
|
|
1554
|
+
} else {
|
|
1555
|
+
const duplicateError = await checkDuplicateWrite(fullPath, runId)
|
|
1556
|
+
if (duplicateError) {
|
|
1557
|
+
result.error = duplicateError
|
|
1558
|
+
return result
|
|
1559
|
+
}
|
|
1478
1560
|
}
|
|
1479
1561
|
}
|
|
1480
1562
|
|
|
@@ -1504,6 +1586,10 @@ export const reportGeneratorTool = tool({
|
|
|
1504
1586
|
.enum(["critical", "high", "medium", "low", "informational"])
|
|
1505
1587
|
.default("informational"),
|
|
1506
1588
|
preflight_policy: tool.schema.enum(["warn", "strict-fail"]).optional(),
|
|
1589
|
+
quality_gate_policy: tool.schema
|
|
1590
|
+
.enum(["warn", "strict-fail"])
|
|
1591
|
+
.optional()
|
|
1592
|
+
.describe("Controls whether report quality gate violations warn or fail generation."),
|
|
1507
1593
|
tool_coverage_policy: tool.schema
|
|
1508
1594
|
.enum(["enforce", "warn", "skip"])
|
|
1509
1595
|
.optional()
|
|
@@ -1517,6 +1603,18 @@ export const reportGeneratorTool = tool({
|
|
|
1517
1603
|
.describe(
|
|
1518
1604
|
"The canonical run ID from <argus-context>. The tool reads the materialized report-input.json from disk using this ID.",
|
|
1519
1605
|
),
|
|
1606
|
+
revision: tool.schema
|
|
1607
|
+
.number()
|
|
1608
|
+
.optional()
|
|
1609
|
+
.describe(
|
|
1610
|
+
"Caller-supplied report revision. Must be an integer >= 2 and writes a -r{revision} file.",
|
|
1611
|
+
),
|
|
1612
|
+
force: tool.schema
|
|
1613
|
+
.boolean()
|
|
1614
|
+
.optional()
|
|
1615
|
+
.describe(
|
|
1616
|
+
"Overwrite only the base canonical report path when existing Argus metadata matches the same run_id.",
|
|
1617
|
+
),
|
|
1520
1618
|
},
|
|
1521
1619
|
async execute(args, context) {
|
|
1522
1620
|
const result = await executeReportGeneration(args, context)
|