solidity-argus 0.3.7 → 0.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +13 -6
- package/README.md +24 -12
- package/package.json +7 -3
- package/skills/checklists/cyfrin-best-practices-runtime/SKILL.md +1 -0
- package/skills/checklists/cyfrin-best-practices-upgrades/SKILL.md +1 -0
- package/skills/checklists/cyfrin-defi-core/SKILL.md +1 -0
- package/skills/checklists/cyfrin-defi-integrations/SKILL.md +1 -0
- package/skills/checklists/cyfrin-gas/SKILL.md +1 -0
- package/skills/checklists/general-audit/SKILL.md +1 -0
- package/skills/methodology/audit-workflow/SKILL.md +1 -0
- package/skills/methodology/report-template/SKILL.md +1 -0
- package/skills/methodology/severity-classification/SKILL.md +1 -0
- package/skills/protocol-patterns/amm-dex/SKILL.md +1 -0
- package/skills/protocol-patterns/bridges-cross-chain/SKILL.md +1 -0
- package/skills/protocol-patterns/dao-governance/SKILL.md +1 -0
- package/skills/protocol-patterns/lending-borrowing/SKILL.md +1 -0
- package/skills/protocol-patterns/staking-vesting/SKILL.md +1 -0
- package/skills/vulnerability-patterns/flash-loan-attacks/SKILL.md +0 -50
- package/skills/vulnerability-patterns/oracle-manipulation/SKILL.md +0 -63
- package/src/agents/argus-prompt.ts +98 -33
- package/src/agents/pythia-prompt.ts +24 -2
- package/src/agents/scribe-prompt.ts +34 -10
- package/src/agents/sentinel-prompt.ts +19 -0
- package/src/agents/themis-prompt.ts +110 -0
- package/src/cli/commands/doctor.ts +29 -17
- package/src/cli/commands/install.ts +74 -33
- package/src/config/loader.ts +29 -5
- package/src/config/schema.ts +45 -45
- package/src/constants/defaults.ts +1 -0
- package/src/create-hooks.ts +806 -173
- package/src/create-managers.ts +4 -2
- package/src/create-tools.ts +5 -1
- package/src/features/audit-enforcer/audit-enforcer.ts +1 -11
- package/src/features/background-agent/background-manager.ts +32 -5
- package/src/features/error-recovery/tool-error-recovery.ts +1 -0
- package/src/features/persistent-state/audit-state-manager.ts +272 -29
- package/src/features/persistent-state/event-sink.ts +96 -25
- package/src/features/persistent-state/findings-materializer.ts +68 -2
- package/src/features/persistent-state/global-run-index.ts +86 -8
- package/src/features/persistent-state/index.ts +7 -1
- package/src/features/persistent-state/run-finalizer.ts +116 -7
- package/src/features/persistent-state/run-pruner.ts +93 -0
- package/src/hooks/agent-tracker.ts +14 -2
- package/src/hooks/compaction-hook.ts +7 -16
- package/src/hooks/config-handler.ts +83 -29
- package/src/hooks/context-budget.ts +4 -5
- package/src/hooks/event-hook.ts +213 -57
- package/src/hooks/knowledge-sync-hook.ts +2 -3
- package/src/hooks/safe-create-hook.ts +13 -1
- package/src/hooks/system-prompt-hook.ts +20 -39
- package/src/hooks/tool-tracking-hook.ts +602 -323
- package/src/index.ts +15 -1
- package/src/knowledge/scvd-client.ts +2 -4
- package/src/knowledge/scvd-errors.ts +25 -2
- package/src/knowledge/scvd-index.ts +7 -5
- package/src/knowledge/scvd-sync.ts +6 -6
- package/src/managers/types.ts +20 -2
- package/src/shared/agent-names.ts +23 -0
- package/src/shared/audit-artifact-resolver.ts +8 -3
- package/src/shared/audit-phases.ts +12 -0
- package/src/shared/cache-paths.ts +41 -0
- package/src/shared/drop-diagnostics.ts +2 -2
- package/src/shared/forge-errors.ts +31 -0
- package/src/shared/forge-runner.ts +30 -0
- package/src/shared/format-error.ts +3 -0
- package/src/shared/index.ts +9 -0
- package/src/shared/key-tools.ts +39 -0
- package/src/shared/logger.ts +7 -7
- package/src/shared/path-containment.ts +25 -0
- package/src/shared/path-utils.ts +11 -0
- package/src/shared/report-path-resolver.ts +4 -2
- package/src/shared/safe-emit.ts +24 -0
- package/src/shared/token-utils.ts +5 -0
- package/src/shared/type-guards.ts +8 -0
- package/src/shared/validation-constants.ts +52 -0
- package/src/skills/analysis/cluster.ts +1 -114
- package/src/skills/analysis/normalize.ts +2 -114
- package/src/skills/analysis/stopwords.ts +109 -0
- package/src/skills/argus-skill-resolver.ts +6 -3
- package/src/solodit-lifecycle.ts +153 -37
- package/src/state/adapters.ts +60 -66
- package/src/state/finding-aggregation.ts +6 -8
- package/src/state/finding-fingerprint.ts +1 -1
- package/src/state/finding-store.ts +31 -9
- package/src/state/index.ts +1 -1
- package/src/state/projectors.ts +27 -19
- package/src/state/schemas.ts +8 -32
- package/src/state/types.ts +3 -0
- package/src/tools/contract-analyzer-tool.ts +4 -6
- package/src/tools/forge-coverage-tool.ts +10 -35
- package/src/tools/forge-fuzz-tool.ts +21 -51
- package/src/tools/forge-test-tool.ts +25 -47
- package/src/tools/gas-analysis-tool.ts +12 -41
- package/src/tools/pattern-checker-tool.ts +37 -15
- package/src/tools/pattern-loader.ts +18 -4
- package/src/tools/persist-deduped-tool.ts +94 -0
- package/src/tools/proxy-detection-tool.ts +35 -34
- package/src/tools/read-findings-tool.ts +390 -0
- package/src/tools/record-finding-tool.ts +130 -25
- package/src/tools/report-generator-tool.ts +475 -327
- package/src/tools/report-preflight.ts +5 -1
- package/src/tools/slither-tool.ts +55 -16
- package/src/tools/solodit-search-tool.ts +260 -112
- package/src/tools/sync-knowledge-tool.ts +2 -3
- package/src/utils/solidity-parser.ts +39 -24
- package/src/features/migration/index.ts +0 -14
- package/src/features/migration/migration-adapter.ts +0 -151
- package/src/features/migration/parity-telemetry.ts +0 -133
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto"
|
|
2
2
|
import type { EventSink } from "../features/persistent-state/event-sink"
|
|
3
|
+
import { isArgusFamily } from "../shared/agent-names"
|
|
4
|
+
import { PHASE_ORDER } from "../shared/audit-phases"
|
|
3
5
|
import type {
|
|
4
6
|
DropDiagnostic,
|
|
5
7
|
DropDiagnosticsCollector,
|
|
@@ -7,6 +9,8 @@ import type {
|
|
|
7
9
|
} from "../shared/drop-diagnostics"
|
|
8
10
|
import { createDropDiagnosticsCollector } from "../shared/drop-diagnostics"
|
|
9
11
|
import { createLogger } from "../shared/logger"
|
|
12
|
+
import { normalizeFilePath } from "../shared/path-utils"
|
|
13
|
+
import { safeEmitToSink } from "../shared/safe-emit"
|
|
10
14
|
import { normalizeToCanonicalFinding } from "../state/adapters"
|
|
11
15
|
import type { FindingStore } from "../state/finding-store"
|
|
12
16
|
import { createFindingStore } from "../state/finding-store"
|
|
@@ -34,15 +38,20 @@ type ToolHookInput = {
|
|
|
34
38
|
type ToolExecutionMetadata = {
|
|
35
39
|
tool: string
|
|
36
40
|
findingsCount: number
|
|
41
|
+
sessionId?: string
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
export type ToolTrackingOptions = {
|
|
40
45
|
getEventSink?: () => EventSink | null
|
|
46
|
+
getEventSinkForSession?: (sessionId: string) => EventSink | null
|
|
47
|
+
getEventSinkForRun?: (runId: string) => EventSink | null
|
|
48
|
+
getActiveRunSinks?: () => EventSink[]
|
|
41
49
|
getSessionId?: () => string
|
|
42
50
|
getAgentName?: () => ArgusAgentName | undefined
|
|
43
51
|
getAgentNameForSession?: (sessionId: string) => ArgusAgentName | undefined
|
|
44
52
|
dropPolicy?: DropPolicy
|
|
45
53
|
onChildSessionDetected?: (parentSessionId: string, childSessionId: string) => void
|
|
54
|
+
projectDir?: string
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
const VALID_SEVERITIES: ReadonlySet<string> = new Set([
|
|
@@ -103,22 +112,7 @@ function toFindingSource(value: unknown): Finding["source"] {
|
|
|
103
112
|
return "manual"
|
|
104
113
|
}
|
|
105
114
|
|
|
106
|
-
|
|
107
|
-
sink: EventSink,
|
|
108
|
-
event: AuditEvent,
|
|
109
|
-
options?: { failFast?: boolean },
|
|
110
|
-
): Promise<void> {
|
|
111
|
-
try {
|
|
112
|
-
await sink.append(event)
|
|
113
|
-
} catch (error) {
|
|
114
|
-
const message = `Failed to emit ${event.type} event to sink: ${error instanceof Error ? error.message : String(error)}`
|
|
115
|
-
logger.error(message)
|
|
116
|
-
|
|
117
|
-
if (options?.failFast) {
|
|
118
|
-
throw new Error(message)
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
115
|
+
const emitToSink = safeEmitToSink
|
|
122
116
|
|
|
123
117
|
function buildEvent(
|
|
124
118
|
type: AuditEvent["type"],
|
|
@@ -146,6 +140,7 @@ function buildEvent(
|
|
|
146
140
|
* or plain text with an embedded JSON fragment.
|
|
147
141
|
*/
|
|
148
142
|
function parseChildSessionId(result: string): string | null {
|
|
143
|
+
// Strategy 1: Full JSON parse (structured tool output)
|
|
149
144
|
try {
|
|
150
145
|
const parsed = JSON.parse(result)
|
|
151
146
|
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
@@ -163,11 +158,32 @@ function parseChildSessionId(result: string): string | null {
|
|
|
163
158
|
}
|
|
164
159
|
}
|
|
165
160
|
} catch {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
161
|
+
// Not valid JSON — fall through to regex strategies
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Strategy 2: OpenCode task tool XML format
|
|
165
|
+
// <task_metadata>
|
|
166
|
+
// session_id: ses_xxx
|
|
167
|
+
// </task_metadata>
|
|
168
|
+
const xmlMatch = result.match(
|
|
169
|
+
/<task_metadata>[\s\S]*?session_id:\s*(ses_\S+)[\s\S]*?<\/task_metadata>/,
|
|
170
|
+
)
|
|
171
|
+
if (xmlMatch?.[1]) {
|
|
172
|
+
return xmlMatch[1]
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Strategy 3: JSON fragment in plain text
|
|
176
|
+
const jsonFragmentMatch = result.match(/"session_id"\s*:\s*"([^"]+)"/)
|
|
177
|
+
if (jsonFragmentMatch?.[1]) {
|
|
178
|
+
return jsonFragmentMatch[1]
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Strategy 4: Bare session_id line (e.g. "session_id: ses_xxx" outside XML tags)
|
|
182
|
+
const bareMatch = result.match(/session_id:\s*(ses_\S+)/)
|
|
183
|
+
if (bareMatch?.[1]) {
|
|
184
|
+
return bareMatch[1]
|
|
170
185
|
}
|
|
186
|
+
|
|
171
187
|
return null
|
|
172
188
|
}
|
|
173
189
|
|
|
@@ -190,141 +206,100 @@ const SLITHER_REQUIRED = ["check", "description", "file", "lines"] as const
|
|
|
190
206
|
const PATTERN_REQUIRED = ["pattern", "description", "file", "lines"] as const
|
|
191
207
|
const MANUAL_REQUIRED = ["check", "description", "file", "lines"] as const
|
|
192
208
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (!finding) continue
|
|
206
|
-
|
|
207
|
-
const check = finding.check
|
|
208
|
-
const description = finding.description
|
|
209
|
-
const file = finding.file
|
|
210
|
-
const lines = toLines(finding.lines)
|
|
209
|
+
type ProcessorConfig = {
|
|
210
|
+
toolLabel: string
|
|
211
|
+
arrayKey: string
|
|
212
|
+
nestedArrayKey?: string
|
|
213
|
+
primaryIdField: string
|
|
214
|
+
requiredFields: readonly string[]
|
|
215
|
+
sourceLabel: string | "dynamic"
|
|
216
|
+
confidenceMode: "read" | "fixed"
|
|
217
|
+
confidenceDefault?: string
|
|
218
|
+
extractOptionalFields: boolean
|
|
219
|
+
allowReportedByOverride: boolean
|
|
220
|
+
}
|
|
211
221
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
missing[0],
|
|
223
|
-
)
|
|
224
|
-
continue
|
|
225
|
-
}
|
|
222
|
+
const SLITHER_CONFIG: ProcessorConfig = {
|
|
223
|
+
toolLabel: "Slither",
|
|
224
|
+
arrayKey: "findings",
|
|
225
|
+
primaryIdField: "check",
|
|
226
|
+
requiredFields: SLITHER_REQUIRED,
|
|
227
|
+
sourceLabel: "slither",
|
|
228
|
+
confidenceMode: "read",
|
|
229
|
+
extractOptionalFields: false,
|
|
230
|
+
allowReportedByOverride: false,
|
|
231
|
+
}
|
|
226
232
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
233
|
+
const PATTERN_CONFIG: ProcessorConfig = {
|
|
234
|
+
toolLabel: "Pattern",
|
|
235
|
+
arrayKey: "sources",
|
|
236
|
+
nestedArrayKey: "matches",
|
|
237
|
+
primaryIdField: "pattern",
|
|
238
|
+
requiredFields: PATTERN_REQUIRED,
|
|
239
|
+
sourceLabel: "pattern",
|
|
240
|
+
confidenceMode: "fixed",
|
|
241
|
+
confidenceDefault: "Medium",
|
|
242
|
+
extractOptionalFields: false,
|
|
243
|
+
allowReportedByOverride: false,
|
|
244
|
+
}
|
|
240
245
|
|
|
241
|
-
|
|
246
|
+
const RECORDED_CONFIG: ProcessorConfig = {
|
|
247
|
+
toolLabel: "Recorded",
|
|
248
|
+
arrayKey: "findings",
|
|
249
|
+
primaryIdField: "check",
|
|
250
|
+
requiredFields: MANUAL_REQUIRED,
|
|
251
|
+
sourceLabel: "dynamic",
|
|
252
|
+
confidenceMode: "read",
|
|
253
|
+
extractOptionalFields: true,
|
|
254
|
+
allowReportedByOverride: true,
|
|
242
255
|
}
|
|
243
256
|
|
|
244
|
-
function
|
|
257
|
+
function processToolResult(
|
|
245
258
|
parsed: Record<string, unknown>,
|
|
246
259
|
store: FindingStore,
|
|
247
260
|
diag: DropDiagnosticsCollector,
|
|
248
261
|
metadata: { reportedByAgent: ArgusAgentName; reportedBySessionId: string },
|
|
262
|
+
config: ProcessorConfig,
|
|
263
|
+
projectDir?: string,
|
|
249
264
|
): number {
|
|
250
|
-
const
|
|
251
|
-
if (!Array.isArray(
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const matches = source.matches
|
|
259
|
-
if (!Array.isArray(matches)) continue
|
|
260
|
-
|
|
261
|
-
for (const rawMatch of matches) {
|
|
262
|
-
const match = toRecord(rawMatch)
|
|
263
|
-
if (!match) continue
|
|
264
|
-
|
|
265
|
-
const pattern = match.pattern
|
|
266
|
-
const description = match.description
|
|
267
|
-
const file = match.file
|
|
268
|
-
const lines = toLines(match.lines)
|
|
269
|
-
|
|
270
|
-
if (
|
|
271
|
-
typeof pattern !== "string" ||
|
|
272
|
-
typeof description !== "string" ||
|
|
273
|
-
typeof file !== "string" ||
|
|
274
|
-
!lines
|
|
275
|
-
) {
|
|
276
|
-
const missing = identifyMissingFields(match, PATTERN_REQUIRED)
|
|
277
|
-
diag.error(
|
|
278
|
-
"MISSING_REQUIRED_FIELD",
|
|
279
|
-
`Pattern finding skipped: missing ${missing.join(", ")}`,
|
|
280
|
-
missing[0],
|
|
281
|
-
)
|
|
282
|
-
continue
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
store.addFinding({
|
|
286
|
-
check: pattern,
|
|
287
|
-
severity: toSeverity(match.severity),
|
|
288
|
-
confidence: "Medium",
|
|
289
|
-
description,
|
|
290
|
-
file,
|
|
291
|
-
lines,
|
|
292
|
-
source: "pattern",
|
|
293
|
-
reported_by_agent: metadata.reportedByAgent,
|
|
294
|
-
reported_by_session_id: metadata.reportedBySessionId,
|
|
295
|
-
})
|
|
296
|
-
count++
|
|
265
|
+
const topLevel = parsed[config.arrayKey]
|
|
266
|
+
if (!Array.isArray(topLevel)) {
|
|
267
|
+
if (config.toolLabel === "Recorded") {
|
|
268
|
+
diag.error(
|
|
269
|
+
"MISSING_REQUIRED_FIELD",
|
|
270
|
+
"argus_record_finding result missing findings array",
|
|
271
|
+
"findings",
|
|
272
|
+
)
|
|
297
273
|
}
|
|
274
|
+
return 0
|
|
298
275
|
}
|
|
299
276
|
|
|
300
|
-
|
|
301
|
-
|
|
277
|
+
const items: unknown[] = []
|
|
278
|
+
if (config.nestedArrayKey) {
|
|
279
|
+
for (const rawOuter of topLevel) {
|
|
280
|
+
const outer = toRecord(rawOuter)
|
|
281
|
+
if (!outer) continue
|
|
302
282
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
if (!Array.isArray(findings)) {
|
|
311
|
-
diag.error(
|
|
312
|
-
"MISSING_REQUIRED_FIELD",
|
|
313
|
-
"argus_record_finding result missing findings array",
|
|
314
|
-
"findings",
|
|
315
|
-
)
|
|
316
|
-
return 0
|
|
283
|
+
const nested = outer[config.nestedArrayKey]
|
|
284
|
+
if (!Array.isArray(nested)) continue
|
|
285
|
+
|
|
286
|
+
items.push(...nested)
|
|
287
|
+
}
|
|
288
|
+
} else {
|
|
289
|
+
items.push(...topLevel)
|
|
317
290
|
}
|
|
318
291
|
|
|
319
292
|
let count = 0
|
|
320
|
-
for (const
|
|
321
|
-
const
|
|
322
|
-
if (!
|
|
293
|
+
for (const rawItem of items) {
|
|
294
|
+
const item = toRecord(rawItem)
|
|
295
|
+
if (!item) continue
|
|
323
296
|
|
|
324
|
-
const check =
|
|
325
|
-
const description =
|
|
326
|
-
const
|
|
327
|
-
const
|
|
297
|
+
const check = item[config.primaryIdField]
|
|
298
|
+
const description = item.description
|
|
299
|
+
const rawFile = item.file
|
|
300
|
+
const file =
|
|
301
|
+
typeof rawFile === "string" && projectDir ? normalizeFilePath(rawFile, projectDir) : rawFile
|
|
302
|
+
const lines = toLines(item.lines)
|
|
328
303
|
|
|
329
304
|
if (
|
|
330
305
|
typeof check !== "string" ||
|
|
@@ -332,51 +307,65 @@ function processRecordedFindingResult(
|
|
|
332
307
|
typeof file !== "string" ||
|
|
333
308
|
!lines
|
|
334
309
|
) {
|
|
335
|
-
const missing = identifyMissingFields(
|
|
310
|
+
const missing = identifyMissingFields(item, config.requiredFields)
|
|
336
311
|
diag.error(
|
|
337
312
|
"MISSING_REQUIRED_FIELD",
|
|
338
|
-
|
|
313
|
+
`${config.toolLabel} finding skipped: missing ${missing.join(", ")}`,
|
|
339
314
|
missing[0],
|
|
340
315
|
)
|
|
341
316
|
continue
|
|
342
317
|
}
|
|
343
318
|
|
|
344
|
-
const reportedByAgentRaw =
|
|
319
|
+
const reportedByAgentRaw = item.reported_by_agent
|
|
345
320
|
const reportedByAgent =
|
|
346
|
-
|
|
347
|
-
reportedByAgentRaw === "
|
|
348
|
-
reportedByAgentRaw === "
|
|
349
|
-
reportedByAgentRaw === "scribe" ||
|
|
350
|
-
reportedByAgentRaw === "unknown"
|
|
321
|
+
config.allowReportedByOverride &&
|
|
322
|
+
typeof reportedByAgentRaw === "string" &&
|
|
323
|
+
(isArgusFamily(reportedByAgentRaw) || reportedByAgentRaw === "unknown")
|
|
351
324
|
? (reportedByAgentRaw as ArgusAgentName)
|
|
352
325
|
: metadata.reportedByAgent
|
|
353
326
|
|
|
354
|
-
|
|
327
|
+
const findingPayload: Parameters<FindingStore["addFinding"]>[0] = {
|
|
355
328
|
check,
|
|
356
|
-
severity: toSeverity(
|
|
357
|
-
confidence:
|
|
329
|
+
severity: toSeverity(item.severity),
|
|
330
|
+
confidence:
|
|
331
|
+
config.confidenceMode === "read"
|
|
332
|
+
? toConfidence(item.confidence)
|
|
333
|
+
: toConfidence(config.confidenceDefault),
|
|
358
334
|
description,
|
|
359
335
|
file,
|
|
360
336
|
lines,
|
|
361
|
-
source:
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
337
|
+
source:
|
|
338
|
+
config.sourceLabel === "dynamic"
|
|
339
|
+
? toFindingSource(item.source)
|
|
340
|
+
: toFindingSource(config.sourceLabel),
|
|
365
341
|
reported_by_agent: reportedByAgent,
|
|
366
342
|
reported_by_session_id:
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
343
|
+
config.allowReportedByOverride &&
|
|
344
|
+
typeof item.reported_by_session_id === "string" &&
|
|
345
|
+
item.reported_by_session_id.length > 0
|
|
346
|
+
? item.reported_by_session_id
|
|
370
347
|
: metadata.reportedBySessionId,
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
typeof
|
|
379
|
-
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (config.extractOptionalFields) {
|
|
351
|
+
findingPayload.impact = typeof item.impact === "string" ? item.impact : undefined
|
|
352
|
+
findingPayload.recommendation =
|
|
353
|
+
typeof item.recommendation === "string" ? item.recommendation : undefined
|
|
354
|
+
findingPayload.proofOfConcept =
|
|
355
|
+
typeof item.proofOfConcept === "string" ? item.proofOfConcept : undefined
|
|
356
|
+
findingPayload.remediation =
|
|
357
|
+
typeof item.remediation === "string" ? item.remediation : undefined
|
|
358
|
+
findingPayload.exploitReference =
|
|
359
|
+
typeof item.exploitReference === "string" ? item.exploitReference : undefined
|
|
360
|
+
findingPayload.issue_fingerprint =
|
|
361
|
+
typeof item.issue_fingerprint === "string" ? item.issue_fingerprint : undefined
|
|
362
|
+
findingPayload.observation_fingerprint =
|
|
363
|
+
typeof item.observation_fingerprint === "string" ? item.observation_fingerprint : undefined
|
|
364
|
+
findingPayload.observation_id =
|
|
365
|
+
typeof item.observation_id === "string" ? item.observation_id : undefined
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
store.addFinding(findingPayload)
|
|
380
369
|
count++
|
|
381
370
|
}
|
|
382
371
|
|
|
@@ -472,21 +461,77 @@ function recordToolExecution(state: AuditState, toolName: string, findingsCount:
|
|
|
472
461
|
})
|
|
473
462
|
}
|
|
474
463
|
|
|
464
|
+
const TOOL_PHASE_MAP: Record<string, AuditState["currentPhase"]> = {
|
|
465
|
+
argus_slither_analyze: "scanning",
|
|
466
|
+
argus_check_patterns: "scanning",
|
|
467
|
+
argus_analyze_contract: "scanning",
|
|
468
|
+
argus_proxy_detection: "scanning",
|
|
469
|
+
argus_solodit_search: "research",
|
|
470
|
+
argus_forge_test: "testing",
|
|
471
|
+
argus_forge_fuzz: "testing",
|
|
472
|
+
argus_forge_coverage: "testing",
|
|
473
|
+
argus_gas_analysis: "testing",
|
|
474
|
+
argus_generate_report: "reporting",
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function inferPhaseAdvancement(
|
|
478
|
+
state: AuditState,
|
|
479
|
+
toolName: string,
|
|
480
|
+
): AuditState["currentPhase"] | null {
|
|
481
|
+
const inferredPhase = TOOL_PHASE_MAP[toolName]
|
|
482
|
+
if (!inferredPhase) return null
|
|
483
|
+
|
|
484
|
+
const currentIdx = PHASE_ORDER.indexOf(state.currentPhase)
|
|
485
|
+
const inferredIdx = PHASE_ORDER.indexOf(inferredPhase)
|
|
486
|
+
if (inferredIdx <= currentIdx) return null
|
|
487
|
+
|
|
488
|
+
return inferredPhase
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
type OrphanEvent = {
|
|
492
|
+
event: AuditEvent
|
|
493
|
+
failFast: boolean
|
|
494
|
+
bufferedAt: number
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const ORPHAN_BUFFER_TTL_MS = 60_000
|
|
498
|
+
const MAX_ORPHAN_EVENTS_PER_SESSION = 50
|
|
499
|
+
|
|
475
500
|
export type ToolTrackingHook = {
|
|
476
501
|
(input: ToolHookInput): Promise<void>
|
|
477
502
|
getLastDiagnostics(): DropDiagnostic[]
|
|
503
|
+
flushOrphanEvents(sessionId: string, sink: EventSink): Promise<number>
|
|
478
504
|
}
|
|
479
505
|
|
|
480
506
|
export function createToolTrackingHook(
|
|
481
|
-
getAuditState: () => AuditState | null,
|
|
507
|
+
getAuditState: (sessionId?: string) => AuditState | null,
|
|
482
508
|
onStateChanged?: (metadata: ToolExecutionMetadata) => void,
|
|
483
509
|
options?: ToolTrackingOptions,
|
|
484
510
|
): ToolTrackingHook {
|
|
511
|
+
const projectDir = options?.projectDir
|
|
485
512
|
const storesByState = new WeakMap<AuditState, FindingStore>()
|
|
486
513
|
let lastDiagnostics: DropDiagnostic[] = []
|
|
514
|
+
const orphanBuffer = new Map<string, OrphanEvent[]>()
|
|
487
515
|
|
|
488
|
-
function
|
|
489
|
-
|
|
516
|
+
function bufferOrphanEvent(sessionId: string, entry: OrphanEvent): void {
|
|
517
|
+
let entries = orphanBuffer.get(sessionId)
|
|
518
|
+
if (!entries) {
|
|
519
|
+
entries = []
|
|
520
|
+
orphanBuffer.set(sessionId, entries)
|
|
521
|
+
}
|
|
522
|
+
if (entries.length >= MAX_ORPHAN_EVENTS_PER_SESSION) {
|
|
523
|
+
logger.warn(
|
|
524
|
+
`Orphan event buffer full for session ${sessionId} (${MAX_ORPHAN_EVENTS_PER_SESSION} events) — dropping oldest`,
|
|
525
|
+
)
|
|
526
|
+
entries.shift()
|
|
527
|
+
}
|
|
528
|
+
entries.push(entry)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function resolveStateAndStore(
|
|
532
|
+
sessionId?: string,
|
|
533
|
+
): { state: AuditState; store: FindingStore } | null {
|
|
534
|
+
const state = getAuditState(sessionId)
|
|
490
535
|
if (!state) return null
|
|
491
536
|
|
|
492
537
|
let store = storesByState.get(state)
|
|
@@ -503,8 +548,7 @@ export function createToolTrackingHook(
|
|
|
503
548
|
if (input.tool === "task") {
|
|
504
549
|
const childSessionId = parseChildSessionId(input.result)
|
|
505
550
|
const correlationId = randomUUID()
|
|
506
|
-
const resolved = resolveStateAndStore()
|
|
507
|
-
const sink = options?.getEventSink?.()
|
|
551
|
+
const resolved = resolveStateAndStore(input.sessionID)
|
|
508
552
|
const sessionId = input.sessionID ?? options?.getSessionId?.() ?? ""
|
|
509
553
|
const toolCallId = randomUUID()
|
|
510
554
|
|
|
@@ -512,6 +556,26 @@ export function createToolTrackingHook(
|
|
|
512
556
|
options?.onChildSessionDetected?.(sessionId, childSessionId)
|
|
513
557
|
}
|
|
514
558
|
|
|
559
|
+
let sink: EventSink | null =
|
|
560
|
+
(sessionId ? options?.getEventSinkForSession?.(sessionId) : null) ??
|
|
561
|
+
options?.getEventSink?.() ??
|
|
562
|
+
null
|
|
563
|
+
|
|
564
|
+
if (sink && resolved) {
|
|
565
|
+
const runId = resolved.state.sessionId
|
|
566
|
+
if (sink.runId !== runId) {
|
|
567
|
+
const runScopedSink = options?.getEventSinkForRun?.(runId) ?? null
|
|
568
|
+
if (runScopedSink && runScopedSink.runId === runId) {
|
|
569
|
+
sink = runScopedSink
|
|
570
|
+
} else {
|
|
571
|
+
logger.warn(
|
|
572
|
+
`Skipping task sink emission due to run mismatch: state run ${runId}, sink run ${sink.runId}`,
|
|
573
|
+
)
|
|
574
|
+
sink = null
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
515
579
|
if (sink && resolved) {
|
|
516
580
|
const runId = resolved.state.sessionId
|
|
517
581
|
await emitToSink(
|
|
@@ -538,7 +602,7 @@ export function createToolTrackingHook(
|
|
|
538
602
|
|
|
539
603
|
if (resolved) {
|
|
540
604
|
recordToolExecution(resolved.state, "task", 0)
|
|
541
|
-
onStateChanged?.({ tool: "task", findingsCount: 0 })
|
|
605
|
+
onStateChanged?.({ tool: "task", findingsCount: 0, sessionId: input.sessionID })
|
|
542
606
|
}
|
|
543
607
|
|
|
544
608
|
return
|
|
@@ -548,7 +612,7 @@ export function createToolTrackingHook(
|
|
|
548
612
|
return
|
|
549
613
|
}
|
|
550
614
|
|
|
551
|
-
const resolved = resolveStateAndStore()
|
|
615
|
+
const resolved = resolveStateAndStore(input.sessionID)
|
|
552
616
|
if (!resolved) {
|
|
553
617
|
if (input.tool === "argus_record_finding") {
|
|
554
618
|
throw new Error("argus_record_finding requires active audit state")
|
|
@@ -577,9 +641,34 @@ export function createToolTrackingHook(
|
|
|
577
641
|
}
|
|
578
642
|
|
|
579
643
|
const { state: auditState, store } = resolved
|
|
580
|
-
const sink = options?.getEventSink?.()
|
|
581
644
|
const runId = auditState.sessionId
|
|
582
645
|
const sessionId = input.sessionID ?? options?.getSessionId?.() ?? ""
|
|
646
|
+
let sink: EventSink | null =
|
|
647
|
+
(sessionId ? options?.getEventSinkForSession?.(sessionId) : null) ??
|
|
648
|
+
options?.getEventSink?.() ??
|
|
649
|
+
null
|
|
650
|
+
if (sink && sink.runId !== runId) {
|
|
651
|
+
const runScopedSink = options?.getEventSinkForRun?.(runId) ?? null
|
|
652
|
+
if (runScopedSink && runScopedSink.runId === runId) {
|
|
653
|
+
sink = runScopedSink
|
|
654
|
+
} else {
|
|
655
|
+
// Single-run coalescence: if exactly one active (non-finalized) sink
|
|
656
|
+
// exists, use it rather than dropping events silently.
|
|
657
|
+
const activeSinks = options?.getActiveRunSinks?.() ?? []
|
|
658
|
+
const coalescedSink = activeSinks.length === 1 ? activeSinks[0] : undefined
|
|
659
|
+
if (coalescedSink) {
|
|
660
|
+
logger.debug(
|
|
661
|
+
`Coalescing tool ${input.tool} from session ${sessionId} into active run ${coalescedSink.runId} (state run ${runId}, original sink run ${sink.runId})`,
|
|
662
|
+
)
|
|
663
|
+
sink = coalescedSink
|
|
664
|
+
} else {
|
|
665
|
+
logger.warn(
|
|
666
|
+
`Skipping sink emission for ${input.tool} due to run mismatch: state run ${runId}, sink run ${sink.runId}`,
|
|
667
|
+
)
|
|
668
|
+
sink = null
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
583
672
|
const reportedByAgent =
|
|
584
673
|
(input.sessionID ? options?.getAgentNameForSession?.(input.sessionID) : undefined) ??
|
|
585
674
|
options?.getAgentName?.() ??
|
|
@@ -601,186 +690,376 @@ export function createToolTrackingHook(
|
|
|
601
690
|
}),
|
|
602
691
|
{ failFast: input.tool === "argus_record_finding" },
|
|
603
692
|
)
|
|
693
|
+
} else if (sessionId) {
|
|
694
|
+
const event = buildEvent("tool.started", runId, sessionId, toolCallId, {
|
|
695
|
+
tool: input.tool,
|
|
696
|
+
args: input.args,
|
|
697
|
+
})
|
|
698
|
+
bufferOrphanEvent(sessionId, {
|
|
699
|
+
event,
|
|
700
|
+
failFast: input.tool === "argus_record_finding",
|
|
701
|
+
bufferedAt: Date.now(),
|
|
702
|
+
})
|
|
703
|
+
logger.warn(
|
|
704
|
+
`Buffered orphan tool.started event for ${input.tool} from session ${sessionId} (run_id=${runId})`,
|
|
705
|
+
)
|
|
604
706
|
}
|
|
605
707
|
|
|
606
708
|
const findingsCountBefore = auditState.findings.length
|
|
709
|
+
let findingsCount = 0
|
|
710
|
+
let completedSuccess = false
|
|
711
|
+
let completionError: string | undefined
|
|
607
712
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
auditState.skillsLoaded.
|
|
713
|
+
try {
|
|
714
|
+
if (input.tool === "argus_skill_load") {
|
|
715
|
+
const nameMatch = input.result.match(/^##\s+Argus Skill:\s+(.+?)(?:\s+\[|$)/m)
|
|
716
|
+
const skillName = nameMatch?.[1]?.trim()
|
|
717
|
+
if (skillName) {
|
|
718
|
+
auditState.skillsLoaded ??= []
|
|
719
|
+
if (!auditState.skillsLoaded.includes(skillName)) {
|
|
720
|
+
auditState.skillsLoaded.push(skillName)
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
findingsCount = 0
|
|
724
|
+
completedSuccess = true
|
|
725
|
+
} else {
|
|
726
|
+
let parsed: unknown
|
|
727
|
+
try {
|
|
728
|
+
parsed = JSON.parse(input.result)
|
|
729
|
+
} catch {
|
|
730
|
+
// For large tool outputs (e.g. argus_check_patterns can produce 3MB+),
|
|
731
|
+
// OpenCode may truncate the result before it reaches this hook.
|
|
732
|
+
// Two truncation modes:
|
|
733
|
+
// 1. Partial JSON — first N bytes of valid JSON (check for "success": true)
|
|
734
|
+
// 2. OpenCode replacement — full output replaced with "...N bytes truncated..."
|
|
735
|
+
const successInPartialJson = input.result.match(/"success"\s*:\s*(true|false)/)
|
|
736
|
+
const opencodeTruncation = input.result.match(
|
|
737
|
+
/bytes truncated|output was truncated|tool call succeeded/i,
|
|
738
|
+
)
|
|
739
|
+
const truncatedSuccess = successInPartialJson?.[1] === "true" || !!opencodeTruncation
|
|
740
|
+
if (truncatedSuccess) {
|
|
741
|
+
diag.error(
|
|
742
|
+
"TRUNCATED_OUTPUT",
|
|
743
|
+
`${input.tool} output was truncated (${input.result.length} chars) — tool likely succeeded`,
|
|
744
|
+
)
|
|
745
|
+
logger.warn(
|
|
746
|
+
`Tool output truncated — findings may be incomplete (${input.tool}, ${input.result.length} chars)`,
|
|
747
|
+
)
|
|
748
|
+
completionError = "Tool output truncated — findings may be incomplete"
|
|
749
|
+
} else {
|
|
750
|
+
diag.error("MALFORMED_JSON", `Failed to parse JSON result from ${input.tool}`)
|
|
751
|
+
if (input.tool === "argus_record_finding") {
|
|
752
|
+
throw new Error("argus_record_finding returned malformed JSON")
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
diag.throwIfStrict()
|
|
756
|
+
return
|
|
615
757
|
}
|
|
616
|
-
}
|
|
617
|
-
recordToolExecution(auditState, input.tool, 0)
|
|
618
|
-
onStateChanged?.({ tool: input.tool, findingsCount: 0 })
|
|
619
758
|
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
}),
|
|
628
|
-
)
|
|
629
|
-
}
|
|
759
|
+
const record = toRecord(parsed)
|
|
760
|
+
if (!record) {
|
|
761
|
+
if (input.tool === "argus_record_finding") {
|
|
762
|
+
throw new Error("argus_record_finding response must be a JSON object")
|
|
763
|
+
}
|
|
764
|
+
return
|
|
765
|
+
}
|
|
630
766
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
767
|
+
switch (input.tool) {
|
|
768
|
+
case "argus_slither_analyze": {
|
|
769
|
+
findingsCount = processToolResult(
|
|
770
|
+
record,
|
|
771
|
+
store,
|
|
772
|
+
diag,
|
|
773
|
+
findingMetadata,
|
|
774
|
+
SLITHER_CONFIG,
|
|
775
|
+
projectDir,
|
|
776
|
+
)
|
|
777
|
+
if (auditState.scope.length === 0 && findingsCount > 0) {
|
|
778
|
+
const slitherFindings = Array.isArray(record.findings) ? record.findings : []
|
|
779
|
+
const files = [
|
|
780
|
+
...new Set(
|
|
781
|
+
slitherFindings
|
|
782
|
+
.map((f: Record<string, unknown>) => f.file as string)
|
|
783
|
+
.filter(Boolean),
|
|
784
|
+
),
|
|
785
|
+
]
|
|
786
|
+
if (files.length > 0) {
|
|
787
|
+
auditState.scope = files
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
break
|
|
791
|
+
}
|
|
792
|
+
case "argus_check_patterns":
|
|
793
|
+
findingsCount = processToolResult(
|
|
794
|
+
record,
|
|
795
|
+
store,
|
|
796
|
+
diag,
|
|
797
|
+
findingMetadata,
|
|
798
|
+
PATTERN_CONFIG,
|
|
799
|
+
projectDir,
|
|
800
|
+
)
|
|
801
|
+
if (typeof record.patternVersion === "string") {
|
|
802
|
+
auditState.patternVersion = record.patternVersion
|
|
803
|
+
}
|
|
804
|
+
break
|
|
805
|
+
case "argus_record_finding":
|
|
806
|
+
findingsCount = processToolResult(
|
|
807
|
+
record,
|
|
808
|
+
store,
|
|
809
|
+
diag,
|
|
810
|
+
findingMetadata,
|
|
811
|
+
RECORDED_CONFIG,
|
|
812
|
+
projectDir,
|
|
813
|
+
)
|
|
814
|
+
break
|
|
815
|
+
case "argus_analyze_contract": {
|
|
816
|
+
processContractAnalyzerResult(record, auditState)
|
|
817
|
+
const filePath = (input.args as Record<string, unknown>)?.file_path as string
|
|
818
|
+
if (filePath && !auditState.scope.includes(filePath)) {
|
|
819
|
+
auditState.scope = [...auditState.scope, filePath]
|
|
820
|
+
}
|
|
821
|
+
break
|
|
822
|
+
}
|
|
823
|
+
case "argus_solodit_search":
|
|
824
|
+
processSoloditResult(record, auditState)
|
|
825
|
+
break
|
|
826
|
+
case "argus_forge_test": {
|
|
827
|
+
const summary = toRecord(record.summary)
|
|
828
|
+
if (summary && typeof summary.failed === "number") {
|
|
829
|
+
findingsCount = summary.failed
|
|
830
|
+
}
|
|
831
|
+
break
|
|
832
|
+
}
|
|
833
|
+
case "argus_forge_fuzz":
|
|
834
|
+
processFuzzResult(record, auditState)
|
|
835
|
+
break
|
|
836
|
+
case "argus_generate_report": {
|
|
837
|
+
const reportError = toRecord(record.error)
|
|
838
|
+
const filePath = record.filePath
|
|
839
|
+
if (reportError) {
|
|
840
|
+
const errorMessage =
|
|
841
|
+
typeof reportError.message === "string"
|
|
842
|
+
? reportError.message
|
|
843
|
+
: "argus_generate_report reported an unknown error"
|
|
844
|
+
throw new Error(`argus_generate_report failed: ${errorMessage}`)
|
|
845
|
+
}
|
|
846
|
+
if (typeof filePath !== "string" || filePath.length === 0) {
|
|
847
|
+
throw new Error("argus_generate_report completed without filePath")
|
|
848
|
+
}
|
|
849
|
+
auditState.reportGenerated = true
|
|
850
|
+
break
|
|
851
|
+
}
|
|
852
|
+
case "argus_sync_knowledge": {
|
|
853
|
+
const success = record.success === true
|
|
854
|
+
auditState.knowledgeSynced = { success, timestamp: Date.now() }
|
|
855
|
+
break
|
|
856
|
+
}
|
|
857
|
+
case "argus_forge_coverage": {
|
|
858
|
+
const reportObj = toRecord(record.report)
|
|
859
|
+
const files = reportObj?.files
|
|
860
|
+
if (Array.isArray(files)) {
|
|
861
|
+
auditState.coverageReport = {
|
|
862
|
+
files: files
|
|
863
|
+
.filter((f): f is Record<string, unknown> => !!f && typeof f === "object")
|
|
864
|
+
.map((f) => ({
|
|
865
|
+
path: typeof f.path === "string" ? f.path : "unknown",
|
|
866
|
+
linesPct: typeof f.linesPct === "number" ? f.linesPct : 0,
|
|
867
|
+
statementsPct: typeof f.statementsPct === "number" ? f.statementsPct : 0,
|
|
868
|
+
branchesPct: typeof f.branchesPct === "number" ? f.branchesPct : 0,
|
|
869
|
+
functionsPct: typeof f.functionsPct === "number" ? f.functionsPct : 0,
|
|
870
|
+
})),
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
break
|
|
874
|
+
}
|
|
875
|
+
case "argus_proxy_detection": {
|
|
876
|
+
if (record.isProxy === true) {
|
|
877
|
+
auditState.proxyContracts ??= []
|
|
878
|
+
auditState.proxyContracts.push({
|
|
879
|
+
file: typeof record.file === "string" ? record.file : "unknown",
|
|
880
|
+
proxyType: typeof record.proxyType === "string" ? record.proxyType : "unknown",
|
|
881
|
+
indicators: Array.isArray(record.indicators)
|
|
882
|
+
? record.indicators.filter((i): i is string => typeof i === "string")
|
|
883
|
+
: [],
|
|
884
|
+
})
|
|
885
|
+
}
|
|
886
|
+
break
|
|
887
|
+
}
|
|
888
|
+
case "argus_gas_analysis": {
|
|
889
|
+
const hotspots = record.hotspots
|
|
890
|
+
if (Array.isArray(hotspots)) {
|
|
891
|
+
auditState.gasHotspots = hotspots
|
|
892
|
+
.filter((h): h is Record<string, unknown> => !!h && typeof h === "object")
|
|
893
|
+
.map((h) => ({
|
|
894
|
+
contract: typeof h.contract === "string" ? h.contract : "unknown",
|
|
895
|
+
function: typeof h.function === "string" ? h.function : "unknown",
|
|
896
|
+
avgGas: typeof h.avgGas === "number" ? h.avgGas : 0,
|
|
897
|
+
}))
|
|
898
|
+
}
|
|
899
|
+
break
|
|
900
|
+
}
|
|
901
|
+
}
|
|
634
902
|
|
|
635
|
-
|
|
636
|
-
try {
|
|
637
|
-
parsed = JSON.parse(input.result)
|
|
638
|
-
} catch {
|
|
639
|
-
diag.error("MALFORMED_JSON", `Failed to parse JSON result from ${input.tool}`)
|
|
640
|
-
lastDiagnostics = diag.getDiagnostics()
|
|
641
|
-
if (input.tool === "argus_record_finding") {
|
|
642
|
-
throw new Error("argus_record_finding returned malformed JSON")
|
|
643
|
-
}
|
|
644
|
-
diag.throwIfStrict()
|
|
645
|
-
return
|
|
646
|
-
}
|
|
903
|
+
diag.throwIfStrict()
|
|
647
904
|
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
if (input.tool === "argus_record_finding") {
|
|
652
|
-
throw new Error("argus_record_finding response must be a JSON object")
|
|
653
|
-
}
|
|
654
|
-
return
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
let findingsCount = 0
|
|
905
|
+
if (input.tool === "argus_record_finding" && findingsCount === 0) {
|
|
906
|
+
throw new Error("argus_record_finding did not persist any findings")
|
|
907
|
+
}
|
|
658
908
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
processContractAnalyzerResult(record, auditState)
|
|
671
|
-
break
|
|
672
|
-
case "argus_solodit_search":
|
|
673
|
-
processSoloditResult(record, auditState)
|
|
674
|
-
break
|
|
675
|
-
case "argus_forge_test": {
|
|
676
|
-
const summary = toRecord(record.summary)
|
|
677
|
-
if (summary && typeof summary.failed === "number") {
|
|
678
|
-
findingsCount = summary.failed
|
|
909
|
+
if (input.tool === "argus_record_finding" && !sink) {
|
|
910
|
+
const newFindings = auditState.findings.slice(findingsCountBefore)
|
|
911
|
+
if (newFindings.length > 0) {
|
|
912
|
+
throw new Error(
|
|
913
|
+
`argus_record_finding produced ${newFindings.length} finding(s) but no event sink is available — findings would be lost from the report`,
|
|
914
|
+
)
|
|
915
|
+
}
|
|
916
|
+
diag.error(
|
|
917
|
+
"NO_EVENT_SINK",
|
|
918
|
+
"argus_record_finding: no active event sink — no new findings to emit",
|
|
919
|
+
)
|
|
679
920
|
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
path: typeof f.path === "string" ? f.path : "unknown",
|
|
703
|
-
linesPct: typeof f.linesPct === "number" ? f.linesPct : 0,
|
|
704
|
-
statementsPct: typeof f.statementsPct === "number" ? f.statementsPct : 0,
|
|
705
|
-
branchesPct: typeof f.branchesPct === "number" ? f.branchesPct : 0,
|
|
706
|
-
functionsPct: typeof f.functionsPct === "number" ? f.functionsPct : 0,
|
|
707
|
-
})),
|
|
921
|
+
|
|
922
|
+
if (sink) {
|
|
923
|
+
const failFast = input.tool === "argus_record_finding"
|
|
924
|
+
const newFindings = auditState.findings.slice(findingsCountBefore)
|
|
925
|
+
for (const [index, finding] of newFindings.entries()) {
|
|
926
|
+
const { data: canonical } = normalizeToCanonicalFinding(
|
|
927
|
+
finding,
|
|
928
|
+
runId,
|
|
929
|
+
0,
|
|
930
|
+
{
|
|
931
|
+
reportedByAgent,
|
|
932
|
+
reportedBySessionId: sessionId,
|
|
933
|
+
toolCallId,
|
|
934
|
+
observationId: `${toolCallId}:${index + 1}`,
|
|
935
|
+
},
|
|
936
|
+
projectDir,
|
|
937
|
+
)
|
|
938
|
+
await emitToSink(
|
|
939
|
+
sink,
|
|
940
|
+
buildEvent("finding.added", runId, sessionId, toolCallId, canonical),
|
|
941
|
+
{ failFast },
|
|
942
|
+
)
|
|
708
943
|
}
|
|
709
944
|
}
|
|
710
|
-
|
|
945
|
+
|
|
946
|
+
completedSuccess = true
|
|
711
947
|
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
948
|
+
|
|
949
|
+
recordToolExecution(auditState, input.tool, findingsCount)
|
|
950
|
+
|
|
951
|
+
const nextPhase = inferPhaseAdvancement(auditState, input.tool)
|
|
952
|
+
if (nextPhase) {
|
|
953
|
+
auditState.currentPhase = nextPhase
|
|
954
|
+
if (sink) {
|
|
955
|
+
await emitToSink(
|
|
956
|
+
sink,
|
|
957
|
+
buildEvent("phase.changed", runId, sessionId, toolCallId, {
|
|
958
|
+
phase: nextPhase,
|
|
959
|
+
trigger: input.tool,
|
|
960
|
+
}),
|
|
961
|
+
)
|
|
722
962
|
}
|
|
723
|
-
break
|
|
724
963
|
}
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
964
|
+
|
|
965
|
+
onStateChanged?.({ tool: input.tool, findingsCount, sessionId: input.sessionID })
|
|
966
|
+
} catch (error) {
|
|
967
|
+
completionError = error instanceof Error ? error.message : String(error)
|
|
968
|
+
throw error
|
|
969
|
+
} finally {
|
|
970
|
+
lastDiagnostics = diag.getDiagnostics()
|
|
971
|
+
if (sink) {
|
|
972
|
+
const failFast = input.tool === "argus_record_finding"
|
|
973
|
+
// Enrichment data for event replay — projector extracts these from payloads
|
|
974
|
+
const enrichment: Record<string, unknown> = {}
|
|
975
|
+
if (completedSuccess) {
|
|
976
|
+
switch (input.tool) {
|
|
977
|
+
case "argus_solodit_search":
|
|
978
|
+
if (auditState.soloditResults) enrichment.soloditResults = auditState.soloditResults
|
|
979
|
+
break
|
|
980
|
+
case "argus_forge_fuzz":
|
|
981
|
+
if (auditState.fuzzCounterexamples)
|
|
982
|
+
enrichment.fuzzCounterexamples = auditState.fuzzCounterexamples
|
|
983
|
+
break
|
|
984
|
+
case "argus_forge_coverage":
|
|
985
|
+
if (auditState.coverageReport) enrichment.coverageReport = auditState.coverageReport
|
|
986
|
+
break
|
|
987
|
+
case "argus_gas_analysis":
|
|
988
|
+
if (auditState.gasHotspots) enrichment.gasHotspots = auditState.gasHotspots
|
|
989
|
+
break
|
|
990
|
+
case "argus_proxy_detection":
|
|
991
|
+
if (auditState.proxyContracts) enrichment.proxyContracts = auditState.proxyContracts
|
|
992
|
+
break
|
|
993
|
+
case "argus_skill_load":
|
|
994
|
+
if (auditState.skillsLoaded) enrichment.skillsLoaded = auditState.skillsLoaded
|
|
995
|
+
break
|
|
996
|
+
case "argus_check_patterns":
|
|
997
|
+
if (auditState.patternVersion) enrichment.patternVersion = auditState.patternVersion
|
|
998
|
+
break
|
|
999
|
+
}
|
|
735
1000
|
}
|
|
736
|
-
|
|
1001
|
+
await emitToSink(
|
|
1002
|
+
sink,
|
|
1003
|
+
buildEvent("tool.completed", runId, sessionId, toolCallId, {
|
|
1004
|
+
tool: input.tool,
|
|
1005
|
+
findingsCount,
|
|
1006
|
+
success: completedSuccess,
|
|
1007
|
+
...(completionError ? { error: completionError } : {}),
|
|
1008
|
+
...enrichment,
|
|
1009
|
+
}),
|
|
1010
|
+
{ failFast },
|
|
1011
|
+
)
|
|
1012
|
+
} else if (sessionId) {
|
|
1013
|
+
const enrichment: Record<string, unknown> = {}
|
|
1014
|
+
const event = buildEvent("tool.completed", runId, sessionId, toolCallId, {
|
|
1015
|
+
tool: input.tool,
|
|
1016
|
+
findingsCount,
|
|
1017
|
+
success: completedSuccess,
|
|
1018
|
+
...(completionError ? { error: completionError } : {}),
|
|
1019
|
+
...enrichment,
|
|
1020
|
+
})
|
|
1021
|
+
bufferOrphanEvent(sessionId, {
|
|
1022
|
+
event,
|
|
1023
|
+
failFast: input.tool === "argus_record_finding",
|
|
1024
|
+
bufferedAt: Date.now(),
|
|
1025
|
+
})
|
|
1026
|
+
logger.warn(
|
|
1027
|
+
`Buffered orphan tool.completed event for ${input.tool} from session ${sessionId} (run_id=${runId}, findings=${findingsCount})`,
|
|
1028
|
+
)
|
|
737
1029
|
}
|
|
738
1030
|
}
|
|
1031
|
+
}
|
|
739
1032
|
|
|
740
|
-
|
|
741
|
-
diag.throwIfStrict()
|
|
1033
|
+
hookFn.getLastDiagnostics = (): DropDiagnostic[] => lastDiagnostics
|
|
742
1034
|
|
|
743
|
-
|
|
744
|
-
|
|
1035
|
+
hookFn.flushOrphanEvents = async (sessionId: string, sink: EventSink): Promise<number> => {
|
|
1036
|
+
const entries = orphanBuffer.get(sessionId)
|
|
1037
|
+
if (!entries || entries.length === 0) {
|
|
1038
|
+
return 0
|
|
745
1039
|
}
|
|
746
1040
|
|
|
747
|
-
|
|
748
|
-
|
|
1041
|
+
orphanBuffer.delete(sessionId)
|
|
1042
|
+
const now = Date.now()
|
|
1043
|
+
const fresh = entries.filter((e) => now - e.bufferedAt < ORPHAN_BUFFER_TTL_MS)
|
|
749
1044
|
|
|
750
|
-
if (
|
|
751
|
-
|
|
1045
|
+
if (fresh.length < entries.length) {
|
|
1046
|
+
logger.debug(
|
|
1047
|
+
`Discarded ${entries.length - fresh.length} stale orphan events for session ${sessionId}`,
|
|
1048
|
+
)
|
|
752
1049
|
}
|
|
753
1050
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
reportedByAgent,
|
|
760
|
-
reportedBySessionId: sessionId,
|
|
761
|
-
toolCallId,
|
|
762
|
-
observationId: `${toolCallId}:${index + 1}`,
|
|
763
|
-
})
|
|
764
|
-
await emitToSink(
|
|
765
|
-
sink,
|
|
766
|
-
buildEvent("finding.added", runId, sessionId, toolCallId, canonical),
|
|
767
|
-
{ failFast },
|
|
768
|
-
)
|
|
769
|
-
}
|
|
1051
|
+
let flushed = 0
|
|
1052
|
+
for (const entry of fresh) {
|
|
1053
|
+
await emitToSink(sink, entry.event, { failFast: entry.failFast })
|
|
1054
|
+
flushed++
|
|
1055
|
+
}
|
|
770
1056
|
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
buildEvent("tool.completed", runId, sessionId, toolCallId, {
|
|
774
|
-
tool: input.tool,
|
|
775
|
-
findingsCount,
|
|
776
|
-
success: true,
|
|
777
|
-
}),
|
|
778
|
-
{ failFast },
|
|
779
|
-
)
|
|
1057
|
+
if (flushed > 0) {
|
|
1058
|
+
logger.info(`Flushed ${flushed} orphan events for session ${sessionId} to sink ${sink.runId}`)
|
|
780
1059
|
}
|
|
781
|
-
}
|
|
782
1060
|
|
|
783
|
-
|
|
1061
|
+
return flushed
|
|
1062
|
+
}
|
|
784
1063
|
|
|
785
1064
|
return hookFn
|
|
786
1065
|
}
|