solidity-argus 0.3.3 → 0.3.4
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 +1 -1
- package/src/agents/argus-prompt.ts +21 -8
- package/src/agents/scribe-prompt.ts +9 -5
- package/src/cli/index.ts +0 -0
- package/src/config/schema.ts +5 -0
- package/src/create-hooks.ts +78 -22
- package/src/features/migration/index.ts +14 -0
- package/src/features/migration/migration-adapter.ts +151 -0
- package/src/features/migration/parity-telemetry.ts +133 -0
- package/src/features/persistent-state/event-sink.ts +171 -0
- package/src/features/persistent-state/index.ts +2 -0
- package/src/features/persistent-state/run-finalizer.ts +175 -0
- package/src/features/persistent-state/run-journal.ts +1 -1
- package/src/hooks/agent-tracker.ts +15 -0
- package/src/hooks/event-hook.ts +93 -1
- package/src/hooks/tool-tracking-hook.ts +263 -33
- package/src/shared/audit-artifact-resolver.ts +74 -0
- package/src/shared/drop-diagnostics.ts +108 -0
- package/src/shared/index.ts +14 -0
- package/src/shared/report-path-resolver.ts +70 -0
- package/src/solodit-lifecycle.ts +86 -7
- package/src/state/adapters.ts +262 -0
- package/src/state/index.ts +15 -0
- package/src/state/projectors.ts +437 -0
- package/src/state/schemas.ts +356 -0
- package/src/tools/report-generator-tool.ts +569 -31
- package/src/tools/solodit-search-tool.ts +11 -24
- package/src/utils/solodit-health.ts +18 -0
|
@@ -1,7 +1,21 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import type { EventSink } from "../features/persistent-state/event-sink"
|
|
3
|
+
import type {
|
|
4
|
+
DropDiagnostic,
|
|
5
|
+
DropDiagnosticsCollector,
|
|
6
|
+
DropPolicy,
|
|
7
|
+
} from "../shared/drop-diagnostics"
|
|
8
|
+
import { createDropDiagnosticsCollector } from "../shared/drop-diagnostics"
|
|
9
|
+
import { createLogger } from "../shared/logger"
|
|
10
|
+
import { normalizeToCanonicalFinding } from "../state/adapters"
|
|
1
11
|
import type { FindingStore } from "../state/finding-store"
|
|
2
12
|
import { createFindingStore } from "../state/finding-store"
|
|
13
|
+
import type { AuditEvent } from "../state/schemas"
|
|
14
|
+
import { SCHEMA_VERSION } from "../state/schemas"
|
|
3
15
|
import type { AuditState, FindingSeverity, FuzzCounterexample, SoloditResult } from "../state/types"
|
|
4
16
|
|
|
17
|
+
const logger = createLogger()
|
|
18
|
+
|
|
5
19
|
type ToolHookInput = {
|
|
6
20
|
tool: string
|
|
7
21
|
args: unknown
|
|
@@ -13,6 +27,13 @@ type ToolExecutionMetadata = {
|
|
|
13
27
|
findingsCount: number
|
|
14
28
|
}
|
|
15
29
|
|
|
30
|
+
export type ToolTrackingOptions = {
|
|
31
|
+
getEventSink?: () => EventSink | null
|
|
32
|
+
getSessionId?: () => string
|
|
33
|
+
dropPolicy?: DropPolicy
|
|
34
|
+
onChildSessionDetected?: (parentSessionId: string, childSessionId: string) => void
|
|
35
|
+
}
|
|
36
|
+
|
|
16
37
|
const VALID_SEVERITIES: ReadonlySet<string> = new Set([
|
|
17
38
|
"Critical",
|
|
18
39
|
"High",
|
|
@@ -56,7 +77,90 @@ function toRecord(value: unknown): Record<string, unknown> | undefined {
|
|
|
56
77
|
return undefined
|
|
57
78
|
}
|
|
58
79
|
|
|
59
|
-
function
|
|
80
|
+
async function emitToSink(sink: EventSink, event: AuditEvent): Promise<void> {
|
|
81
|
+
try {
|
|
82
|
+
await sink.append(event)
|
|
83
|
+
} catch (error) {
|
|
84
|
+
logger.error(
|
|
85
|
+
`Failed to emit ${event.type} event to sink: ${error instanceof Error ? error.message : String(error)}`,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildEvent(
|
|
91
|
+
type: AuditEvent["type"],
|
|
92
|
+
runId: string,
|
|
93
|
+
sessionId: string,
|
|
94
|
+
toolCallId: string,
|
|
95
|
+
payload: unknown,
|
|
96
|
+
): AuditEvent {
|
|
97
|
+
return {
|
|
98
|
+
type,
|
|
99
|
+
run_id: runId,
|
|
100
|
+
seq: 0,
|
|
101
|
+
session_id: sessionId,
|
|
102
|
+
tool_call_id: toolCallId,
|
|
103
|
+
source: "tool-tracking-hook",
|
|
104
|
+
schema_version: SCHEMA_VERSION,
|
|
105
|
+
timestamp: Date.now(),
|
|
106
|
+
payload,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Defensively parse a child session_id from a `task` tool result.
|
|
112
|
+
* The result may be JSON with a top-level or nested `session_id` field,
|
|
113
|
+
* or plain text with an embedded JSON fragment.
|
|
114
|
+
*/
|
|
115
|
+
function parseChildSessionId(result: string): string | null {
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(result)
|
|
118
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
119
|
+
if (typeof parsed.session_id === "string" && parsed.session_id.length > 0) {
|
|
120
|
+
return parsed.session_id
|
|
121
|
+
}
|
|
122
|
+
if (
|
|
123
|
+
typeof parsed.result === "object" &&
|
|
124
|
+
parsed.result !== null &&
|
|
125
|
+
!Array.isArray(parsed.result) &&
|
|
126
|
+
typeof parsed.result.session_id === "string" &&
|
|
127
|
+
parsed.result.session_id.length > 0
|
|
128
|
+
) {
|
|
129
|
+
return parsed.result.session_id
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
const match = result.match(/"session_id"\s*:\s*"([^"]+)"/)
|
|
134
|
+
if (match?.[1]) {
|
|
135
|
+
return match[1]
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function identifyMissingFields(
|
|
142
|
+
finding: Record<string, unknown>,
|
|
143
|
+
requiredFields: readonly string[],
|
|
144
|
+
): string[] {
|
|
145
|
+
const missing: string[] = []
|
|
146
|
+
for (const field of requiredFields) {
|
|
147
|
+
if (field === "lines") {
|
|
148
|
+
if (!toLines(finding.lines)) missing.push(field)
|
|
149
|
+
} else if (typeof finding[field] !== "string") {
|
|
150
|
+
missing.push(field)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return missing
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const SLITHER_REQUIRED = ["check", "description", "file", "lines"] as const
|
|
157
|
+
const PATTERN_REQUIRED = ["pattern", "description", "file", "lines"] as const
|
|
158
|
+
|
|
159
|
+
function processSlitherResult(
|
|
160
|
+
parsed: Record<string, unknown>,
|
|
161
|
+
store: FindingStore,
|
|
162
|
+
diag: DropDiagnosticsCollector,
|
|
163
|
+
): number {
|
|
60
164
|
const findings = parsed.findings
|
|
61
165
|
if (!Array.isArray(findings)) return 0
|
|
62
166
|
|
|
@@ -76,6 +180,12 @@ function processSlitherResult(parsed: Record<string, unknown>, store: FindingSto
|
|
|
76
180
|
typeof file !== "string" ||
|
|
77
181
|
!lines
|
|
78
182
|
) {
|
|
183
|
+
const missing = identifyMissingFields(finding, SLITHER_REQUIRED)
|
|
184
|
+
diag.error(
|
|
185
|
+
"MISSING_REQUIRED_FIELD",
|
|
186
|
+
`Slither finding skipped: missing ${missing.join(", ")}`,
|
|
187
|
+
missing[0],
|
|
188
|
+
)
|
|
79
189
|
continue
|
|
80
190
|
}
|
|
81
191
|
|
|
@@ -94,7 +204,11 @@ function processSlitherResult(parsed: Record<string, unknown>, store: FindingSto
|
|
|
94
204
|
return count
|
|
95
205
|
}
|
|
96
206
|
|
|
97
|
-
function processPatternResult(
|
|
207
|
+
function processPatternResult(
|
|
208
|
+
parsed: Record<string, unknown>,
|
|
209
|
+
store: FindingStore,
|
|
210
|
+
diag: DropDiagnosticsCollector,
|
|
211
|
+
): number {
|
|
98
212
|
const sources = parsed.sources
|
|
99
213
|
if (!Array.isArray(sources)) return 0
|
|
100
214
|
|
|
@@ -121,6 +235,12 @@ function processPatternResult(parsed: Record<string, unknown>, store: FindingSto
|
|
|
121
235
|
typeof file !== "string" ||
|
|
122
236
|
!lines
|
|
123
237
|
) {
|
|
238
|
+
const missing = identifyMissingFields(match, PATTERN_REQUIRED)
|
|
239
|
+
diag.error(
|
|
240
|
+
"MISSING_REQUIRED_FIELD",
|
|
241
|
+
`Pattern finding skipped: missing ${missing.join(", ")}`,
|
|
242
|
+
missing[0],
|
|
243
|
+
)
|
|
124
244
|
continue
|
|
125
245
|
}
|
|
126
246
|
|
|
@@ -141,7 +261,6 @@ function processPatternResult(parsed: Record<string, unknown>, store: FindingSto
|
|
|
141
261
|
}
|
|
142
262
|
|
|
143
263
|
function processContractAnalyzerResult(parsed: Record<string, unknown>, state: AuditState): void {
|
|
144
|
-
// Handle direct ContractProfile format (actual tool output)
|
|
145
264
|
if (typeof parsed.filePath === "string") {
|
|
146
265
|
if (!state.contractsReviewed.includes(parsed.filePath)) {
|
|
147
266
|
state.contractsReviewed.push(parsed.filePath)
|
|
@@ -149,7 +268,6 @@ function processContractAnalyzerResult(parsed: Record<string, unknown>, state: A
|
|
|
149
268
|
return
|
|
150
269
|
}
|
|
151
270
|
|
|
152
|
-
// Handle wrapped { contractProfile: { filePath } } format
|
|
153
271
|
const profile = toRecord(parsed.contractProfile)
|
|
154
272
|
if (profile && typeof profile.filePath === "string") {
|
|
155
273
|
if (!state.contractsReviewed.includes(profile.filePath)) {
|
|
@@ -220,19 +338,6 @@ function processSoloditResult(parsed: Record<string, unknown>, state: AuditState
|
|
|
220
338
|
})
|
|
221
339
|
}
|
|
222
340
|
|
|
223
|
-
/**
|
|
224
|
-
* Records a tool execution in the audit state.
|
|
225
|
-
*
|
|
226
|
-
* Multiple entries per tool name are allowed — if the same tool runs multiple times
|
|
227
|
-
* (e.g., argus_slither_analyze on different targets), each execution is recorded
|
|
228
|
-
* with its own findingsCount.
|
|
229
|
-
*
|
|
230
|
-
* Timing limitation: startTime and endTime are both set to Date.now() because this
|
|
231
|
-
* hook fires in the tool.execute.after phase, after execution has already completed.
|
|
232
|
-
* We cannot capture the actual start time. This is a known limitation of the hook
|
|
233
|
-
* architecture. For accurate timing, the hook would need to fire in tool.execute.before
|
|
234
|
-
* and tool.execute.after phases separately.
|
|
235
|
-
*/
|
|
236
341
|
function recordToolExecution(state: AuditState, toolName: string, findingsCount: number): void {
|
|
237
342
|
const now = Date.now()
|
|
238
343
|
state.toolsExecuted.push({
|
|
@@ -244,18 +349,18 @@ function recordToolExecution(state: AuditState, toolName: string, findingsCount:
|
|
|
244
349
|
})
|
|
245
350
|
}
|
|
246
351
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
* Findings are deduplicated via the FindingStore (by check+file+lines).
|
|
253
|
-
*/
|
|
352
|
+
export type ToolTrackingHook = {
|
|
353
|
+
(input: ToolHookInput): Promise<void>
|
|
354
|
+
getLastDiagnostics(): DropDiagnostic[]
|
|
355
|
+
}
|
|
356
|
+
|
|
254
357
|
export function createToolTrackingHook(
|
|
255
358
|
getAuditState: () => AuditState | null,
|
|
256
359
|
onStateChanged?: (metadata: ToolExecutionMetadata) => void,
|
|
257
|
-
|
|
360
|
+
options?: ToolTrackingOptions,
|
|
361
|
+
): ToolTrackingHook {
|
|
258
362
|
const storesByState = new WeakMap<AuditState, FindingStore>()
|
|
363
|
+
let lastDiagnostics: DropDiagnostic[] = []
|
|
259
364
|
|
|
260
365
|
function resolveStateAndStore(): { state: AuditState; store: FindingStore } | null {
|
|
261
366
|
const state = getAuditState()
|
|
@@ -270,19 +375,101 @@ export function createToolTrackingHook(
|
|
|
270
375
|
return { state, store }
|
|
271
376
|
}
|
|
272
377
|
|
|
273
|
-
|
|
378
|
+
const hookFn = async (input: ToolHookInput): Promise<void> => {
|
|
379
|
+
// Handle task tool (subagent dispatch) before argus_ filter
|
|
380
|
+
if (input.tool === "task") {
|
|
381
|
+
const childSessionId = parseChildSessionId(input.result)
|
|
382
|
+
const correlationId = randomUUID()
|
|
383
|
+
const resolved = resolveStateAndStore()
|
|
384
|
+
const sink = options?.getEventSink?.()
|
|
385
|
+
const sessionId = options?.getSessionId?.() ?? ""
|
|
386
|
+
const toolCallId = randomUUID()
|
|
387
|
+
|
|
388
|
+
if (childSessionId) {
|
|
389
|
+
options?.onChildSessionDetected?.(sessionId, childSessionId)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (sink && resolved) {
|
|
393
|
+
const runId = resolved.state.sessionId
|
|
394
|
+
await emitToSink(
|
|
395
|
+
sink,
|
|
396
|
+
buildEvent("tool.started", runId, sessionId, toolCallId, {
|
|
397
|
+
tool: "task",
|
|
398
|
+
args: input.args,
|
|
399
|
+
correlation_id: correlationId,
|
|
400
|
+
child_session_id: childSessionId ?? null,
|
|
401
|
+
}),
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
await emitToSink(
|
|
405
|
+
sink,
|
|
406
|
+
buildEvent("tool.completed", runId, sessionId, toolCallId, {
|
|
407
|
+
tool: "task",
|
|
408
|
+
findingsCount: 0,
|
|
409
|
+
success: true,
|
|
410
|
+
correlation_id: correlationId,
|
|
411
|
+
child_session_id: childSessionId ?? null,
|
|
412
|
+
}),
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (resolved) {
|
|
417
|
+
recordToolExecution(resolved.state, "task", 0)
|
|
418
|
+
onStateChanged?.({ tool: "task", findingsCount: 0 })
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return
|
|
422
|
+
}
|
|
423
|
+
|
|
274
424
|
if (!input.tool.startsWith("argus_")) {
|
|
275
425
|
return
|
|
276
426
|
}
|
|
277
427
|
|
|
278
428
|
const resolved = resolveStateAndStore()
|
|
279
|
-
if (!resolved)
|
|
429
|
+
if (!resolved) {
|
|
430
|
+
const sinkForNoState = options?.getEventSink?.()
|
|
431
|
+
if (sinkForNoState) {
|
|
432
|
+
const toolCallId = randomUUID()
|
|
433
|
+
await emitToSink(
|
|
434
|
+
sinkForNoState,
|
|
435
|
+
buildEvent("tool.started", "", "", toolCallId, {
|
|
436
|
+
tool: input.tool,
|
|
437
|
+
args: input.args,
|
|
438
|
+
}),
|
|
439
|
+
)
|
|
440
|
+
await emitToSink(
|
|
441
|
+
sinkForNoState,
|
|
442
|
+
buildEvent("tool.completed", "", "", toolCallId, {
|
|
443
|
+
tool: input.tool,
|
|
444
|
+
findingsCount: 0,
|
|
445
|
+
success: false,
|
|
446
|
+
}),
|
|
447
|
+
)
|
|
448
|
+
}
|
|
449
|
+
return
|
|
450
|
+
}
|
|
280
451
|
|
|
281
452
|
const { state: auditState, store } = resolved
|
|
453
|
+
const sink = options?.getEventSink?.()
|
|
454
|
+
const runId = auditState.sessionId
|
|
455
|
+
const sessionId = options?.getSessionId?.() ?? ""
|
|
456
|
+
const toolCallId = randomUUID()
|
|
457
|
+
const policy = options?.dropPolicy ?? "warn"
|
|
458
|
+
const diag = createDropDiagnosticsCollector(policy, "tool-tracking-hook", input.tool)
|
|
459
|
+
|
|
460
|
+
if (sink) {
|
|
461
|
+
await emitToSink(
|
|
462
|
+
sink,
|
|
463
|
+
buildEvent("tool.started", runId, sessionId, toolCallId, {
|
|
464
|
+
tool: input.tool,
|
|
465
|
+
args: input.args,
|
|
466
|
+
}),
|
|
467
|
+
)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const findingsCountBefore = auditState.findings.length
|
|
282
471
|
|
|
283
|
-
// Handle argus_skill_load first — it returns markdown, not JSON
|
|
284
472
|
if (input.tool === "argus_skill_load") {
|
|
285
|
-
// Extract skill name from markdown header: "## Argus Skill: {name} [Source: ...]"
|
|
286
473
|
const nameMatch = input.result.match(/^##\s+Argus Skill:\s+(.+?)(?:\s+\[|$)/m)
|
|
287
474
|
const skillName = nameMatch?.[1]?.trim()
|
|
288
475
|
if (skillName) {
|
|
@@ -293,6 +480,19 @@ export function createToolTrackingHook(
|
|
|
293
480
|
}
|
|
294
481
|
recordToolExecution(auditState, input.tool, 0)
|
|
295
482
|
onStateChanged?.({ tool: input.tool, findingsCount: 0 })
|
|
483
|
+
|
|
484
|
+
if (sink) {
|
|
485
|
+
await emitToSink(
|
|
486
|
+
sink,
|
|
487
|
+
buildEvent("tool.completed", runId, sessionId, toolCallId, {
|
|
488
|
+
tool: input.tool,
|
|
489
|
+
findingsCount: 0,
|
|
490
|
+
success: true,
|
|
491
|
+
}),
|
|
492
|
+
)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
lastDiagnostics = diag.getDiagnostics()
|
|
296
496
|
return
|
|
297
497
|
}
|
|
298
498
|
|
|
@@ -300,20 +500,26 @@ export function createToolTrackingHook(
|
|
|
300
500
|
try {
|
|
301
501
|
parsed = JSON.parse(input.result)
|
|
302
502
|
} catch {
|
|
303
|
-
|
|
503
|
+
diag.error("MALFORMED_JSON", `Failed to parse JSON result from ${input.tool}`)
|
|
504
|
+
lastDiagnostics = diag.getDiagnostics()
|
|
505
|
+
diag.throwIfStrict()
|
|
506
|
+
return
|
|
304
507
|
}
|
|
305
508
|
|
|
306
509
|
const record = toRecord(parsed)
|
|
307
|
-
if (!record)
|
|
510
|
+
if (!record) {
|
|
511
|
+
lastDiagnostics = diag.getDiagnostics()
|
|
512
|
+
return
|
|
513
|
+
}
|
|
308
514
|
|
|
309
515
|
let findingsCount = 0
|
|
310
516
|
|
|
311
517
|
switch (input.tool) {
|
|
312
518
|
case "argus_slither_analyze":
|
|
313
|
-
findingsCount = processSlitherResult(record, store)
|
|
519
|
+
findingsCount = processSlitherResult(record, store, diag)
|
|
314
520
|
break
|
|
315
521
|
case "argus_check_patterns":
|
|
316
|
-
findingsCount = processPatternResult(record, store)
|
|
522
|
+
findingsCount = processPatternResult(record, store, diag)
|
|
317
523
|
break
|
|
318
524
|
case "argus_analyze_contract":
|
|
319
525
|
processContractAnalyzerResult(record, auditState)
|
|
@@ -386,7 +592,31 @@ export function createToolTrackingHook(
|
|
|
386
592
|
}
|
|
387
593
|
}
|
|
388
594
|
|
|
595
|
+
lastDiagnostics = diag.getDiagnostics()
|
|
596
|
+
diag.throwIfStrict()
|
|
597
|
+
|
|
389
598
|
recordToolExecution(auditState, input.tool, findingsCount)
|
|
390
599
|
onStateChanged?.({ tool: input.tool, findingsCount })
|
|
600
|
+
|
|
601
|
+
if (sink) {
|
|
602
|
+
const newFindings = auditState.findings.slice(findingsCountBefore)
|
|
603
|
+
for (const finding of newFindings) {
|
|
604
|
+
const { data: canonical } = normalizeToCanonicalFinding(finding, runId, 0)
|
|
605
|
+
await emitToSink(sink, buildEvent("finding.added", runId, sessionId, toolCallId, canonical))
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
await emitToSink(
|
|
609
|
+
sink,
|
|
610
|
+
buildEvent("tool.completed", runId, sessionId, toolCallId, {
|
|
611
|
+
tool: input.tool,
|
|
612
|
+
findingsCount,
|
|
613
|
+
success: true,
|
|
614
|
+
}),
|
|
615
|
+
)
|
|
616
|
+
}
|
|
391
617
|
}
|
|
618
|
+
|
|
619
|
+
hookFn.getLastDiagnostics = (): DropDiagnostic[] => lastDiagnostics
|
|
620
|
+
|
|
621
|
+
return hookFn
|
|
392
622
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
|
|
3
|
+
export class ArtifactResolverError extends Error {
|
|
4
|
+
constructor(message: string) {
|
|
5
|
+
super(message)
|
|
6
|
+
this.name = "ArtifactResolverError"
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface AuditArtifactPaths {
|
|
11
|
+
/** {projectDir}/.opencode/argus-state.json (legacy compat) */
|
|
12
|
+
stateFile: string
|
|
13
|
+
/** {projectDir}/.opencode/runs/{runId}/events.jsonl */
|
|
14
|
+
journalFile: string
|
|
15
|
+
/** {projectDir}/.opencode/runs/{runId}/findings.json */
|
|
16
|
+
findingsFile: string
|
|
17
|
+
/** {projectDir}/.opencode/reports */
|
|
18
|
+
reportDir: string
|
|
19
|
+
/** {projectDir}/.opencode/runs/{runId}/evidence */
|
|
20
|
+
evidenceDir: string
|
|
21
|
+
/** {projectDir}/.opencode/archives */
|
|
22
|
+
archiveDir: string
|
|
23
|
+
/** {projectDir}/.opencode/runs/{runId} */
|
|
24
|
+
runDir: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AuditArtifactResolver {
|
|
28
|
+
readonly runId: string
|
|
29
|
+
readonly projectDir: string
|
|
30
|
+
paths(): AuditArtifactPaths
|
|
31
|
+
/** Returns {reportDir}/{filename} */
|
|
32
|
+
reportFilePath(filename: string): string
|
|
33
|
+
/** Returns {evidenceDir}/{filename} */
|
|
34
|
+
evidenceFilePath(filename: string): string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createAuditArtifactResolver(
|
|
38
|
+
runId: string,
|
|
39
|
+
projectDir: string,
|
|
40
|
+
): AuditArtifactResolver {
|
|
41
|
+
if (!runId || runId.trim() === "") {
|
|
42
|
+
throw new ArtifactResolverError("runId must not be empty")
|
|
43
|
+
}
|
|
44
|
+
if (!projectDir || projectDir.trim() === "") {
|
|
45
|
+
throw new ArtifactResolverError("projectDir must not be empty")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const opencodeDir = join(projectDir, ".opencode")
|
|
49
|
+
const runDir = join(opencodeDir, "runs", runId)
|
|
50
|
+
|
|
51
|
+
const cachedPaths: AuditArtifactPaths = {
|
|
52
|
+
stateFile: join(opencodeDir, "argus-state.json"),
|
|
53
|
+
journalFile: join(runDir, "events.jsonl"),
|
|
54
|
+
findingsFile: join(runDir, "findings.json"),
|
|
55
|
+
reportDir: join(opencodeDir, "reports"),
|
|
56
|
+
evidenceDir: join(runDir, "evidence"),
|
|
57
|
+
archiveDir: join(opencodeDir, "archives"),
|
|
58
|
+
runDir,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
runId,
|
|
63
|
+
projectDir,
|
|
64
|
+
paths(): AuditArtifactPaths {
|
|
65
|
+
return cachedPaths
|
|
66
|
+
},
|
|
67
|
+
reportFilePath(filename: string): string {
|
|
68
|
+
return join(cachedPaths.reportDir, filename)
|
|
69
|
+
},
|
|
70
|
+
evidenceFilePath(filename: string): string {
|
|
71
|
+
return join(cachedPaths.evidenceDir, filename)
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createLogger } from "./logger"
|
|
2
|
+
|
|
3
|
+
const logger = createLogger()
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* "warn": log and continue (default). "error": collect, continue, surface.
|
|
7
|
+
* "strict-fail": collect, then throw after all diagnostics gathered.
|
|
8
|
+
*/
|
|
9
|
+
export type DropPolicy = "warn" | "error" | "strict-fail"
|
|
10
|
+
|
|
11
|
+
export type DropReason = {
|
|
12
|
+
code: string
|
|
13
|
+
field?: string
|
|
14
|
+
message: string
|
|
15
|
+
policy: DropPolicy
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type DropDiagnostic = {
|
|
19
|
+
type: "drop"
|
|
20
|
+
source: string
|
|
21
|
+
tool?: string
|
|
22
|
+
reason: DropReason
|
|
23
|
+
timestamp: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type DropDiagnosticsCollector = {
|
|
27
|
+
warn(code: string, message: string, field?: string): void
|
|
28
|
+
error(code: string, message: string, field?: string): void
|
|
29
|
+
getDiagnostics(): DropDiagnostic[]
|
|
30
|
+
hasErrors(): boolean
|
|
31
|
+
throwIfStrict(): void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Thrown in strict-fail mode when error-level diagnostics exist. */
|
|
35
|
+
export class DropDiagnosticsError extends Error {
|
|
36
|
+
public readonly diagnostics: DropDiagnostic[]
|
|
37
|
+
|
|
38
|
+
constructor(diagnostics: DropDiagnostic[]) {
|
|
39
|
+
const errorDiags = diagnostics.filter(
|
|
40
|
+
(d) => d.reason.policy === "strict-fail" || d.reason.policy === "error",
|
|
41
|
+
)
|
|
42
|
+
const summary = errorDiags.map((d) => `[${d.reason.code}] ${d.reason.message}`).join("; ")
|
|
43
|
+
super(`Drop diagnostics: ${errorDiags.length} error(s) — ${summary}`)
|
|
44
|
+
this.name = "DropDiagnosticsError"
|
|
45
|
+
this.diagnostics = diagnostics
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createDropDiagnosticsCollector(
|
|
50
|
+
policy: DropPolicy,
|
|
51
|
+
source: string,
|
|
52
|
+
tool?: string,
|
|
53
|
+
): DropDiagnosticsCollector {
|
|
54
|
+
const diagnostics: DropDiagnostic[] = []
|
|
55
|
+
let errorCount = 0
|
|
56
|
+
|
|
57
|
+
function push(code: string, message: string, level: "warn" | "error", field?: string): void {
|
|
58
|
+
const effectivePolicy: DropPolicy = level === "error" ? policy : "warn"
|
|
59
|
+
const diagnostic: DropDiagnostic = {
|
|
60
|
+
type: "drop",
|
|
61
|
+
source,
|
|
62
|
+
tool,
|
|
63
|
+
reason: {
|
|
64
|
+
code,
|
|
65
|
+
message,
|
|
66
|
+
policy: effectivePolicy,
|
|
67
|
+
...(field !== undefined ? { field } : {}),
|
|
68
|
+
},
|
|
69
|
+
timestamp: Date.now(),
|
|
70
|
+
}
|
|
71
|
+
diagnostics.push(diagnostic)
|
|
72
|
+
|
|
73
|
+
if (level === "error") {
|
|
74
|
+
errorCount++
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const logMsg = `[${source}${tool ? `:${tool}` : ""}] ${code}${field ? ` (field: ${field})` : ""}: ${message}`
|
|
78
|
+
if (level === "error") {
|
|
79
|
+
logger.warn(logMsg)
|
|
80
|
+
} else {
|
|
81
|
+
logger.info(logMsg)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
warn(code: string, message: string, field?: string): void {
|
|
87
|
+
push(code, message, "warn", field)
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
error(code: string, message: string, field?: string): void {
|
|
91
|
+
push(code, message, "error", field)
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
getDiagnostics(): DropDiagnostic[] {
|
|
95
|
+
return [...diagnostics]
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
hasErrors(): boolean {
|
|
99
|
+
return errorCount > 0
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
throwIfStrict(): void {
|
|
103
|
+
if (policy === "strict-fail" && errorCount > 0) {
|
|
104
|
+
throw new DropDiagnosticsError(diagnostics)
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
}
|
package/src/shared/index.ts
CHANGED
|
@@ -9,3 +9,17 @@ export {
|
|
|
9
9
|
export { stripJsoncComments } from "./jsonc-parser"
|
|
10
10
|
export { createLogger, type Logger, type LoggerConfig } from "./logger"
|
|
11
11
|
export { findFoundryProjectDir, resolveProjectDir } from "./project-utils"
|
|
12
|
+
export {
|
|
13
|
+
ArtifactResolverError,
|
|
14
|
+
type AuditArtifactPaths,
|
|
15
|
+
type AuditArtifactResolver,
|
|
16
|
+
createAuditArtifactResolver,
|
|
17
|
+
} from "./audit-artifact-resolver"
|
|
18
|
+
export {
|
|
19
|
+
ReportPathError,
|
|
20
|
+
type ReportPathOptions,
|
|
21
|
+
type ResolvedReportPath,
|
|
22
|
+
formatReportDate,
|
|
23
|
+
sanitizeContractName,
|
|
24
|
+
resolveReportPath,
|
|
25
|
+
} from "./report-path-resolver"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
|
|
3
|
+
export class ReportPathError extends Error {
|
|
4
|
+
constructor(message: string) {
|
|
5
|
+
super(message)
|
|
6
|
+
this.name = "ReportPathError"
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ReportPathOptions {
|
|
11
|
+
/** Contract name, e.g. "VulnerableVault" */
|
|
12
|
+
contractName: string
|
|
13
|
+
/** If not provided, use new Date() */
|
|
14
|
+
date?: Date
|
|
15
|
+
/** Canonical output directory (from config or default) */
|
|
16
|
+
outputDir: string
|
|
17
|
+
/** Optional run_id for run-scoped naming */
|
|
18
|
+
runId?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ResolvedReportPath {
|
|
22
|
+
/** "VulnerableVault-security-audit-2026-02-21.md" */
|
|
23
|
+
filename: string
|
|
24
|
+
/** Full absolute path */
|
|
25
|
+
filePath: string
|
|
26
|
+
/** The directory used */
|
|
27
|
+
outputDir: string
|
|
28
|
+
/** runId if provided, else filename (deterministic identity) */
|
|
29
|
+
canonicalId: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function formatReportDate(date: Date): string {
|
|
33
|
+
const year = date.getFullYear()
|
|
34
|
+
const month = String(date.getMonth() + 1).padStart(2, "0")
|
|
35
|
+
const day = String(date.getDate()).padStart(2, "0")
|
|
36
|
+
return `${year}-${month}-${day}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function sanitizeContractName(name: string): string {
|
|
40
|
+
return name
|
|
41
|
+
.replace(/\s+/g, "-")
|
|
42
|
+
.replace(/[^a-zA-Z0-9-]/g, "")
|
|
43
|
+
.replace(/-+/g, "-")
|
|
44
|
+
.replace(/^-|-$/g, "")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function resolveReportPath(options: ReportPathOptions): ResolvedReportPath {
|
|
48
|
+
const { contractName, date, outputDir, runId } = options
|
|
49
|
+
|
|
50
|
+
if (!contractName || contractName.trim() === "") {
|
|
51
|
+
throw new ReportPathError("contractName must not be empty")
|
|
52
|
+
}
|
|
53
|
+
if (!outputDir || outputDir.trim() === "") {
|
|
54
|
+
throw new ReportPathError("outputDir must not be empty")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const resolvedDate = date ?? new Date()
|
|
58
|
+
const dateStr = formatReportDate(resolvedDate)
|
|
59
|
+
const sanitizedName = sanitizeContractName(contractName)
|
|
60
|
+
const filename = `${sanitizedName}-security-audit-${dateStr}.md`
|
|
61
|
+
const filePath = join(outputDir, filename)
|
|
62
|
+
const canonicalId = runId ?? filename
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
filename,
|
|
66
|
+
filePath,
|
|
67
|
+
outputDir,
|
|
68
|
+
canonicalId,
|
|
69
|
+
}
|
|
70
|
+
}
|