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.
Files changed (36) hide show
  1. package/package.json +4 -4
  2. package/src/agents/argus-prompt.ts +56 -2
  3. package/src/agents/pythia-prompt.ts +11 -0
  4. package/src/agents/scribe-prompt.ts +9 -4
  5. package/src/agents/sentinel-prompt.ts +10 -0
  6. package/src/cli/commands/init.ts +1 -1
  7. package/src/config/schema.ts +2 -2
  8. package/src/create-hooks.ts +95 -12
  9. package/src/create-tools.ts +2 -0
  10. package/src/features/audit-enforcer/audit-enforcer.ts +30 -2
  11. package/src/features/persistent-state/audit-state-manager.ts +180 -10
  12. package/src/features/persistent-state/event-sink.ts +15 -6
  13. package/src/features/persistent-state/findings-materializer.ts +52 -0
  14. package/src/features/persistent-state/index.ts +1 -1
  15. package/src/features/persistent-state/run-finalizer.ts +26 -7
  16. package/src/features/persistent-state/run-journal.ts +12 -4
  17. package/src/hooks/event-hook.ts +4 -1
  18. package/src/hooks/system-prompt-hook.ts +15 -0
  19. package/src/hooks/tool-tracking-hook.ts +168 -10
  20. package/src/shared/audit-artifact-resolver.ts +13 -12
  21. package/src/shared/file-utils.ts +7 -2
  22. package/src/shared/index.ts +8 -8
  23. package/src/shared/path-root-resolver.ts +34 -0
  24. package/src/shared/plugin-metadata.ts +23 -0
  25. package/src/shared/report-path-resolver.ts +3 -3
  26. package/src/state/adapters.ts +99 -5
  27. package/src/state/finding-aggregation.ts +100 -0
  28. package/src/state/finding-fingerprint.ts +47 -0
  29. package/src/state/finding-store.ts +19 -29
  30. package/src/state/projectors.ts +18 -4
  31. package/src/state/schemas.ts +145 -1
  32. package/src/state/types.ts +17 -1
  33. package/src/tools/record-finding-tool.ts +125 -0
  34. package/src/tools/report-generator-tool.ts +116 -7
  35. package/src/tools/report-preflight.ts +79 -0
  36. 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
- const findingMap = new Map<string, Finding>()
27
+ let observationCounter = state.findings.length
32
28
 
33
- function generateId(check: string, file: string, lines: [number, number]): string {
34
- const key = `${check}:${file}:${lines[0]}-${lines[1]}`
35
- // Use deterministic hash for stable IDs
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
- // Hydrate findingMap from persisted state.findings
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 = generateId(finding.check, finding.file, finding.lines)
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 Array.from(findingMap.values())
58
+ return findings
74
59
  }
75
60
 
76
- return Array.from(findingMap.values()).filter((finding) => {
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
- const id = generateId(check, file, lines)
89
- return findingMap.has(id)
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 = Array.from(findingMap.values())
83
+ const findings = hydratedFindings.slice()
94
84
  const contractCount = state.contractsReviewed.length
95
85
  const findingCount = findings.length
96
86
 
@@ -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 byId = new Map<string, CanonicalFinding>()
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
- byId.set(validation.data.id, validation.data)
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 Array.from(byId.values()).sort((left, right) => {
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
- return left.id.localeCompare(right.id)
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
 
@@ -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 = "1.0.0"
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") {
@@ -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 // unique hash: check+file+lines
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
+ })