solidity-argus 0.3.4 → 0.3.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/package.json +4 -4
- package/src/agents/argus-prompt.ts +56 -2
- package/src/agents/pythia-prompt.ts +11 -0
- package/src/agents/scribe-prompt.ts +9 -4
- package/src/agents/sentinel-prompt.ts +10 -0
- package/src/cli/commands/init.ts +1 -1
- package/src/config/schema.ts +2 -2
- package/src/create-hooks.ts +95 -12
- package/src/create-tools.ts +2 -0
- package/src/features/audit-enforcer/audit-enforcer.ts +30 -2
- package/src/features/persistent-state/audit-state-manager.ts +180 -10
- package/src/features/persistent-state/event-sink.ts +15 -6
- package/src/features/persistent-state/findings-materializer.ts +52 -0
- package/src/features/persistent-state/index.ts +1 -1
- package/src/features/persistent-state/run-finalizer.ts +26 -7
- package/src/features/persistent-state/run-journal.ts +12 -4
- package/src/hooks/event-hook.ts +4 -1
- package/src/hooks/system-prompt-hook.ts +15 -0
- package/src/hooks/tool-tracking-hook.ts +168 -10
- package/src/shared/audit-artifact-resolver.ts +13 -12
- package/src/shared/file-utils.ts +7 -2
- package/src/shared/index.ts +8 -8
- package/src/shared/path-root-resolver.ts +34 -0
- package/src/shared/plugin-metadata.ts +23 -0
- package/src/shared/report-path-resolver.ts +3 -3
- package/src/state/adapters.ts +99 -5
- package/src/state/finding-aggregation.ts +100 -0
- package/src/state/finding-fingerprint.ts +47 -0
- package/src/state/finding-store.ts +19 -29
- package/src/state/projectors.ts +18 -4
- package/src/state/schemas.ts +145 -1
- package/src/state/types.ts +17 -1
- package/src/tools/record-finding-tool.ts +125 -0
- package/src/tools/report-generator-tool.ts +116 -7
- package/src/tools/report-preflight.ts +79 -0
- package/src/tools/solodit-search-tool.ts +6 -2
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { CanonicalFinding } from "./schemas"
|
|
2
|
+
|
|
3
|
+
const SEVERITY_RANK: Record<CanonicalFinding["severity"], number> = {
|
|
4
|
+
Critical: 0,
|
|
5
|
+
High: 1,
|
|
6
|
+
Medium: 2,
|
|
7
|
+
Low: 3,
|
|
8
|
+
Informational: 4,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function uniqueSorted(values: string[]): string[] {
|
|
12
|
+
return Array.from(new Set(values)).sort((left, right) => left.localeCompare(right))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function compareObservations(left: CanonicalFinding, right: CanonicalFinding): number {
|
|
16
|
+
if (left.seq !== right.seq) return left.seq - right.seq
|
|
17
|
+
return left.observation_id.localeCompare(right.observation_id)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function compareFinalFindings(left: CanonicalFinding, right: CanonicalFinding): number {
|
|
21
|
+
const bySeverity = SEVERITY_RANK[left.severity] - SEVERITY_RANK[right.severity]
|
|
22
|
+
if (bySeverity !== 0) return bySeverity
|
|
23
|
+
|
|
24
|
+
const byFile = left.file.localeCompare(right.file)
|
|
25
|
+
if (byFile !== 0) return byFile
|
|
26
|
+
|
|
27
|
+
const byLine = left.lines[0] - right.lines[0]
|
|
28
|
+
if (byLine !== 0) return byLine
|
|
29
|
+
|
|
30
|
+
return left.issue_fingerprint.localeCompare(right.issue_fingerprint)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function dedupeFindingsForFinalOutput(findings: CanonicalFinding[]): CanonicalFinding[] {
|
|
34
|
+
const byIssue = new Map<string, CanonicalFinding[]>()
|
|
35
|
+
for (const finding of findings) {
|
|
36
|
+
const group = byIssue.get(finding.issue_fingerprint)
|
|
37
|
+
if (group) {
|
|
38
|
+
group.push(finding)
|
|
39
|
+
} else {
|
|
40
|
+
byIssue.set(finding.issue_fingerprint, [finding])
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const merged: CanonicalFinding[] = []
|
|
45
|
+
|
|
46
|
+
for (const [issueFingerprint, observations] of byIssue.entries()) {
|
|
47
|
+
const sortedObservations = observations.slice().sort(compareObservations)
|
|
48
|
+
const base = sortedObservations[0]
|
|
49
|
+
if (!base) continue
|
|
50
|
+
|
|
51
|
+
const reportedByAgents = uniqueSorted(
|
|
52
|
+
sortedObservations.map((finding) => finding.reported_by_agent),
|
|
53
|
+
)
|
|
54
|
+
const sources = uniqueSorted(sortedObservations.map((finding) => finding.source))
|
|
55
|
+
const observationIds = sortedObservations
|
|
56
|
+
.map((finding) => finding.observation_id)
|
|
57
|
+
.sort((left, right) => left.localeCompare(right))
|
|
58
|
+
|
|
59
|
+
merged.push({
|
|
60
|
+
...base,
|
|
61
|
+
id: issueFingerprint,
|
|
62
|
+
sources,
|
|
63
|
+
reported_by_agents: reportedByAgents,
|
|
64
|
+
observation_ids: observationIds,
|
|
65
|
+
observation_count: sortedObservations.length,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return merged.sort(compareFinalFindings)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function issueFingerprintSet(findings: CanonicalFinding[]): Set<string> {
|
|
73
|
+
const set = new Set<string>()
|
|
74
|
+
for (const finding of findings) {
|
|
75
|
+
set.add(finding.issue_fingerprint)
|
|
76
|
+
}
|
|
77
|
+
return set
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function compareIssueFingerprintSets(
|
|
81
|
+
expected: CanonicalFinding[],
|
|
82
|
+
actual: CanonicalFinding[],
|
|
83
|
+
): { missing: string[]; extra: string[]; matches: boolean } {
|
|
84
|
+
const expectedSet = issueFingerprintSet(expected)
|
|
85
|
+
const actualSet = issueFingerprintSet(actual)
|
|
86
|
+
|
|
87
|
+
const missing = Array.from(expectedSet)
|
|
88
|
+
.filter((fingerprint) => !actualSet.has(fingerprint))
|
|
89
|
+
.sort((left, right) => left.localeCompare(right))
|
|
90
|
+
|
|
91
|
+
const extra = Array.from(actualSet)
|
|
92
|
+
.filter((fingerprint) => !expectedSet.has(fingerprint))
|
|
93
|
+
.sort((left, right) => left.localeCompare(right))
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
missing,
|
|
97
|
+
extra,
|
|
98
|
+
matches: missing.length === 0 && extra.length === 0,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import type { ArgusAgentName, FindingSeverity } from "./types"
|
|
3
|
+
|
|
4
|
+
type IssueFingerprintInput = {
|
|
5
|
+
check: string
|
|
6
|
+
file: string
|
|
7
|
+
lines: [number, number]
|
|
8
|
+
severity: FindingSeverity
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type ObservationFingerprintInput = {
|
|
12
|
+
issueFingerprint: string
|
|
13
|
+
source: string
|
|
14
|
+
reportedByAgent: ArgusAgentName
|
|
15
|
+
toolCallId?: string
|
|
16
|
+
sessionId?: string
|
|
17
|
+
observationId?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function hash(parts: string[]): string {
|
|
21
|
+
return createHash("sha256").update(parts.join("|"), "utf8").digest("hex")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizeText(value: string): string {
|
|
25
|
+
return value.trim().toLowerCase()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function computeIssueFingerprint(input: IssueFingerprintInput): string {
|
|
29
|
+
return hash([
|
|
30
|
+
normalizeText(input.check),
|
|
31
|
+
normalizeText(input.file),
|
|
32
|
+
String(input.lines[0]),
|
|
33
|
+
String(input.lines[1]),
|
|
34
|
+
input.severity,
|
|
35
|
+
])
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function computeObservationFingerprint(input: ObservationFingerprintInput): string {
|
|
39
|
+
return hash([
|
|
40
|
+
input.issueFingerprint,
|
|
41
|
+
normalizeText(input.source),
|
|
42
|
+
input.reportedByAgent,
|
|
43
|
+
input.toolCallId ?? "",
|
|
44
|
+
input.sessionId ?? "",
|
|
45
|
+
input.observationId ?? "",
|
|
46
|
+
])
|
|
47
|
+
}
|
|
@@ -8,10 +8,6 @@ export interface FindingStore {
|
|
|
8
8
|
serialize(): string
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
* Creates a finding store with deduplication by check+file+lines
|
|
13
|
-
* Deduplication key: `${check}:${file}:${lines[0]}-${lines[1]}`
|
|
14
|
-
*/
|
|
15
11
|
function isValidHydrationFinding(f: unknown): f is Finding {
|
|
16
12
|
if (typeof f !== "object" || f === null) return false
|
|
17
13
|
const obj = f as Record<string, unknown>
|
|
@@ -28,39 +24,26 @@ function isValidHydrationFinding(f: unknown): f is Finding {
|
|
|
28
24
|
}
|
|
29
25
|
|
|
30
26
|
export function createFindingStore(state: AuditState): FindingStore {
|
|
31
|
-
|
|
27
|
+
let observationCounter = state.findings.length
|
|
32
28
|
|
|
33
|
-
function
|
|
34
|
-
const key = `${check}:${file}:${lines[0]}-${lines[1]}`
|
|
35
|
-
|
|
29
|
+
function generateObservationId(check: string, file: string, lines: [number, number]): string {
|
|
30
|
+
const key = `${check}:${file}:${lines[0]}-${lines[1]}:${observationCounter}`
|
|
31
|
+
observationCounter += 1
|
|
36
32
|
return createHash("sha256").update(key).digest("hex").substring(0, 16)
|
|
37
33
|
}
|
|
38
34
|
|
|
39
|
-
|
|
40
|
-
for (const f of state.findings) {
|
|
41
|
-
if (!isValidHydrationFinding(f)) continue
|
|
42
|
-
const id = generateId(f.check, f.file, f.lines)
|
|
43
|
-
if (!findingMap.has(id)) {
|
|
44
|
-
findingMap.set(id, { ...f, id })
|
|
45
|
-
}
|
|
46
|
-
}
|
|
35
|
+
const hydratedFindings = state.findings.filter(isValidHydrationFinding)
|
|
47
36
|
|
|
48
37
|
function addFinding(finding: Omit<Finding, "id">): Finding {
|
|
49
|
-
const id =
|
|
50
|
-
|
|
51
|
-
// Check if finding already exists (deduplication)
|
|
52
|
-
const existing = findingMap.get(id)
|
|
53
|
-
if (existing) {
|
|
54
|
-
return existing
|
|
55
|
-
}
|
|
38
|
+
const id = generateObservationId(finding.check, finding.file, finding.lines)
|
|
56
39
|
|
|
57
40
|
const newFinding: Finding = {
|
|
58
41
|
...finding,
|
|
59
42
|
id,
|
|
60
43
|
}
|
|
61
44
|
|
|
62
|
-
findingMap.set(id, newFinding)
|
|
63
45
|
state.findings.push(newFinding)
|
|
46
|
+
hydratedFindings.push(newFinding)
|
|
64
47
|
|
|
65
48
|
return newFinding
|
|
66
49
|
}
|
|
@@ -69,11 +52,13 @@ export function createFindingStore(state: AuditState): FindingStore {
|
|
|
69
52
|
severity?: FindingSeverity
|
|
70
53
|
source?: Finding["source"]
|
|
71
54
|
}): Finding[] {
|
|
55
|
+
const findings = hydratedFindings.slice()
|
|
56
|
+
|
|
72
57
|
if (!filter) {
|
|
73
|
-
return
|
|
58
|
+
return findings
|
|
74
59
|
}
|
|
75
60
|
|
|
76
|
-
return
|
|
61
|
+
return findings.filter((finding) => {
|
|
77
62
|
if (filter.severity && finding.severity !== filter.severity) {
|
|
78
63
|
return false
|
|
79
64
|
}
|
|
@@ -85,12 +70,17 @@ export function createFindingStore(state: AuditState): FindingStore {
|
|
|
85
70
|
}
|
|
86
71
|
|
|
87
72
|
function hasFinding(check: string, file: string, lines: [number, number]): boolean {
|
|
88
|
-
|
|
89
|
-
|
|
73
|
+
return hydratedFindings.some(
|
|
74
|
+
(finding) =>
|
|
75
|
+
finding.check === check &&
|
|
76
|
+
finding.file === file &&
|
|
77
|
+
finding.lines[0] === lines[0] &&
|
|
78
|
+
finding.lines[1] === lines[1],
|
|
79
|
+
)
|
|
90
80
|
}
|
|
91
81
|
|
|
92
82
|
function serialize(): string {
|
|
93
|
-
const findings =
|
|
83
|
+
const findings = hydratedFindings.slice()
|
|
94
84
|
const contractCount = state.contractsReviewed.length
|
|
95
85
|
const findingCount = findings.length
|
|
96
86
|
|
package/src/state/projectors.ts
CHANGED
|
@@ -245,7 +245,7 @@ export function validateEventSequence(events: AuditEvent[]): void {
|
|
|
245
245
|
export function projectFindings(events: AuditEvent[]): CanonicalFinding[] {
|
|
246
246
|
validateEventSequence(events)
|
|
247
247
|
|
|
248
|
-
const
|
|
248
|
+
const findings: CanonicalFinding[] = []
|
|
249
249
|
|
|
250
250
|
for (const event of events) {
|
|
251
251
|
if (event.type !== "finding.added") continue
|
|
@@ -258,10 +258,15 @@ export function projectFindings(events: AuditEvent[]): CanonicalFinding[] {
|
|
|
258
258
|
)
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
-
|
|
261
|
+
findings.push({
|
|
262
|
+
...validation.data,
|
|
263
|
+
seq: event.seq,
|
|
264
|
+
run_id: event.run_id,
|
|
265
|
+
schema_version: event.schema_version,
|
|
266
|
+
})
|
|
262
267
|
}
|
|
263
268
|
|
|
264
|
-
return
|
|
269
|
+
return findings.sort((left, right) => {
|
|
265
270
|
const bySeverity = SEVERITY_RANK[left.severity] - SEVERITY_RANK[right.severity]
|
|
266
271
|
if (bySeverity !== 0) return bySeverity
|
|
267
272
|
|
|
@@ -271,7 +276,16 @@ export function projectFindings(events: AuditEvent[]): CanonicalFinding[] {
|
|
|
271
276
|
const byLine = left.lines[0] - right.lines[0]
|
|
272
277
|
if (byLine !== 0) return byLine
|
|
273
278
|
|
|
274
|
-
|
|
279
|
+
const byIssue = left.issue_fingerprint.localeCompare(right.issue_fingerprint)
|
|
280
|
+
if (byIssue !== 0) return byIssue
|
|
281
|
+
|
|
282
|
+
const byObservation = left.observation_fingerprint.localeCompare(right.observation_fingerprint)
|
|
283
|
+
if (byObservation !== 0) return byObservation
|
|
284
|
+
|
|
285
|
+
const byId = left.id.localeCompare(right.id)
|
|
286
|
+
if (byId !== 0) return byId
|
|
287
|
+
|
|
288
|
+
return left.seq - right.seq
|
|
275
289
|
})
|
|
276
290
|
}
|
|
277
291
|
|
package/src/state/schemas.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
ArgusAgentName,
|
|
2
3
|
AuditPhase,
|
|
3
4
|
Finding,
|
|
4
5
|
FindingSeverity,
|
|
@@ -7,7 +8,7 @@ import type {
|
|
|
7
8
|
ToolExecution,
|
|
8
9
|
} from "./types"
|
|
9
10
|
|
|
10
|
-
export const SCHEMA_VERSION = "
|
|
11
|
+
export const SCHEMA_VERSION = "2.0.0"
|
|
11
12
|
|
|
12
13
|
export type AuditEventType =
|
|
13
14
|
| "session.created"
|
|
@@ -45,6 +46,11 @@ export interface CanonicalFinding extends Finding {
|
|
|
45
46
|
run_id: string
|
|
46
47
|
seq: number
|
|
47
48
|
schema_version: string
|
|
49
|
+
observation_id: string
|
|
50
|
+
issue_fingerprint: string
|
|
51
|
+
observation_fingerprint: string
|
|
52
|
+
reported_by_agent: ArgusAgentName
|
|
53
|
+
reported_by_session_id?: string
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
export interface CanonicalToolExecution extends ToolExecution {
|
|
@@ -156,6 +162,13 @@ const VALID_SOURCES: ReadonlySet<CanonicalFinding["source"]> = new Set([
|
|
|
156
162
|
"solodit",
|
|
157
163
|
"fuzz",
|
|
158
164
|
])
|
|
165
|
+
const VALID_AGENTS: ReadonlySet<ArgusAgentName> = new Set([
|
|
166
|
+
"argus",
|
|
167
|
+
"sentinel",
|
|
168
|
+
"pythia",
|
|
169
|
+
"scribe",
|
|
170
|
+
"unknown",
|
|
171
|
+
])
|
|
159
172
|
|
|
160
173
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
161
174
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
@@ -197,6 +210,10 @@ export function validateCanonicalFinding(raw: unknown): ValidationResult<Canonic
|
|
|
197
210
|
pushRequiredStringError(errors, raw, "file")
|
|
198
211
|
pushRequiredStringError(errors, raw, "run_id")
|
|
199
212
|
pushRequiredStringError(errors, raw, "schema_version")
|
|
213
|
+
pushRequiredStringError(errors, raw, "observation_id")
|
|
214
|
+
pushRequiredStringError(errors, raw, "issue_fingerprint")
|
|
215
|
+
pushRequiredStringError(errors, raw, "observation_fingerprint")
|
|
216
|
+
pushRequiredStringError(errors, raw, "reported_by_agent")
|
|
200
217
|
|
|
201
218
|
if (typeof raw.seq !== "number" || !Number.isInteger(raw.seq) || raw.seq < 0) {
|
|
202
219
|
errors.push({
|
|
@@ -253,6 +270,37 @@ export function validateCanonicalFinding(raw: unknown): ValidationResult<Canonic
|
|
|
253
270
|
})
|
|
254
271
|
}
|
|
255
272
|
|
|
273
|
+
if (
|
|
274
|
+
typeof raw.reported_by_agent !== "string" ||
|
|
275
|
+
!VALID_AGENTS.has(raw.reported_by_agent as ArgusAgentName)
|
|
276
|
+
) {
|
|
277
|
+
errors.push({
|
|
278
|
+
field: "reported_by_agent",
|
|
279
|
+
code: "enum",
|
|
280
|
+
message: "reported_by_agent must be one of: argus, sentinel, pythia, scribe, unknown",
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (
|
|
285
|
+
raw.reported_by_session_id != null &&
|
|
286
|
+
(typeof raw.reported_by_session_id !== "string" ||
|
|
287
|
+
raw.reported_by_session_id.trim().length === 0)
|
|
288
|
+
) {
|
|
289
|
+
errors.push({
|
|
290
|
+
field: "reported_by_session_id",
|
|
291
|
+
code: "invalid",
|
|
292
|
+
message: "reported_by_session_id must be a non-empty string when provided",
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (raw.schema_version !== SCHEMA_VERSION) {
|
|
297
|
+
errors.push({
|
|
298
|
+
field: "schema_version",
|
|
299
|
+
code: "version_mismatch",
|
|
300
|
+
message: `schema_version must be ${SCHEMA_VERSION}`,
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
|
|
256
304
|
if (errors.length > 0) {
|
|
257
305
|
return { success: false, errors }
|
|
258
306
|
}
|
|
@@ -260,6 +308,90 @@ export function validateCanonicalFinding(raw: unknown): ValidationResult<Canonic
|
|
|
260
308
|
return { success: true, data: raw as unknown as CanonicalFinding }
|
|
261
309
|
}
|
|
262
310
|
|
|
311
|
+
export function validateCanonicalToolExecution(
|
|
312
|
+
raw: unknown,
|
|
313
|
+
): ValidationResult<CanonicalToolExecution> {
|
|
314
|
+
if (!isRecord(raw)) {
|
|
315
|
+
return {
|
|
316
|
+
success: false,
|
|
317
|
+
errors: [
|
|
318
|
+
{
|
|
319
|
+
field: "$root",
|
|
320
|
+
code: "type",
|
|
321
|
+
message: "canonical tool execution must be an object",
|
|
322
|
+
},
|
|
323
|
+
],
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const errors: ValidationError[] = []
|
|
328
|
+
|
|
329
|
+
if (typeof raw.tool !== "string" || raw.tool.trim().length === 0) {
|
|
330
|
+
errors.push({
|
|
331
|
+
field: "tool",
|
|
332
|
+
code: "required",
|
|
333
|
+
message: "tool is required and must be a non-empty string",
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (typeof raw.startTime !== "number" || !Number.isInteger(raw.startTime) || raw.startTime <= 0) {
|
|
338
|
+
errors.push({
|
|
339
|
+
field: "startTime",
|
|
340
|
+
code: "invalid",
|
|
341
|
+
message: "startTime must be a positive integer",
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (raw.endTime != null && (typeof raw.endTime !== "number" || !Number.isInteger(raw.endTime))) {
|
|
346
|
+
errors.push({
|
|
347
|
+
field: "endTime",
|
|
348
|
+
code: "invalid",
|
|
349
|
+
message: "endTime must be an integer when provided",
|
|
350
|
+
})
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (typeof raw.success !== "boolean") {
|
|
354
|
+
errors.push({
|
|
355
|
+
field: "success",
|
|
356
|
+
code: "required",
|
|
357
|
+
message: "success is required and must be a boolean",
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (
|
|
362
|
+
typeof raw.findingsCount !== "number" ||
|
|
363
|
+
!Number.isInteger(raw.findingsCount) ||
|
|
364
|
+
raw.findingsCount < 0
|
|
365
|
+
) {
|
|
366
|
+
errors.push({
|
|
367
|
+
field: "findingsCount",
|
|
368
|
+
code: "invalid",
|
|
369
|
+
message: "findingsCount must be a non-negative integer",
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (typeof raw.run_id !== "string" || raw.run_id.trim().length === 0) {
|
|
374
|
+
errors.push({
|
|
375
|
+
field: "run_id",
|
|
376
|
+
code: "required",
|
|
377
|
+
message: "run_id is required and must be a non-empty string",
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (typeof raw.schema_version !== "string" || raw.schema_version.trim().length === 0) {
|
|
382
|
+
errors.push({
|
|
383
|
+
field: "schema_version",
|
|
384
|
+
code: "required",
|
|
385
|
+
message: "schema_version is required and must be a non-empty string",
|
|
386
|
+
})
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (errors.length > 0) {
|
|
390
|
+
return { success: false, errors }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return { success: true, data: raw as unknown as CanonicalToolExecution }
|
|
394
|
+
}
|
|
263
395
|
export function validateReportInput(raw: unknown): ValidationResult<ReportInput> {
|
|
264
396
|
if (!isRecord(raw)) {
|
|
265
397
|
return {
|
|
@@ -306,6 +438,18 @@ export function validateReportInput(raw: unknown): ValidationResult<ReportInput>
|
|
|
306
438
|
code: "invalid",
|
|
307
439
|
message: "toolsExecuted must be an array",
|
|
308
440
|
})
|
|
441
|
+
} else {
|
|
442
|
+
for (const [index, entry] of raw.toolsExecuted.entries()) {
|
|
443
|
+
const toolValidation = validateCanonicalToolExecution(entry)
|
|
444
|
+
if (toolValidation.success) continue
|
|
445
|
+
for (const toolError of toolValidation.errors) {
|
|
446
|
+
errors.push({
|
|
447
|
+
field: `toolsExecuted[${index}].${toolError.field}`,
|
|
448
|
+
code: toolError.code,
|
|
449
|
+
message: toolError.message,
|
|
450
|
+
})
|
|
451
|
+
}
|
|
452
|
+
}
|
|
309
453
|
}
|
|
310
454
|
|
|
311
455
|
if (raw.patternVersion != null && typeof raw.patternVersion !== "string") {
|
package/src/state/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export type FindingSeverity = "Critical" | "High" | "Medium" | "Low" | "Informational"
|
|
2
|
+
export type ArgusAgentName = "argus" | "sentinel" | "pythia" | "scribe" | "unknown"
|
|
2
3
|
export type AuditPhase =
|
|
3
4
|
| "reconnaissance"
|
|
4
5
|
| "scanning"
|
|
@@ -10,7 +11,7 @@ export type AuditPhase =
|
|
|
10
11
|
| "complete"
|
|
11
12
|
|
|
12
13
|
export interface Finding {
|
|
13
|
-
id: string
|
|
14
|
+
id: string
|
|
14
15
|
check: string // detector name e.g. "reentrancy-eth"
|
|
15
16
|
severity: FindingSeverity
|
|
16
17
|
confidence: "High" | "Medium" | "Low"
|
|
@@ -18,6 +19,15 @@ export interface Finding {
|
|
|
18
19
|
file: string // relative file path
|
|
19
20
|
lines: [number, number] // [start, end]
|
|
20
21
|
source: "slither" | "manual" | "pattern" | "scvd" | "solodit" | "fuzz"
|
|
22
|
+
reported_by_agent?: ArgusAgentName
|
|
23
|
+
reported_by_session_id?: string
|
|
24
|
+
issue_fingerprint?: string
|
|
25
|
+
observation_fingerprint?: string
|
|
26
|
+
observation_id?: string
|
|
27
|
+
observation_ids?: string[]
|
|
28
|
+
reported_by_agents?: string[]
|
|
29
|
+
sources?: string[]
|
|
30
|
+
observation_count?: number
|
|
21
31
|
remediation?: string
|
|
22
32
|
exploitReference?: string
|
|
23
33
|
provenance?: {
|
|
@@ -110,4 +120,10 @@ export interface PersistentAuditState extends AuditState {
|
|
|
110
120
|
savedAt: number
|
|
111
121
|
version: string
|
|
112
122
|
filePath: string
|
|
123
|
+
/** Whether this snapshot was projected from events or loaded from a prior snapshot */
|
|
124
|
+
source_of_truth?: "events" | "snapshot"
|
|
125
|
+
/** Sequence number of the last event included in this snapshot */
|
|
126
|
+
last_event_seq?: number
|
|
127
|
+
/** Hash of the event stream for staleness detection */
|
|
128
|
+
event_stream_hash?: string
|
|
113
129
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { type ToolContext, tool } from "@opencode-ai/plugin"
|
|
2
|
+
import { normalizeToCanonicalFinding } from "../state/adapters"
|
|
3
|
+
import { SCHEMA_VERSION } from "../state/schemas"
|
|
4
|
+
import type { ArgusAgentName } from "../state/types"
|
|
5
|
+
|
|
6
|
+
type RecordFindingArgs = {
|
|
7
|
+
finding?: string
|
|
8
|
+
findings?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type RecordFindingResponse = {
|
|
12
|
+
success: boolean
|
|
13
|
+
count: number
|
|
14
|
+
findings: ReturnType<typeof normalizeToCanonicalFinding>["data"][]
|
|
15
|
+
schema_version: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseFindingObject(raw: string, label: "finding" | "findings"): Record<string, unknown>[] {
|
|
19
|
+
let parsed: unknown
|
|
20
|
+
try {
|
|
21
|
+
parsed = JSON.parse(raw)
|
|
22
|
+
} catch {
|
|
23
|
+
throw new Error(`${label} must be valid JSON`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (label === "finding") {
|
|
27
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
28
|
+
throw new Error("finding must be a JSON object")
|
|
29
|
+
}
|
|
30
|
+
return [parsed as Record<string, unknown>]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!Array.isArray(parsed)) {
|
|
34
|
+
throw new Error("findings must be a JSON array")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return parsed.filter(
|
|
38
|
+
(item): item is Record<string, unknown> =>
|
|
39
|
+
typeof item === "object" && item !== null && !Array.isArray(item),
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeAgent(value: string): ArgusAgentName {
|
|
44
|
+
if (value === "argus" || value === "sentinel" || value === "pythia" || value === "scribe") {
|
|
45
|
+
return value
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return "unknown"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function executeRecordFinding(
|
|
52
|
+
args: RecordFindingArgs,
|
|
53
|
+
context: ToolContext,
|
|
54
|
+
): Promise<string> {
|
|
55
|
+
const rawFindings: Record<string, unknown>[] = []
|
|
56
|
+
|
|
57
|
+
if (typeof args.finding === "string" && args.finding.trim().length > 0) {
|
|
58
|
+
rawFindings.push(...parseFindingObject(args.finding, "finding"))
|
|
59
|
+
}
|
|
60
|
+
if (typeof args.findings === "string" && args.findings.trim().length > 0) {
|
|
61
|
+
rawFindings.push(...parseFindingObject(args.findings, "findings"))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (rawFindings.length === 0) {
|
|
65
|
+
throw new Error("Provide at least one finding via finding or findings")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const reportedByAgent = normalizeAgent(context.agent)
|
|
69
|
+
const reportedBySessionId = context.sessionID
|
|
70
|
+
const runId = context.sessionID || "manual-run"
|
|
71
|
+
|
|
72
|
+
const findings: ReturnType<typeof normalizeToCanonicalFinding>["data"][] = []
|
|
73
|
+
const errors: string[] = []
|
|
74
|
+
|
|
75
|
+
for (const [index, rawFinding] of rawFindings.entries()) {
|
|
76
|
+
const normalized = normalizeToCanonicalFinding(rawFinding, runId, index + 1, {
|
|
77
|
+
reportedByAgent,
|
|
78
|
+
reportedBySessionId,
|
|
79
|
+
observationId: `${reportedBySessionId}:${index + 1}`,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const diagnosticsErrors = normalized.diagnostics.filter((diag) => diag.level === "error")
|
|
83
|
+
if (diagnosticsErrors.length > 0) {
|
|
84
|
+
errors.push(
|
|
85
|
+
...diagnosticsErrors.map(
|
|
86
|
+
(diag) => `[index:${index}] ${diag.field ?? "$root"}: ${diag.message}`,
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
findings.push(normalized.data)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (errors.length > 0) {
|
|
96
|
+
throw new Error(`Failed to record finding(s): ${errors.join("; ")}`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const response: RecordFindingResponse = {
|
|
100
|
+
success: true,
|
|
101
|
+
count: findings.length,
|
|
102
|
+
findings,
|
|
103
|
+
schema_version: SCHEMA_VERSION,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return JSON.stringify(response)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const recordFindingTool = tool({
|
|
110
|
+
description:
|
|
111
|
+
"Record manually identified findings in canonical format for durable event-backed tracking.",
|
|
112
|
+
args: {
|
|
113
|
+
finding: tool.schema
|
|
114
|
+
.string()
|
|
115
|
+
.optional()
|
|
116
|
+
.describe("Serialized JSON object containing a single finding payload."),
|
|
117
|
+
findings: tool.schema
|
|
118
|
+
.string()
|
|
119
|
+
.optional()
|
|
120
|
+
.describe("Serialized JSON array containing one or more finding payload objects."),
|
|
121
|
+
},
|
|
122
|
+
async execute(args, context) {
|
|
123
|
+
return executeRecordFinding(args, context)
|
|
124
|
+
},
|
|
125
|
+
})
|