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
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from "node:fs"
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises"
|
|
3
|
+
import { dirname, join } from "node:path"
|
|
4
|
+
import { type ToolContext, tool } from "@opencode-ai/plugin"
|
|
5
|
+
import { createAuditArtifactResolver } from "../shared/audit-artifact-resolver"
|
|
6
|
+
import { createLogger } from "../shared/logger"
|
|
7
|
+
import { defaultRootResolver } from "../shared/path-root-resolver"
|
|
8
|
+
import { resolveProjectDir } from "../shared/project-utils"
|
|
9
|
+
import type { CanonicalFinding, CanonicalToolExecution, ReportInput } from "../state/schemas"
|
|
10
|
+
import { SCHEMA_VERSION } from "../state/schemas"
|
|
11
|
+
import type { AuditState } from "../state/types"
|
|
12
|
+
|
|
13
|
+
type ReadFindingsArgs = {
|
|
14
|
+
run_id: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type ReportFinding = Omit<
|
|
18
|
+
CanonicalFinding,
|
|
19
|
+
| "run_id"
|
|
20
|
+
| "seq"
|
|
21
|
+
| "schema_version"
|
|
22
|
+
| "observation_id"
|
|
23
|
+
| "issue_fingerprint"
|
|
24
|
+
| "observation_fingerprint"
|
|
25
|
+
| "reported_by_agent"
|
|
26
|
+
| "reported_by_session_id"
|
|
27
|
+
>
|
|
28
|
+
|
|
29
|
+
type ReportToolExecution = Omit<CanonicalToolExecution, "run_id" | "schema_version">
|
|
30
|
+
|
|
31
|
+
type CompactReportInput = Omit<
|
|
32
|
+
ReportInput,
|
|
33
|
+
| "findings"
|
|
34
|
+
| "toolsExecuted"
|
|
35
|
+
| "run_id"
|
|
36
|
+
| "seq"
|
|
37
|
+
| "session_id"
|
|
38
|
+
| "tool_call_id"
|
|
39
|
+
| "source"
|
|
40
|
+
| "schema_version"
|
|
41
|
+
> & {
|
|
42
|
+
run_id: string
|
|
43
|
+
findings: ReportFinding[]
|
|
44
|
+
toolsExecuted: ReportToolExecution[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type ReadFindingsInlineResult = {
|
|
48
|
+
success: boolean
|
|
49
|
+
truncated: false
|
|
50
|
+
source: "report-input.json"
|
|
51
|
+
reportInput: CompactReportInput
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type ReadFindingsFileResult = {
|
|
55
|
+
success: boolean
|
|
56
|
+
truncated: true
|
|
57
|
+
source: "report-input.json"
|
|
58
|
+
compactReportInputFile: string
|
|
59
|
+
summary: {
|
|
60
|
+
run_id: string
|
|
61
|
+
findingsCount: number
|
|
62
|
+
toolsExecutedCount: number
|
|
63
|
+
scope: string[]
|
|
64
|
+
severityDistribution: Record<string, number>
|
|
65
|
+
topFindings: Array<{ title: string; severity: string; category: string }>
|
|
66
|
+
}
|
|
67
|
+
instructions: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type ReadFindingsResult = ReadFindingsInlineResult | ReadFindingsFileResult
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* OpenCode truncates plugin tool output above ~50KB (exact threshold unknown,
|
|
74
|
+
* but observed at 122KB). We use 40KB as a safe ceiling to avoid truncation.
|
|
75
|
+
*/
|
|
76
|
+
const OUTPUT_SIZE_THRESHOLD_BYTES = 40_000
|
|
77
|
+
|
|
78
|
+
const FINDING_INTERNAL_KEYS: ReadonlySet<string> = new Set([
|
|
79
|
+
"run_id",
|
|
80
|
+
"seq",
|
|
81
|
+
"schema_version",
|
|
82
|
+
"observation_id",
|
|
83
|
+
"issue_fingerprint",
|
|
84
|
+
"observation_fingerprint",
|
|
85
|
+
"reported_by_agent",
|
|
86
|
+
"reported_by_session_id",
|
|
87
|
+
])
|
|
88
|
+
|
|
89
|
+
const TOOL_EXECUTION_INTERNAL_KEYS: ReadonlySet<string> = new Set(["run_id", "schema_version"])
|
|
90
|
+
|
|
91
|
+
function stripInternalKeys(obj: object, keysToStrip: ReadonlySet<string>): Record<string, unknown> {
|
|
92
|
+
const result: Record<string, unknown> = {}
|
|
93
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
94
|
+
if (!keysToStrip.has(key)) {
|
|
95
|
+
result[key] = value
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return result
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function buildCompactInput(reportInput: ReportInput): CompactReportInput {
|
|
102
|
+
return {
|
|
103
|
+
run_id: reportInput.run_id,
|
|
104
|
+
projectDir: reportInput.projectDir,
|
|
105
|
+
findings: reportInput.findings.map(
|
|
106
|
+
(f) => stripInternalKeys(f, FINDING_INTERNAL_KEYS) as ReportFinding,
|
|
107
|
+
),
|
|
108
|
+
toolsExecuted: reportInput.toolsExecuted.map(
|
|
109
|
+
(t) => stripInternalKeys(t, TOOL_EXECUTION_INTERNAL_KEYS) as ReportToolExecution,
|
|
110
|
+
),
|
|
111
|
+
scope: reportInput.scope,
|
|
112
|
+
...(reportInput.soloditResults && { soloditResults: reportInput.soloditResults }),
|
|
113
|
+
...(reportInput.fuzzCounterexamples && {
|
|
114
|
+
fuzzCounterexamples: reportInput.fuzzCounterexamples,
|
|
115
|
+
}),
|
|
116
|
+
...(reportInput.coverageReport && { coverageReport: reportInput.coverageReport }),
|
|
117
|
+
...(reportInput.gasHotspots && { gasHotspots: reportInput.gasHotspots }),
|
|
118
|
+
...(reportInput.proxyContracts && { proxyContracts: reportInput.proxyContracts }),
|
|
119
|
+
...(reportInput.patternVersion && { patternVersion: reportInput.patternVersion }),
|
|
120
|
+
...(reportInput.skillsLoaded && { skillsLoaded: reportInput.skillsLoaded }),
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function buildSeverityDistribution(findings: ReportFinding[]): Record<string, number> {
|
|
125
|
+
const dist: Record<string, number> = {}
|
|
126
|
+
for (const f of findings) {
|
|
127
|
+
const sev = (f as Record<string, unknown>).severity as string | undefined
|
|
128
|
+
const key = sev ?? "Unknown"
|
|
129
|
+
dist[key] = (dist[key] ?? 0) + 1
|
|
130
|
+
}
|
|
131
|
+
return dist
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function buildTopFindings(
|
|
135
|
+
findings: ReportFinding[],
|
|
136
|
+
limit = 10,
|
|
137
|
+
): Array<{ title: string; severity: string; category: string }> {
|
|
138
|
+
const severityOrder: Record<string, number> = {
|
|
139
|
+
Critical: 0,
|
|
140
|
+
High: 1,
|
|
141
|
+
Medium: 2,
|
|
142
|
+
Low: 3,
|
|
143
|
+
Informational: 4,
|
|
144
|
+
}
|
|
145
|
+
const sorted = [...findings].sort((a, b) => {
|
|
146
|
+
const ra = a as Record<string, unknown>
|
|
147
|
+
const rb = b as Record<string, unknown>
|
|
148
|
+
const sa = severityOrder[(ra.severity as string) ?? ""] ?? 5
|
|
149
|
+
const sb = severityOrder[(rb.severity as string) ?? ""] ?? 5
|
|
150
|
+
return sa - sb
|
|
151
|
+
})
|
|
152
|
+
return sorted.slice(0, limit).map((f) => {
|
|
153
|
+
const r = f as Record<string, unknown>
|
|
154
|
+
return {
|
|
155
|
+
title: (r.title as string) ?? (r.description as string)?.slice(0, 80) ?? "Untitled",
|
|
156
|
+
severity: (r.severity as string) ?? "Unknown",
|
|
157
|
+
category: (r.category as string) ?? "Unknown",
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function convertAuditStateToReportInput(
|
|
163
|
+
state: AuditState,
|
|
164
|
+
runId: string,
|
|
165
|
+
projectDir: string,
|
|
166
|
+
): ReportInput {
|
|
167
|
+
const findings: CanonicalFinding[] = (state.findings ?? []).map((f, i) => ({
|
|
168
|
+
...f,
|
|
169
|
+
run_id: state.sessionId ?? runId,
|
|
170
|
+
seq: i + 1,
|
|
171
|
+
session_id: "audit",
|
|
172
|
+
tool_call_id: "",
|
|
173
|
+
source: f.source ?? ("unknown" as const),
|
|
174
|
+
schema_version: SCHEMA_VERSION,
|
|
175
|
+
issue_fingerprint: f.id ?? "",
|
|
176
|
+
observation_fingerprint: f.id ?? "",
|
|
177
|
+
observation_id: f.id ?? "",
|
|
178
|
+
reported_by_agent: f.reported_by_agent ?? ("unknown" as const),
|
|
179
|
+
reported_by_session_id: f.reported_by_session_id ?? "",
|
|
180
|
+
}))
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
run_id: state.sessionId ?? runId,
|
|
184
|
+
seq: findings.length,
|
|
185
|
+
session_id: "audit",
|
|
186
|
+
tool_call_id: "",
|
|
187
|
+
source: "audit-state",
|
|
188
|
+
schema_version: SCHEMA_VERSION,
|
|
189
|
+
projectDir: state.projectDir ?? projectDir,
|
|
190
|
+
findings,
|
|
191
|
+
toolsExecuted: (state.toolsExecuted ?? []).map((t) => ({
|
|
192
|
+
...t,
|
|
193
|
+
run_id: state.sessionId ?? runId,
|
|
194
|
+
schema_version: SCHEMA_VERSION,
|
|
195
|
+
})),
|
|
196
|
+
scope: state.scope?.length
|
|
197
|
+
? state.scope
|
|
198
|
+
: [...new Set(findings.map((f) => f.file).filter(Boolean))],
|
|
199
|
+
soloditResults: state.soloditResults,
|
|
200
|
+
fuzzCounterexamples: state.fuzzCounterexamples,
|
|
201
|
+
coverageReport: state.coverageReport,
|
|
202
|
+
gasHotspots: state.gasHotspots,
|
|
203
|
+
proxyContracts: state.proxyContracts,
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Scan .argus/sessions/ for the newest state file with findings.
|
|
209
|
+
* Mirrors the fallback logic in audit-state-manager.ts load().
|
|
210
|
+
*/
|
|
211
|
+
function readNewestSessionState(argusRoot: string): AuditState | null {
|
|
212
|
+
const sessionsDir = join(argusRoot, "sessions")
|
|
213
|
+
try {
|
|
214
|
+
const entries = readdirSync(sessionsDir)
|
|
215
|
+
const stateFiles = entries.filter((e) => e.startsWith("state-") && e.endsWith(".json"))
|
|
216
|
+
if (stateFiles.length === 0) return null
|
|
217
|
+
|
|
218
|
+
const ranked = stateFiles
|
|
219
|
+
.map((name) => {
|
|
220
|
+
const filePath = join(sessionsDir, name)
|
|
221
|
+
try {
|
|
222
|
+
return { name, path: filePath, mtime: statSync(filePath).mtimeMs }
|
|
223
|
+
} catch {
|
|
224
|
+
return null
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
.filter((entry): entry is NonNullable<typeof entry> => entry !== null)
|
|
228
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
229
|
+
|
|
230
|
+
for (const entry of ranked) {
|
|
231
|
+
try {
|
|
232
|
+
const state = JSON.parse(readFileSync(entry.path, "utf8")) as AuditState
|
|
233
|
+
if (state.findings && state.findings.length > 0) {
|
|
234
|
+
return state
|
|
235
|
+
}
|
|
236
|
+
} catch {
|
|
237
|
+
/* skip unreadable files */
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
} catch {
|
|
241
|
+
/* sessions dir doesn't exist */
|
|
242
|
+
}
|
|
243
|
+
return null
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function readAuditStateAsReportInput(projectDir: string, runId: string): ReportInput {
|
|
247
|
+
const logger = createLogger()
|
|
248
|
+
const argusRoot = defaultRootResolver.writeRoot(projectDir)
|
|
249
|
+
|
|
250
|
+
const dedupedFile = createAuditArtifactResolver(runId, projectDir).paths().dedupedFindingsFile
|
|
251
|
+
try {
|
|
252
|
+
const dedupedRaw = JSON.parse(readFileSync(dedupedFile, "utf8")) as {
|
|
253
|
+
findings?: unknown[]
|
|
254
|
+
run_id?: string
|
|
255
|
+
}
|
|
256
|
+
if (Array.isArray(dedupedRaw.findings) && dedupedRaw.findings.length > 0) {
|
|
257
|
+
logger.debug(`Loaded deduped findings from: ${dedupedFile}`)
|
|
258
|
+
|
|
259
|
+
const perRunFile = createAuditArtifactResolver(runId, projectDir).paths().reportInputFile
|
|
260
|
+
let baseReportInput: Partial<ReportInput> = {}
|
|
261
|
+
try {
|
|
262
|
+
baseReportInput = JSON.parse(readFileSync(perRunFile, "utf8")) as Partial<ReportInput>
|
|
263
|
+
} catch {}
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
...baseReportInput,
|
|
267
|
+
run_id: dedupedRaw.run_id ?? runId,
|
|
268
|
+
findings: dedupedRaw.findings as CanonicalFinding[],
|
|
269
|
+
toolsExecuted: baseReportInput.toolsExecuted ?? [],
|
|
270
|
+
scope: baseReportInput.scope ?? [],
|
|
271
|
+
projectDir: baseReportInput.projectDir ?? projectDir,
|
|
272
|
+
seq: 0,
|
|
273
|
+
session_id: "audit",
|
|
274
|
+
tool_call_id: "",
|
|
275
|
+
source: "deduped-findings",
|
|
276
|
+
schema_version: SCHEMA_VERSION,
|
|
277
|
+
} as ReportInput
|
|
278
|
+
}
|
|
279
|
+
} catch {
|
|
280
|
+
logger.debug(`No deduped findings at ${dedupedFile}`)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 1. Per-run report-input artifact (materialized by findings-materializer into runs/{runId}/)
|
|
284
|
+
const perRunFile = createAuditArtifactResolver(runId, projectDir).paths().reportInputFile
|
|
285
|
+
try {
|
|
286
|
+
const data = JSON.parse(readFileSync(perRunFile, "utf8")) as ReportInput
|
|
287
|
+
if (data.findings && data.findings.length > 0) {
|
|
288
|
+
logger.debug(`Loaded report-input from per-run artifact: ${perRunFile}`)
|
|
289
|
+
return data
|
|
290
|
+
}
|
|
291
|
+
} catch {
|
|
292
|
+
logger.debug(`No per-run report-input at ${perRunFile}`)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// 2. Flat report-input at argus root (legacy location)
|
|
296
|
+
const flatFile = join(argusRoot, "report-input.json")
|
|
297
|
+
try {
|
|
298
|
+
const data = JSON.parse(readFileSync(flatFile, "utf8")) as ReportInput
|
|
299
|
+
if (data.findings && data.findings.length > 0) {
|
|
300
|
+
logger.debug(`Loaded report-input from flat file: ${flatFile}`)
|
|
301
|
+
return data
|
|
302
|
+
}
|
|
303
|
+
} catch {
|
|
304
|
+
logger.debug(`No flat report-input at ${flatFile}`)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 3. Per-session state files (per-session managers write to sessions/state-{sessionId}.json)
|
|
308
|
+
const sessionState = readNewestSessionState(argusRoot)
|
|
309
|
+
if (sessionState) {
|
|
310
|
+
logger.debug("Loaded audit state from newest session state file")
|
|
311
|
+
return convertAuditStateToReportInput(sessionState, runId, projectDir)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// 4. Shared audit state (legacy fallback)
|
|
315
|
+
const sharedStateFile = join(argusRoot, "argus-state.json")
|
|
316
|
+
try {
|
|
317
|
+
const state = JSON.parse(readFileSync(sharedStateFile, "utf8")) as AuditState
|
|
318
|
+
if (state.findings && state.findings.length > 0) {
|
|
319
|
+
logger.debug(`Loaded audit state from shared file: ${sharedStateFile}`)
|
|
320
|
+
return convertAuditStateToReportInput(state, runId, projectDir)
|
|
321
|
+
}
|
|
322
|
+
} catch {
|
|
323
|
+
/* shared state not available */
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
throw new Error(
|
|
327
|
+
`Cannot read findings from any source for run ${runId}. Checked: per-run artifact (${perRunFile}), flat file (${flatFile}), session state files, shared state (${sharedStateFile})`,
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export async function executeReadFindings(
|
|
332
|
+
args: ReadFindingsArgs,
|
|
333
|
+
context: ToolContext,
|
|
334
|
+
): Promise<string> {
|
|
335
|
+
const runId = args.run_id
|
|
336
|
+
if (!runId || runId.trim().length === 0) {
|
|
337
|
+
throw new Error("run_id is required")
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const projectDir = resolveProjectDir(context)
|
|
341
|
+
const reportInput = readAuditStateAsReportInput(projectDir, runId)
|
|
342
|
+
const compactInput = buildCompactInput(reportInput)
|
|
343
|
+
|
|
344
|
+
const inlineJson = JSON.stringify({
|
|
345
|
+
success: true,
|
|
346
|
+
truncated: false,
|
|
347
|
+
source: "report-input.json" as const,
|
|
348
|
+
reportInput: compactInput,
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
if (Buffer.byteLength(inlineJson, "utf-8") <= OUTPUT_SIZE_THRESHOLD_BYTES) {
|
|
352
|
+
return inlineJson
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const resolver = createAuditArtifactResolver(runId, projectDir)
|
|
356
|
+
const compactFilePath = resolver
|
|
357
|
+
.paths()
|
|
358
|
+
.reportInputFile.replace("report-input.json", "compact-report-input.json")
|
|
359
|
+
await mkdir(dirname(compactFilePath), { recursive: true })
|
|
360
|
+
await writeFile(compactFilePath, JSON.stringify(compactInput, null, 2))
|
|
361
|
+
|
|
362
|
+
const fileResult: ReadFindingsFileResult = {
|
|
363
|
+
success: true,
|
|
364
|
+
truncated: true,
|
|
365
|
+
source: "report-input.json",
|
|
366
|
+
compactReportInputFile: compactFilePath,
|
|
367
|
+
summary: {
|
|
368
|
+
run_id: runId,
|
|
369
|
+
findingsCount: compactInput.findings.length,
|
|
370
|
+
toolsExecutedCount: compactInput.toolsExecuted.length,
|
|
371
|
+
scope: compactInput.scope,
|
|
372
|
+
severityDistribution: buildSeverityDistribution(compactInput.findings),
|
|
373
|
+
topFindings: buildTopFindings(compactInput.findings),
|
|
374
|
+
},
|
|
375
|
+
instructions: `Output exceeds safe inline size (${Buffer.byteLength(inlineJson, "utf-8")} bytes). Full compact data written to: ${compactFilePath}. Use the read tool to access the file contents before generating the report.`,
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return JSON.stringify(fileResult)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export const readFindingsTool = tool({
|
|
382
|
+
description:
|
|
383
|
+
"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
|
+
args: {
|
|
385
|
+
run_id: tool.schema.string().describe("The run ID to read findings for."),
|
|
386
|
+
},
|
|
387
|
+
async execute(args, context) {
|
|
388
|
+
return executeReadFindings(args, context)
|
|
389
|
+
},
|
|
390
|
+
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ToolContext, tool } from "@opencode-ai/plugin"
|
|
2
|
+
import { isNonEmptyString } from "../shared/type-guards"
|
|
2
3
|
import { normalizeToCanonicalFinding } from "../state/adapters"
|
|
3
4
|
import { SCHEMA_VERSION } from "../state/schemas"
|
|
4
5
|
import type { ArgusAgentName } from "../state/types"
|
|
@@ -11,33 +12,47 @@ type RecordFindingArgs = {
|
|
|
11
12
|
type RecordFindingResponse = {
|
|
12
13
|
success: boolean
|
|
13
14
|
count: number
|
|
14
|
-
findings:
|
|
15
|
+
findings: Array<{
|
|
16
|
+
id: string
|
|
17
|
+
check: string
|
|
18
|
+
severity: string
|
|
19
|
+
file: string
|
|
20
|
+
description: string
|
|
21
|
+
lines: [number, number]
|
|
22
|
+
source: string
|
|
23
|
+
}>
|
|
15
24
|
schema_version: string
|
|
25
|
+
note: string
|
|
16
26
|
}
|
|
17
27
|
|
|
18
|
-
|
|
28
|
+
type ParseResult = { ok: true; data: Record<string, unknown>[] } | { ok: false; error: string }
|
|
29
|
+
|
|
30
|
+
function parseFindingObject(raw: string, label: "finding" | "findings"): ParseResult {
|
|
19
31
|
let parsed: unknown
|
|
20
32
|
try {
|
|
21
33
|
parsed = JSON.parse(raw)
|
|
22
34
|
} catch {
|
|
23
|
-
|
|
35
|
+
return { ok: false, error: `${label} must be valid JSON` }
|
|
24
36
|
}
|
|
25
37
|
|
|
26
38
|
if (label === "finding") {
|
|
27
39
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
28
|
-
|
|
40
|
+
return { ok: false, error: "finding must be a JSON object" }
|
|
29
41
|
}
|
|
30
|
-
return [parsed as Record<string, unknown>]
|
|
42
|
+
return { ok: true, data: [parsed as Record<string, unknown>] }
|
|
31
43
|
}
|
|
32
44
|
|
|
33
45
|
if (!Array.isArray(parsed)) {
|
|
34
|
-
|
|
46
|
+
return { ok: false, error: "findings must be a JSON array" }
|
|
35
47
|
}
|
|
36
48
|
|
|
37
|
-
return
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
49
|
+
return {
|
|
50
|
+
ok: true,
|
|
51
|
+
data: parsed.filter(
|
|
52
|
+
(item): item is Record<string, unknown> =>
|
|
53
|
+
typeof item === "object" && item !== null && !Array.isArray(item),
|
|
54
|
+
),
|
|
55
|
+
}
|
|
41
56
|
}
|
|
42
57
|
|
|
43
58
|
function normalizeAgent(value: string): ArgusAgentName {
|
|
@@ -48,36 +63,80 @@ function normalizeAgent(value: string): ArgusAgentName {
|
|
|
48
63
|
return "unknown"
|
|
49
64
|
}
|
|
50
65
|
|
|
66
|
+
function errorResponse(error: string): string {
|
|
67
|
+
return JSON.stringify({
|
|
68
|
+
success: false,
|
|
69
|
+
count: 0,
|
|
70
|
+
findings: [],
|
|
71
|
+
schema_version: SCHEMA_VERSION,
|
|
72
|
+
note: error,
|
|
73
|
+
error,
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
51
77
|
export async function executeRecordFinding(
|
|
52
78
|
args: RecordFindingArgs,
|
|
53
79
|
context: ToolContext,
|
|
54
80
|
): Promise<string> {
|
|
55
81
|
const rawFindings: Record<string, unknown>[] = []
|
|
56
82
|
|
|
57
|
-
if (
|
|
58
|
-
|
|
83
|
+
if (isNonEmptyString(args.finding)) {
|
|
84
|
+
const result = parseFindingObject(args.finding, "finding")
|
|
85
|
+
if (!result.ok) return errorResponse(result.error)
|
|
86
|
+
rawFindings.push(...result.data)
|
|
59
87
|
}
|
|
60
|
-
if (
|
|
61
|
-
|
|
88
|
+
if (isNonEmptyString(args.findings)) {
|
|
89
|
+
const result = parseFindingObject(args.findings, "findings")
|
|
90
|
+
if (!result.ok) return errorResponse(result.error)
|
|
91
|
+
rawFindings.push(...result.data)
|
|
62
92
|
}
|
|
63
93
|
|
|
64
94
|
if (rawFindings.length === 0) {
|
|
65
|
-
|
|
95
|
+
return errorResponse("Provide at least one finding via finding or findings")
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const f of rawFindings) {
|
|
99
|
+
if (!f.check && typeof f.title === "string") f.check = f.title
|
|
100
|
+
if (!f.check && typeof f.name === "string") f.check = f.name
|
|
101
|
+
if (!f.file && typeof f.location === "string") {
|
|
102
|
+
const loc = f.location as string
|
|
103
|
+
const colonIdx = loc.lastIndexOf(":")
|
|
104
|
+
if (colonIdx > 0 && /^\d+(-\d+)?$/.test(loc.substring(colonIdx + 1))) {
|
|
105
|
+
f.file = loc.substring(0, colonIdx)
|
|
106
|
+
if (!f.lines) {
|
|
107
|
+
const match = loc.substring(colonIdx + 1).match(/^(\d+)(?:-(\d+))?$/)
|
|
108
|
+
if (match)
|
|
109
|
+
f.lines = [
|
|
110
|
+
Number.parseInt(match[1] ?? "0", 10),
|
|
111
|
+
Number.parseInt(match[2] ?? match[1] ?? "0", 10),
|
|
112
|
+
]
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
f.file = loc
|
|
116
|
+
}
|
|
117
|
+
}
|
|
66
118
|
}
|
|
67
119
|
|
|
68
120
|
const reportedByAgent = normalizeAgent(context.agent)
|
|
69
121
|
const reportedBySessionId = context.sessionID
|
|
70
|
-
const runId =
|
|
122
|
+
const runId = "tool-local"
|
|
123
|
+
const projectDir = context.directory ?? process.cwd()
|
|
71
124
|
|
|
72
125
|
const findings: ReturnType<typeof normalizeToCanonicalFinding>["data"][] = []
|
|
73
126
|
const errors: string[] = []
|
|
74
127
|
|
|
75
128
|
for (const [index, rawFinding] of rawFindings.entries()) {
|
|
76
|
-
const normalized = normalizeToCanonicalFinding(
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
129
|
+
const normalized = normalizeToCanonicalFinding(
|
|
130
|
+
rawFinding,
|
|
131
|
+
runId,
|
|
132
|
+
index + 1,
|
|
133
|
+
{
|
|
134
|
+
reportedByAgent,
|
|
135
|
+
reportedBySessionId,
|
|
136
|
+
observationId: `${reportedBySessionId}:${index + 1}`,
|
|
137
|
+
},
|
|
138
|
+
projectDir,
|
|
139
|
+
)
|
|
81
140
|
|
|
82
141
|
const diagnosticsErrors = normalized.diagnostics.filter((diag) => diag.level === "error")
|
|
83
142
|
if (diagnosticsErrors.length > 0) {
|
|
@@ -93,14 +152,46 @@ export async function executeRecordFinding(
|
|
|
93
152
|
}
|
|
94
153
|
|
|
95
154
|
if (errors.length > 0) {
|
|
96
|
-
|
|
155
|
+
return errorResponse(`Failed to record finding(s): ${errors.join("; ")}`)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Warn when Critical/High findings are missing enrichment fields
|
|
159
|
+
const enrichmentWarnings: string[] = []
|
|
160
|
+
const HIGH_SEVERITIES = new Set(["Critical", "High"])
|
|
161
|
+
for (const f of findings) {
|
|
162
|
+
if (!HIGH_SEVERITIES.has(f.severity)) continue
|
|
163
|
+
const missing: string[] = []
|
|
164
|
+
if (!f.impact) missing.push("impact")
|
|
165
|
+
if (!f.recommendation) missing.push("recommendation")
|
|
166
|
+
if (!f.proofOfConcept) missing.push("proofOfConcept")
|
|
167
|
+
if (missing.length > 0) {
|
|
168
|
+
enrichmentWarnings.push(
|
|
169
|
+
`[${f.severity}] ${f.check} in ${f.file} is missing: ${missing.join(", ")}. Quality gate will flag this.`,
|
|
170
|
+
)
|
|
171
|
+
}
|
|
97
172
|
}
|
|
98
173
|
|
|
99
174
|
const response: RecordFindingResponse = {
|
|
100
175
|
success: true,
|
|
101
176
|
count: findings.length,
|
|
102
|
-
findings
|
|
177
|
+
findings: findings.map((f) => ({
|
|
178
|
+
id: f.id,
|
|
179
|
+
check: f.check,
|
|
180
|
+
severity: f.severity,
|
|
181
|
+
file: f.file,
|
|
182
|
+
description: f.description,
|
|
183
|
+
lines: f.lines,
|
|
184
|
+
source: f.source,
|
|
185
|
+
})),
|
|
103
186
|
schema_version: SCHEMA_VERSION,
|
|
187
|
+
note: "Findings recorded to event journal. The system assigns the canonical run_id automatically — use the run_id from <argus-context> for Scribe dispatch.",
|
|
188
|
+
...(enrichmentWarnings.length > 0
|
|
189
|
+
? {
|
|
190
|
+
enrichment_warnings: enrichmentWarnings,
|
|
191
|
+
enrichment_hint:
|
|
192
|
+
"Critical and High findings MUST include impact, recommendation, and proofOfConcept fields. Re-submit with these fields to pass the quality gate.",
|
|
193
|
+
}
|
|
194
|
+
: {}),
|
|
104
195
|
}
|
|
105
196
|
|
|
106
197
|
return JSON.stringify(response)
|
|
@@ -113,11 +204,15 @@ export const recordFindingTool = tool({
|
|
|
113
204
|
finding: tool.schema
|
|
114
205
|
.string()
|
|
115
206
|
.optional()
|
|
116
|
-
.describe(
|
|
207
|
+
.describe(
|
|
208
|
+
'Serialized JSON object for a single finding. Required fields: check (string, e.g. "reentrancy-eth"), severity (Critical|High|Medium|Low|Informational), confidence (High|Medium|Low), description (string), file (relative path, e.g. "src/Vault.sol"), lines ([startLine, endLine] tuple), source ("manual"). Optional: impact, recommendation, proofOfConcept (mandatory for Critical/High).',
|
|
209
|
+
),
|
|
117
210
|
findings: tool.schema
|
|
118
211
|
.string()
|
|
119
212
|
.optional()
|
|
120
|
-
.describe(
|
|
213
|
+
.describe(
|
|
214
|
+
"Serialized JSON array of finding objects. Each object requires the same fields as the finding parameter: check, severity, confidence, description, file, lines, source. Aliases title/name → check and location → file are accepted but canonical names are preferred.",
|
|
215
|
+
),
|
|
121
216
|
},
|
|
122
217
|
async execute(args, context) {
|
|
123
218
|
return executeRecordFinding(args, context)
|