solidity-argus 0.3.3 → 0.3.5
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 +67 -8
- package/src/agents/scribe-prompt.ts +13 -5
- package/src/cli/commands/init.ts +1 -1
- package/src/cli/index.ts +0 -0
- package/src/config/schema.ts +7 -2
- package/src/create-hooks.ts +116 -27
- package/src/features/audit-enforcer/audit-enforcer.ts +31 -2
- 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/audit-state-manager.ts +28 -6
- package/src/features/persistent-state/event-sink.ts +175 -0
- package/src/features/persistent-state/findings-materializer.ts +51 -0
- package/src/features/persistent-state/index.ts +2 -0
- package/src/features/persistent-state/run-finalizer.ts +192 -0
- package/src/features/persistent-state/run-journal.ts +15 -4
- package/src/hooks/agent-tracker.ts +15 -0
- package/src/hooks/event-hook.ts +93 -1
- package/src/hooks/system-prompt-hook.ts +20 -0
- package/src/hooks/tool-tracking-hook.ts +263 -33
- package/src/shared/audit-artifact-resolver.ts +75 -0
- package/src/shared/drop-diagnostics.ts +108 -0
- package/src/shared/file-utils.ts +7 -2
- package/src/shared/index.ts +14 -0
- package/src/shared/path-root-resolver.ts +34 -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 +453 -0
- package/src/state/types.ts +6 -0
- package/src/tools/report-generator-tool.ts +647 -36
- package/src/tools/report-preflight.ts +79 -0
- package/src/tools/solodit-search-tool.ts +15 -24
- package/src/utils/solodit-health.ts +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "solidity-argus",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "Solidity smart contract security auditing plugin for OpenCode — 4 specialized agents, 12 tools (11 core + optional Solodit), and a curated vulnerability knowledge base",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"solidity",
|
|
@@ -225,6 +225,49 @@ Task(subagent_type="scribe", prompt="Generate the final audit report for Project
|
|
|
225
225
|
\`\`\`
|
|
226
226
|
- Wait for both to complete before synthesizing their results.
|
|
227
227
|
|
|
228
|
+
### STATE-FIRST SYNTHESIS POLICY
|
|
229
|
+
|
|
230
|
+
**Synthesize and report from durable evidence — not transcript tails.**
|
|
231
|
+
|
|
232
|
+
When building the final report or synthesizing findings:
|
|
233
|
+
1. **Primary source**: \`toolsExecuted\` records, \`findings\` from state, and event stream data persisted via argus_* tool outputs.
|
|
234
|
+
2. **Secondary source**: Tool transcript text (use only when durable evidence is unavailable or incomplete).
|
|
235
|
+
3. **Never** synthesize findings from ephemeral background transcript retrieval alone if durable state evidence exists.
|
|
236
|
+
|
|
237
|
+
**Bounded background fan-out**: For deep audits, limit concurrent high-context background delegations to max 2 at a time. Split larger workloads into sequential waves. This prevents retrieval blind spots from simultaneous long-running tasks.
|
|
238
|
+
|
|
239
|
+
Example — correct fan-out:
|
|
240
|
+
- Wave 1: [Sentinel: slither + pattern check] + [Pythia: solodit search] (2 background tasks)
|
|
241
|
+
- Wait for both. Then Wave 2: [Sentinel: forge tests] (1 background task)
|
|
242
|
+
|
|
243
|
+
## SYNTHESIS BARRIER: MUST NOT PROCEED WITHOUT DURABLE EVIDENCE
|
|
244
|
+
|
|
245
|
+
You **must not proceed** to synthesis or report generation until required durable evidence is confirmed present:
|
|
246
|
+
- \`toolsExecuted\` records exist for all planned tools
|
|
247
|
+
- Expected findings coverage is populated in state
|
|
248
|
+
- Lifecycle invariants are satisfied (no orphaned tool starts)
|
|
249
|
+
|
|
250
|
+
### Adaptive Retrieval Budget
|
|
251
|
+
|
|
252
|
+
When waiting for background tasks, use bounded retrieval budgets by workload class:
|
|
253
|
+
|
|
254
|
+
| Class | Budget | Criteria |
|
|
255
|
+
|----------|---------|---------------------------------------------|
|
|
256
|
+
| quick | 60s | Single-tool or single-contract checks |
|
|
257
|
+
| standard | 180s | Multi-tool single-agent batches |
|
|
258
|
+
| deep | 600s | Multi-agent or synthesis-heavy runs |
|
|
259
|
+
|
|
260
|
+
Poll until the task reaches a terminal state: \`completed\`, \`error\`, \`cancelled\`, or \`interrupt\`.
|
|
261
|
+
|
|
262
|
+
### Re-dispatch (LAST RESORT)
|
|
263
|
+
|
|
264
|
+
Re-dispatch is only justified when ALL of these are true:
|
|
265
|
+
1. The task has reached terminal state OR retrieval budget has expired
|
|
266
|
+
2. Required durable evidence is STILL missing from state/events
|
|
267
|
+
3. The gap is specific and bounded (not a general "redo everything")
|
|
268
|
+
|
|
269
|
+
**When re-dispatching**: Target only missing evidence segments. Use \`run_in_background=false\` (foreground only) for re-dispatch pivots. Do NOT re-dispatch routinely after a single transcript retrieval miss if durable state evidence is already complete.
|
|
270
|
+
|
|
228
271
|
## TASK COMPLETION TRACKING
|
|
229
272
|
|
|
230
273
|
You must track which audit phases are complete to avoid redundant work and tool re-execution.
|
|
@@ -272,7 +315,7 @@ Your subagents have access to these specialized tools. Know when to delegate eac
|
|
|
272
315
|
- **\`argus_generate_report\`**:
|
|
273
316
|
- **Use**: During Reporting.
|
|
274
317
|
- **Purpose**: Generates the final artifact.
|
|
275
|
-
- **Note**: Requires
|
|
318
|
+
- **Note**: Requires a versioned report_input JSON string matching the ReportInput contract (schema_version 1.0.0). Do not send natural-language-only findings to Scribe for tool invocation.
|
|
276
319
|
|
|
277
320
|
- **\`argus_sync_knowledge\`**:
|
|
278
321
|
- **Use**: Maintenance.
|
|
@@ -422,18 +465,28 @@ Tools may fail. You must be resilient.
|
|
|
422
465
|
|
|
423
466
|
**An audit without a report is an incomplete audit.** Your FINAL action before finishing MUST be delegating to Scribe. No exceptions.
|
|
424
467
|
|
|
425
|
-
After you have synthesized your findings,
|
|
468
|
+
After you have synthesized your findings, build a canonical ReportInput payload and invoke Scribe:
|
|
469
|
+
|
|
470
|
+
**State-first requirement**: Before invoking Scribe, verify that \`toolsExecuted\` in your ReportInput contains entries for each tool you ran. Do NOT proceed to report generation if required tool coverage is missing from durable state — re-run the missing tool instead. Use \`preflight_policy: "strict-fail"\` for the final report invocation.
|
|
426
471
|
|
|
427
472
|
\`\`\`
|
|
428
473
|
Task(subagent_type="scribe", prompt="Generate the final security audit report.
|
|
429
474
|
|
|
430
475
|
Project: {name}
|
|
431
476
|
Scope: {list of audited files}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
477
|
+
ReportInput JSON (pass EXACTLY, no prose substitution):
|
|
478
|
+
{
|
|
479
|
+
"run_id": "{run-id}",
|
|
480
|
+
"seq": {last-seq},
|
|
481
|
+
"session_id": "{session-id}",
|
|
482
|
+
"tool_call_id": "{tool-call-id}",
|
|
483
|
+
"source": "argus",
|
|
484
|
+
"schema_version": "1.0.0",
|
|
485
|
+
"projectDir": "{project-dir}",
|
|
486
|
+
"findings": [canonical findings],
|
|
487
|
+
"toolsExecuted": [canonical tool executions],
|
|
488
|
+
"scope": ["..."]
|
|
489
|
+
}
|
|
437
490
|
|
|
438
491
|
Additional context:
|
|
439
492
|
- Tools used: Slither, Forge, Pattern Checker, Solodit
|
|
@@ -442,7 +495,13 @@ Additional context:
|
|
|
442
495
|
")
|
|
443
496
|
\`\`\`
|
|
444
497
|
|
|
445
|
-
|
|
498
|
+
Scribe must call argus_generate_report with:
|
|
499
|
+
- project_name: project name
|
|
500
|
+
- scope: audited file list
|
|
501
|
+
- report_input: serialized ReportInput JSON string
|
|
502
|
+
- preflight_policy: "strict-fail" (non-negotiable for final report)
|
|
503
|
+
|
|
504
|
+
Legacy audit_state is transitional-only and deprecated.
|
|
446
505
|
|
|
447
506
|
**If you have zero findings, still invoke Scribe** with an empty findings list. A clean report is still a report.
|
|
448
507
|
|
|
@@ -8,7 +8,7 @@ Your core responsibilities are:
|
|
|
8
8
|
1. **Aggregation**: Collecting findings from various tools and subagents.
|
|
9
9
|
2. **Deduplication**: Merging similar findings (e.g., multiple Slither warnings for the same issue).
|
|
10
10
|
3. **Contextualization**: Explaining *why* a finding matters in the context of the specific protocol.
|
|
11
|
-
4. **Report Generation**: Producing the final Markdown artifact
|
|
11
|
+
4. **Report Generation**: Producing the final Markdown artifact via \`argus_generate_report\`.
|
|
12
12
|
|
|
13
13
|
## REPORT STRUCTURE
|
|
14
14
|
|
|
@@ -41,13 +41,21 @@ You must adhere to these strict writing standards:
|
|
|
41
41
|
|
|
42
42
|
## HOW TO GENERATE THE REPORT
|
|
43
43
|
|
|
44
|
-
Argus passes you
|
|
44
|
+
Argus passes you structured report data. Use that payload directly and keep it schema-accurate.
|
|
45
45
|
|
|
46
46
|
**Your workflow**:
|
|
47
|
-
1.
|
|
47
|
+
1. Validate Argus provided a serialized ReportInput JSON string (schema_version 1.0.0) with required fields: run_id, seq, session_id, tool_call_id, source, schema_version, projectDir, findings, toolsExecuted, scope. **Execution integrity check**: \`toolsExecuted\` must be non-empty for the audit to be considered complete. If \`toolsExecuted\` is empty or missing key tool families (slither, forge, patterns), add a \`## Limitations\` section to the report noting which tool coverage is absent.
|
|
48
48
|
2. Write the complete report in Markdown following the Report Structure and Output Format sections.
|
|
49
|
-
3.
|
|
50
|
-
4.
|
|
49
|
+
3. Call \`argus_generate_report\` with arguments { project_name, scope, report_input }. Use legacy \`audit_state\` only for transitional compatibility and treat it as deprecated.
|
|
50
|
+
4. **Limitations disclosure** (MANDATORY when tools fail): If any tool was unavailable, timed out, or failed, add a \`## Limitations\` section to the report BEFORE \`## Findings\`. Use this format:
|
|
51
|
+
- \`**Tool name**: [reason \u2014 unavailable/failed/timed out]. [Impact on finding coverage if any.]\`
|
|
52
|
+
- Example: \`**argus_solodit_search**: External database was unavailable. Known-vulnerability cross-referencing was performed using local patterns only.\`
|
|
53
|
+
- Never silently omit limitations — incomplete coverage must be disclosed.
|
|
54
|
+
5. Confirm the report was generated in your response to Argus: "Report generated via argus_generate_report: {filePath}".
|
|
55
|
+
|
|
56
|
+
## SINGLE-WRITER POLICY
|
|
57
|
+
|
|
58
|
+
**CRITICAL**: You must NEVER write final report files directly to disk. All report persistence MUST go through \`argus_generate_report\`. This tool enforces the single-writer policy — it is the sole component authorized to create report artifacts on disk. Direct file writes for report output are a policy violation and will be rejected.
|
|
51
59
|
|
|
52
60
|
## QUALITY STANDARDS
|
|
53
61
|
|
package/src/cli/commands/init.ts
CHANGED
|
@@ -20,7 +20,7 @@ export const initCommand: CliCommand = {
|
|
|
20
20
|
description: "Initialize Argus configuration for this project",
|
|
21
21
|
async execute(_args: string[]): Promise<number> {
|
|
22
22
|
const cwd = process.cwd()
|
|
23
|
-
const configDir = join(cwd, ".
|
|
23
|
+
const configDir = join(cwd, ".argus")
|
|
24
24
|
const configPath = join(configDir, "solidity-argus.json")
|
|
25
25
|
|
|
26
26
|
if (existsSync(configPath)) {
|
package/src/cli/index.ts
CHANGED
|
File without changes
|
package/src/config/schema.ts
CHANGED
|
@@ -31,7 +31,7 @@ const ReportingConfigSchema = z.object({
|
|
|
31
31
|
format: z.enum(["markdown"]).default("markdown"),
|
|
32
32
|
severityThreshold: z.enum(["critical", "high", "medium", "low", "informational"]).default("low"),
|
|
33
33
|
gasAnalysis: z.boolean().default(false),
|
|
34
|
-
output_dir: z.string().default(".
|
|
34
|
+
output_dir: z.string().default(".argus/reports/"),
|
|
35
35
|
})
|
|
36
36
|
|
|
37
37
|
const SoloditConfigSchema = z.object({
|
|
@@ -43,6 +43,10 @@ const BackgroundConfigSchema = z.object({
|
|
|
43
43
|
max_concurrent: z.number().positive().default(3),
|
|
44
44
|
})
|
|
45
45
|
|
|
46
|
+
const MigrationConfigSchema = z.object({
|
|
47
|
+
mode: z.enum(["legacy", "dual", "strict"]).default("legacy"),
|
|
48
|
+
})
|
|
49
|
+
|
|
46
50
|
export const ArgusConfigSchema = z.object({
|
|
47
51
|
agents: z
|
|
48
52
|
.object({
|
|
@@ -70,7 +74,7 @@ export const ArgusConfigSchema = z.object({
|
|
|
70
74
|
format: "markdown",
|
|
71
75
|
severityThreshold: "low",
|
|
72
76
|
gasAnalysis: false,
|
|
73
|
-
output_dir: ".
|
|
77
|
+
output_dir: ".argus/reports/",
|
|
74
78
|
}),
|
|
75
79
|
solodit: SoloditConfigSchema.default({
|
|
76
80
|
enabled: true,
|
|
@@ -82,4 +86,5 @@ export const ArgusConfigSchema = z.object({
|
|
|
82
86
|
background: BackgroundConfigSchema.default({
|
|
83
87
|
max_concurrent: 3,
|
|
84
88
|
}),
|
|
89
|
+
migration: MigrationConfigSchema.optional(),
|
|
85
90
|
})
|
package/src/create-hooks.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { join } from "node:path"
|
|
2
1
|
import type { Hooks as PluginHooks } from "@opencode-ai/plugin"
|
|
3
2
|
import type { ArgusConfig } from "./config/types"
|
|
4
3
|
import { createAuditEnforcer } from "./features/audit-enforcer/audit-enforcer"
|
|
@@ -7,7 +6,12 @@ import {
|
|
|
7
6
|
createSessionRecoveryHandler,
|
|
8
7
|
createToolErrorRecoveryHandler,
|
|
9
8
|
} from "./features/error-recovery"
|
|
9
|
+
import { getMigrationMode } from "./features/migration"
|
|
10
|
+
import { adaptLegacyFindings } from "./features/migration/migration-adapter"
|
|
11
|
+
import { computeParityMetrics, formatParityReport } from "./features/migration/parity-telemetry"
|
|
10
12
|
import { createDebouncedSave } from "./features/persistent-state/audit-state-manager"
|
|
13
|
+
import { createEventSink, type EventSink } from "./features/persistent-state/event-sink"
|
|
14
|
+
import { materializeFindings } from "./features/persistent-state/findings-materializer"
|
|
11
15
|
import { recordRun } from "./features/persistent-state/global-run-index"
|
|
12
16
|
import { createRunJournal } from "./features/persistent-state/run-journal"
|
|
13
17
|
import { createAgentTracker } from "./hooks/agent-tracker"
|
|
@@ -22,6 +26,7 @@ import { createSystemPromptHook } from "./hooks/system-prompt-hook"
|
|
|
22
26
|
import { createToolTrackingHook } from "./hooks/tool-tracking-hook"
|
|
23
27
|
import type { HookName } from "./hooks/types"
|
|
24
28
|
import type { Managers } from "./managers/types"
|
|
29
|
+
import { createAuditArtifactResolver } from "./shared/audit-artifact-resolver"
|
|
25
30
|
import { createLogger } from "./shared/logger"
|
|
26
31
|
import type { AuditState } from "./state/types"
|
|
27
32
|
import { detectAuditArtifacts } from "./utils/audit-artifact-detector"
|
|
@@ -77,6 +82,9 @@ export function createHooks(args: {
|
|
|
77
82
|
const agentTracker = createAgentTracker()
|
|
78
83
|
_agentTrackerRef = agentTracker
|
|
79
84
|
|
|
85
|
+
const migrationMode = getMigrationMode(config)
|
|
86
|
+
logger.debug(`Migration mode: ${migrationMode}`)
|
|
87
|
+
|
|
80
88
|
const contextMonitor = createContextMonitor()
|
|
81
89
|
const sessionRecoveryHandler = createSessionRecoveryHandler(auditStateManager)
|
|
82
90
|
const debouncedSave = createDebouncedSave(auditStateManager.save)
|
|
@@ -88,15 +96,21 @@ export function createHooks(args: {
|
|
|
88
96
|
)
|
|
89
97
|
const outputTruncator = createToolOutputTruncator()
|
|
90
98
|
|
|
99
|
+
let currentEventSink: EventSink | null = null
|
|
100
|
+
let currentOpencodeSessionId = ""
|
|
101
|
+
|
|
91
102
|
// Sub-handlers run sequentially. The state persistence handler MUST be first:
|
|
92
103
|
// it loads persisted state on session.created, overriding the fresh default.
|
|
93
104
|
const {
|
|
94
105
|
hook: eventHook,
|
|
95
106
|
getAuditState,
|
|
96
107
|
setAuditState,
|
|
108
|
+
setEventSink,
|
|
109
|
+
getLastFinalizationResult,
|
|
97
110
|
} = createEventHook(projectDir, [
|
|
98
111
|
async ({ type, sessionId, auditState, setAuditState: setState }) => {
|
|
99
112
|
if (type === "session.created") {
|
|
113
|
+
currentOpencodeSessionId = sessionId ?? ""
|
|
100
114
|
const timestamp = Date.now()
|
|
101
115
|
let recoveredState: AuditState | null = null
|
|
102
116
|
|
|
@@ -123,12 +137,24 @@ export function createHooks(args: {
|
|
|
123
137
|
|
|
124
138
|
const effectiveState = recoveredState ?? auditStateManager.get()
|
|
125
139
|
if (effectiveState) {
|
|
140
|
+
const resolver = createAuditArtifactResolver(effectiveState.sessionId, projectDir)
|
|
141
|
+
try {
|
|
142
|
+
const journalFile = resolver.paths().journalFile
|
|
143
|
+
// createEventSink builds the same path internally; the resolver makes it explicit.
|
|
144
|
+
currentEventSink = createEventSink(effectiveState.sessionId, projectDir)
|
|
145
|
+
setEventSink(currentEventSink)
|
|
146
|
+
logger.debug(`Event sink journal path: ${journalFile}`)
|
|
147
|
+
} catch (error) {
|
|
148
|
+
logger.error(
|
|
149
|
+
`Failed to create event sink: ${error instanceof Error ? error.message : String(error)}`,
|
|
150
|
+
)
|
|
151
|
+
}
|
|
126
152
|
void recordRun({
|
|
127
153
|
runId: effectiveState.sessionId,
|
|
128
154
|
opencodeSessionId: sessionId,
|
|
129
155
|
projectDir: effectiveState.projectDir,
|
|
130
|
-
statePath:
|
|
131
|
-
journalPath:
|
|
156
|
+
statePath: resolver.paths().stateFile,
|
|
157
|
+
journalPath: resolver.paths().journalFile,
|
|
132
158
|
startedAt: effectiveState.startTime,
|
|
133
159
|
phase: effectiveState.currentPhase,
|
|
134
160
|
findingsCount: effectiveState.findings.length,
|
|
@@ -161,17 +187,36 @@ export function createHooks(args: {
|
|
|
161
187
|
toolsExecutedCount: auditState.toolsExecuted.length,
|
|
162
188
|
})
|
|
163
189
|
|
|
190
|
+
const idleResolver = createAuditArtifactResolver(
|
|
191
|
+
auditState.sessionId,
|
|
192
|
+
auditState.projectDir,
|
|
193
|
+
)
|
|
164
194
|
void recordRun({
|
|
165
195
|
runId: auditState.sessionId,
|
|
166
196
|
opencodeSessionId: sessionId,
|
|
167
197
|
projectDir: auditState.projectDir,
|
|
168
|
-
statePath:
|
|
169
|
-
journalPath:
|
|
198
|
+
statePath: idleResolver.paths().stateFile,
|
|
199
|
+
journalPath: idleResolver.paths().journalFile,
|
|
170
200
|
startedAt: auditState.startTime,
|
|
171
201
|
phase: auditState.currentPhase,
|
|
172
202
|
findingsCount: auditState.findings.length,
|
|
173
203
|
})
|
|
174
204
|
|
|
205
|
+
if (migrationMode !== "legacy") {
|
|
206
|
+
try {
|
|
207
|
+
const { legacyFindings, canonicalFindings } = adaptLegacyFindings(
|
|
208
|
+
auditState,
|
|
209
|
+
migrationMode,
|
|
210
|
+
auditState.sessionId,
|
|
211
|
+
)
|
|
212
|
+
const parityMetrics = computeParityMetrics(legacyFindings, canonicalFindings)
|
|
213
|
+
logger.debug(formatParityReport(parityMetrics))
|
|
214
|
+
} catch (error) {
|
|
215
|
+
logger.warn(
|
|
216
|
+
`Migration parity check failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
175
220
|
return
|
|
176
221
|
}
|
|
177
222
|
|
|
@@ -180,16 +225,10 @@ export function createHooks(args: {
|
|
|
180
225
|
if (auditState) {
|
|
181
226
|
await auditStateManager.save(auditState)
|
|
182
227
|
}
|
|
183
|
-
await auditStateManager.archive()
|
|
184
|
-
|
|
185
|
-
if (sessionId) {
|
|
186
|
-
agentTracker.clearSession(sessionId)
|
|
187
|
-
}
|
|
188
|
-
|
|
189
228
|
runJournal.log({
|
|
190
|
-
type: "
|
|
229
|
+
type: "state.saved",
|
|
191
230
|
timestamp: Date.now(),
|
|
192
|
-
|
|
231
|
+
success: true,
|
|
193
232
|
})
|
|
194
233
|
}
|
|
195
234
|
},
|
|
@@ -253,25 +292,75 @@ export function createHooks(args: {
|
|
|
253
292
|
const toolTrackingHook = isHookEnabled("tool-tracking")
|
|
254
293
|
? safeCreateHook(
|
|
255
294
|
() =>
|
|
256
|
-
createToolTrackingHook(
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
295
|
+
createToolTrackingHook(
|
|
296
|
+
getAuditState,
|
|
297
|
+
({ tool, findingsCount }) => {
|
|
298
|
+
const currentState = getAuditState()
|
|
299
|
+
if (currentState) {
|
|
300
|
+
debouncedSave.save(currentState)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
runJournal.log({
|
|
304
|
+
type: "tool.executed",
|
|
305
|
+
tool,
|
|
306
|
+
timestamp: Date.now(),
|
|
307
|
+
findingsCount,
|
|
308
|
+
})
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
getEventSink: () => currentEventSink,
|
|
312
|
+
getSessionId: () => currentOpencodeSessionId,
|
|
313
|
+
},
|
|
314
|
+
),
|
|
269
315
|
"tool-tracking",
|
|
270
316
|
)
|
|
271
317
|
: undefined
|
|
272
318
|
|
|
273
319
|
const safeEventHook = isHookEnabled("event")
|
|
274
|
-
? safeCreateHook(
|
|
320
|
+
? safeCreateHook(
|
|
321
|
+
() => async (input: Parameters<typeof eventHook>[0]) => {
|
|
322
|
+
const isSessionDeleted = input.event.type === "session.deleted"
|
|
323
|
+
const finalizationBeforeDelete = isSessionDeleted ? getLastFinalizationResult() : null
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
await eventHook(input)
|
|
327
|
+
} finally {
|
|
328
|
+
if (isSessionDeleted) {
|
|
329
|
+
const finalizationResult = getLastFinalizationResult()
|
|
330
|
+
const hasNewFinalization =
|
|
331
|
+
finalizationResult !== null && finalizationResult !== finalizationBeforeDelete
|
|
332
|
+
|
|
333
|
+
if (hasNewFinalization && finalizationResult.runId.length > 0) {
|
|
334
|
+
try {
|
|
335
|
+
await materializeFindings(finalizationResult.runId, projectDir)
|
|
336
|
+
} catch (error) {
|
|
337
|
+
logger.warn(
|
|
338
|
+
`Failed to materialize findings artifact for run ${finalizationResult.runId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
await auditStateManager.archive()
|
|
344
|
+
|
|
345
|
+
const deletedSessionId = input.event.sessionId
|
|
346
|
+
if (deletedSessionId) {
|
|
347
|
+
agentTracker.clearSession(deletedSessionId)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
runJournal.log({
|
|
351
|
+
type: "session.deleted",
|
|
352
|
+
timestamp: Date.now(),
|
|
353
|
+
archived: true,
|
|
354
|
+
finalizationPassed: finalizationResult?.invariantsPassed ?? null,
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
currentEventSink = null
|
|
358
|
+
currentOpencodeSessionId = ""
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
"event",
|
|
363
|
+
)
|
|
275
364
|
: undefined
|
|
276
365
|
|
|
277
366
|
return {
|
|
@@ -11,6 +11,24 @@ const PHASE_ORDER: AuditPhase[] = [
|
|
|
11
11
|
"complete",
|
|
12
12
|
]
|
|
13
13
|
|
|
14
|
+
const REPORTING_PHASES: AuditPhase[] = ["reporting", "complete"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
const KEY_TOOL_FAMILIES: Array<{ family: string; prefixes: string[] }> = [
|
|
18
|
+
{ family: "slither", prefixes: ["argus_slither_analyze", "slither"] },
|
|
19
|
+
{ family: "forge_test", prefixes: ["argus_forge_test", "forge_test"] },
|
|
20
|
+
{ family: "forge_fuzz", prefixes: ["argus_forge_fuzz", "forge_fuzz"] },
|
|
21
|
+
{ family: "forge_coverage", prefixes: ["argus_forge_coverage", "forge_coverage"] },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
function getMissingToolFamilies(auditState: AuditState): string[] {
|
|
25
|
+
const executedTools = auditState.toolsExecuted.map((t) => t.tool)
|
|
26
|
+
return KEY_TOOL_FAMILIES.filter(
|
|
27
|
+
({ prefixes }) =>
|
|
28
|
+
!executedTools.some((tool) => prefixes.some((prefix) => tool.startsWith(prefix))),
|
|
29
|
+
).map(({ family }) => family)
|
|
30
|
+
}
|
|
31
|
+
|
|
14
32
|
function getNextPhase(current: AuditPhase): AuditPhase | null {
|
|
15
33
|
const idx = PHASE_ORDER.indexOf(current)
|
|
16
34
|
if (idx === -1 || idx >= PHASE_ORDER.length - 1) return null
|
|
@@ -25,10 +43,21 @@ export function createAuditEnforcer() {
|
|
|
25
43
|
const nextPhase = getNextPhase(auditState.currentPhase)
|
|
26
44
|
if (!nextPhase) return null
|
|
27
45
|
|
|
28
|
-
|
|
46
|
+
const parts: string[] = [
|
|
29
47
|
`[Argus Audit Enforcer] Audit in progress — current phase: ${auditState.currentPhase}.`,
|
|
30
48
|
`Next phase: ${nextPhase}. Do not stop until audit is complete.`,
|
|
31
49
|
`Progress: ${auditState.findings.length} findings, ${auditState.contractsReviewed.length} contracts reviewed.`,
|
|
32
|
-
]
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
if (REPORTING_PHASES.includes(auditState.currentPhase)) {
|
|
53
|
+
const missing = getMissingToolFamilies(auditState)
|
|
54
|
+
if (missing.length > 0) {
|
|
55
|
+
parts.push(
|
|
56
|
+
`\u26a0\ufe0f Tool coverage incomplete: ${missing.join(", ")} have not been executed. Do not proceed to report generation until required tools are complete.`,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return parts.join(" ")
|
|
33
62
|
}
|
|
34
63
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export {
|
|
2
|
+
adaptLegacyFindings,
|
|
3
|
+
adaptLegacyStateToReportInput,
|
|
4
|
+
getMigrationMode,
|
|
5
|
+
type MigrationMode,
|
|
6
|
+
validateStrictCompatibility,
|
|
7
|
+
} from "./migration-adapter"
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
computeParityMetrics,
|
|
11
|
+
formatParityReport,
|
|
12
|
+
type ParityMetrics,
|
|
13
|
+
type SeverityDistribution,
|
|
14
|
+
} from "./parity-telemetry"
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createDropDiagnosticsCollector,
|
|
3
|
+
type DropDiagnosticsCollector,
|
|
4
|
+
} from "../../shared/drop-diagnostics"
|
|
5
|
+
import { normalizeLegacyFindingsArray, normalizeToCanonicalFinding } from "../../state/adapters"
|
|
6
|
+
import type { CanonicalFinding, ReportInput } from "../../state/schemas"
|
|
7
|
+
import { SCHEMA_VERSION } from "../../state/schemas"
|
|
8
|
+
import type { AuditState, Finding } from "../../state/types"
|
|
9
|
+
|
|
10
|
+
export type MigrationMode = "legacy" | "dual" | "strict"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Returns the active migration mode from config, defaulting to "legacy".
|
|
14
|
+
*/
|
|
15
|
+
export function getMigrationMode(config: { migration?: { mode?: MigrationMode } }): MigrationMode {
|
|
16
|
+
return config.migration?.mode ?? "legacy"
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Adapts a legacy `AuditState` into canonical `CanonicalFinding[]`.
|
|
21
|
+
*
|
|
22
|
+
* In legacy mode: returns the raw findings as-is (backward compatible).
|
|
23
|
+
* In dual mode: normalizes findings to canonical AND returns both.
|
|
24
|
+
* In strict mode: normalizes to canonical, rejects payloads missing required canonical fields.
|
|
25
|
+
*/
|
|
26
|
+
export function adaptLegacyFindings(
|
|
27
|
+
state: AuditState,
|
|
28
|
+
mode: MigrationMode,
|
|
29
|
+
runId: string,
|
|
30
|
+
): {
|
|
31
|
+
legacyFindings: Finding[]
|
|
32
|
+
canonicalFindings: CanonicalFinding[]
|
|
33
|
+
diagnostics: ReturnType<DropDiagnosticsCollector["getDiagnostics"]>
|
|
34
|
+
} {
|
|
35
|
+
const legacyFindings = state.findings
|
|
36
|
+
|
|
37
|
+
if (mode === "legacy") {
|
|
38
|
+
return {
|
|
39
|
+
legacyFindings,
|
|
40
|
+
canonicalFindings: [],
|
|
41
|
+
diagnostics: [],
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const policy = mode === "strict" ? "strict-fail" : "warn"
|
|
46
|
+
const diag = createDropDiagnosticsCollector(policy, "migration-adapter")
|
|
47
|
+
|
|
48
|
+
const { findings: canonicalFindings, diagnostics: adapterDiags } = normalizeLegacyFindingsArray(
|
|
49
|
+
legacyFindings as unknown as unknown[],
|
|
50
|
+
runId,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
for (const d of adapterDiags) {
|
|
54
|
+
if (d.level === "error") {
|
|
55
|
+
diag.error(d.code, d.message, d.field)
|
|
56
|
+
} else {
|
|
57
|
+
diag.warn(d.code, d.message, d.field)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// In strict mode, validate that all legacy findings survived normalization
|
|
62
|
+
if (mode === "strict" && canonicalFindings.length < legacyFindings.length) {
|
|
63
|
+
const dropped = legacyFindings.length - canonicalFindings.length
|
|
64
|
+
diag.error(
|
|
65
|
+
"STRICT_FINDINGS_DROPPED",
|
|
66
|
+
`${dropped} legacy finding(s) could not be normalized to canonical format`,
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Throws DropDiagnosticsError in strict mode if errors exist
|
|
71
|
+
diag.throwIfStrict()
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
legacyFindings,
|
|
75
|
+
canonicalFindings,
|
|
76
|
+
diagnostics: diag.getDiagnostics(),
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Adapts a legacy `AuditState` into a canonical `ReportInput`.
|
|
82
|
+
*
|
|
83
|
+
* Maps legacy AuditState fields to the canonical ReportInput contract.
|
|
84
|
+
*/
|
|
85
|
+
export function adaptLegacyStateToReportInput(
|
|
86
|
+
state: AuditState,
|
|
87
|
+
mode: MigrationMode,
|
|
88
|
+
runId: string,
|
|
89
|
+
): {
|
|
90
|
+
reportInput: ReportInput
|
|
91
|
+
diagnostics: ReturnType<DropDiagnosticsCollector["getDiagnostics"]>
|
|
92
|
+
} {
|
|
93
|
+
const { canonicalFindings, diagnostics } = adaptLegacyFindings(
|
|
94
|
+
state,
|
|
95
|
+
mode === "legacy" ? "dual" : mode,
|
|
96
|
+
runId,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
const reportInput: ReportInput = {
|
|
100
|
+
run_id: runId,
|
|
101
|
+
seq: 0,
|
|
102
|
+
session_id: state.sessionId,
|
|
103
|
+
tool_call_id: "",
|
|
104
|
+
source: "migration-adapter",
|
|
105
|
+
schema_version: SCHEMA_VERSION,
|
|
106
|
+
projectDir: state.projectDir,
|
|
107
|
+
findings: canonicalFindings,
|
|
108
|
+
toolsExecuted: state.toolsExecuted.map((t) => ({
|
|
109
|
+
...t,
|
|
110
|
+
run_id: runId,
|
|
111
|
+
schema_version: SCHEMA_VERSION,
|
|
112
|
+
})),
|
|
113
|
+
scope: state.scope,
|
|
114
|
+
soloditResults: state.soloditResults,
|
|
115
|
+
fuzzCounterexamples: state.fuzzCounterexamples,
|
|
116
|
+
coverageReport: state.coverageReport,
|
|
117
|
+
gasHotspots: state.gasHotspots,
|
|
118
|
+
proxyContracts: state.proxyContracts,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { reportInput, diagnostics }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Validates that a legacy AuditState is compatible with strict mode.
|
|
126
|
+
* Returns true if ALL findings can be normalized without errors.
|
|
127
|
+
*/
|
|
128
|
+
export function validateStrictCompatibility(
|
|
129
|
+
state: AuditState,
|
|
130
|
+
runId: string,
|
|
131
|
+
): { compatible: boolean; errors: string[] } {
|
|
132
|
+
const errors: string[] = []
|
|
133
|
+
|
|
134
|
+
for (const [index, finding] of state.findings.entries()) {
|
|
135
|
+
const result = normalizeToCanonicalFinding(
|
|
136
|
+
finding as unknown as Record<string, unknown>,
|
|
137
|
+
runId,
|
|
138
|
+
index + 1,
|
|
139
|
+
)
|
|
140
|
+
const hasErrors = result.diagnostics.some((d) => d.level === "error")
|
|
141
|
+
if (hasErrors) {
|
|
142
|
+
errors.push(
|
|
143
|
+
...result.diagnostics
|
|
144
|
+
.filter((d) => d.level === "error")
|
|
145
|
+
.map((d) => `[finding:${index}] ${d.message}`),
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { compatible: errors.length === 0, errors }
|
|
151
|
+
}
|