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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solidity-argus",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
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",
@@ -272,7 +272,7 @@ Your subagents have access to these specialized tools. Know when to delegate eac
272
272
  - **\`argus_generate_report\`**:
273
273
  - **Use**: During Reporting.
274
274
  - **Purpose**: Generates the final artifact.
275
- - **Note**: Requires structured input of findings. Ensure the tone is objective and helpful.
275
+ - **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
276
 
277
277
  - **\`argus_sync_knowledge\`**:
278
278
  - **Use**: Maintenance.
@@ -422,18 +422,26 @@ Tools may fail. You must be resilient.
422
422
 
423
423
  **An audit without a report is an incomplete audit.** Your FINAL action before finishing MUST be delegating to Scribe. No exceptions.
424
424
 
425
- After you have synthesized your findings, compile them into a structured list and invoke Scribe:
425
+ After you have synthesized your findings, build a canonical ReportInput payload and invoke Scribe:
426
426
 
427
427
  \`\`\`
428
428
  Task(subagent_type="scribe", prompt="Generate the final security audit report.
429
429
 
430
430
  Project: {name}
431
431
  Scope: {list of audited files}
432
-
433
- Findings (pass ALL of these):
434
- 1. [SEVERITY] Title — File:Lines — Description — Impact — Recommendation
435
- 2. [SEVERITY] Title — File:Lines — Description — Impact — Recommendation
436
- ...
432
+ ReportInput JSON (pass EXACTLY, no prose substitution):
433
+ {
434
+ "run_id": "{run-id}",
435
+ "seq": {last-seq},
436
+ "session_id": "{session-id}",
437
+ "tool_call_id": "{tool-call-id}",
438
+ "source": "argus",
439
+ "schema_version": "1.0.0",
440
+ "projectDir": "{project-dir}",
441
+ "findings": [canonical findings],
442
+ "toolsExecuted": [canonical tool executions],
443
+ "scope": ["..."]
444
+ }
437
445
 
438
446
  Additional context:
439
447
  - Tools used: Slither, Forge, Pattern Checker, Solodit
@@ -442,7 +450,12 @@ Additional context:
442
450
  ")
443
451
  \`\`\`
444
452
 
445
- You do NOT need to pass raw JSON or serialized audit state. Just pass your findings as a structured list in natural language — Scribe will format them professionally.
453
+ Scribe must call argus_generate_report with:
454
+ - project_name: project name
455
+ - scope: audited file list
456
+ - report_input: serialized ReportInput JSON string
457
+
458
+ Legacy audit_state is transitional-only and deprecated.
446
459
 
447
460
  **If you have zero findings, still invoke Scribe** with an empty findings list. A clean report is still a report.
448
461
 
@@ -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 and writing it to disk.
11
+ 4. **Report Generation**: Producing the final Markdown artifact via \`argus_generate_report\`.
12
12
 
13
13
  ## REPORT STRUCTURE
14
14
 
@@ -41,13 +41,17 @@ You must adhere to these strict writing standards:
41
41
 
42
42
  ## HOW TO GENERATE THE REPORT
43
43
 
44
- Argus passes you findings in natural language. Write the full report yourself in Markdown following the Report Structure above.
44
+ Argus passes you structured report data. Use that payload directly and keep it schema-accurate.
45
45
 
46
46
  **Your workflow**:
47
- 1. Read the findings Argus provides. Deduplicate, cross-reference, and assess severity.
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.
48
48
  2. Write the complete report in Markdown following the Report Structure and Output Format sections.
49
- 3. Save the report to disk using the \`write\` tool. Path: \`.opencode/reports/{ProjectName}-audit-{YYYY-MM-DD}.md\` relative to the project root.
50
- 4. Confirm the file path in your response to Argus: "Report written to: {filePath}".
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. Confirm the report was generated in your response to Argus: "Report generated via argus_generate_report: {filePath}".
51
+
52
+ ## SINGLE-WRITER POLICY
53
+
54
+ **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
55
 
52
56
  ## QUALITY STANDARDS
53
57
 
package/src/cli/index.ts CHANGED
File without changes
@@ -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({
@@ -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
  })
@@ -8,6 +8,8 @@ import {
8
8
  createToolErrorRecoveryHandler,
9
9
  } from "./features/error-recovery"
10
10
  import { createDebouncedSave } from "./features/persistent-state/audit-state-manager"
11
+ import { getMigrationMode } from "./features/migration"
12
+ import { createEventSink, type EventSink } from "./features/persistent-state/event-sink"
11
13
  import { recordRun } from "./features/persistent-state/global-run-index"
12
14
  import { createRunJournal } from "./features/persistent-state/run-journal"
13
15
  import { createAgentTracker } from "./hooks/agent-tracker"
@@ -23,6 +25,7 @@ import { createToolTrackingHook } from "./hooks/tool-tracking-hook"
23
25
  import type { HookName } from "./hooks/types"
24
26
  import type { Managers } from "./managers/types"
25
27
  import { createLogger } from "./shared/logger"
28
+ import { createAuditArtifactResolver } from "./shared/audit-artifact-resolver"
26
29
  import type { AuditState } from "./state/types"
27
30
  import { detectAuditArtifacts } from "./utils/audit-artifact-detector"
28
31
  import { detectProject, type ProjectConfig } from "./utils/project-detector"
@@ -77,6 +80,9 @@ export function createHooks(args: {
77
80
  const agentTracker = createAgentTracker()
78
81
  _agentTrackerRef = agentTracker
79
82
 
83
+ const migrationMode = getMigrationMode(config)
84
+ logger.debug(`Migration mode: ${migrationMode}`)
85
+
80
86
  const contextMonitor = createContextMonitor()
81
87
  const sessionRecoveryHandler = createSessionRecoveryHandler(auditStateManager)
82
88
  const debouncedSave = createDebouncedSave(auditStateManager.save)
@@ -88,15 +94,21 @@ export function createHooks(args: {
88
94
  )
89
95
  const outputTruncator = createToolOutputTruncator()
90
96
 
97
+ let currentEventSink: EventSink | null = null
98
+ let currentOpencodeSessionId = ""
99
+
91
100
  // Sub-handlers run sequentially. The state persistence handler MUST be first:
92
101
  // it loads persisted state on session.created, overriding the fresh default.
93
102
  const {
94
103
  hook: eventHook,
95
104
  getAuditState,
96
105
  setAuditState,
106
+ setEventSink,
107
+ getLastFinalizationResult,
97
108
  } = createEventHook(projectDir, [
98
109
  async ({ type, sessionId, auditState, setAuditState: setState }) => {
99
110
  if (type === "session.created") {
111
+ currentOpencodeSessionId = sessionId ?? ""
100
112
  const timestamp = Date.now()
101
113
  let recoveredState: AuditState | null = null
102
114
 
@@ -123,6 +135,21 @@ export function createHooks(args: {
123
135
 
124
136
  const effectiveState = recoveredState ?? auditStateManager.get()
125
137
  if (effectiveState) {
138
+ try {
139
+ // createAuditArtifactResolver is the canonical source for all run artifact paths.
140
+ // The journal file path is: {projectDir}/.opencode/runs/{runId}/events.jsonl
141
+ const resolver = createAuditArtifactResolver(effectiveState.sessionId, projectDir)
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
+ }
152
+
126
153
  void recordRun({
127
154
  runId: effectiveState.sessionId,
128
155
  opencodeSessionId: sessionId,
@@ -180,16 +207,10 @@ export function createHooks(args: {
180
207
  if (auditState) {
181
208
  await auditStateManager.save(auditState)
182
209
  }
183
- await auditStateManager.archive()
184
-
185
- if (sessionId) {
186
- agentTracker.clearSession(sessionId)
187
- }
188
-
189
210
  runJournal.log({
190
- type: "session.deleted",
211
+ type: "state.saved",
191
212
  timestamp: Date.now(),
192
- archived: true,
213
+ success: true,
193
214
  })
194
215
  }
195
216
  },
@@ -253,25 +274,60 @@ export function createHooks(args: {
253
274
  const toolTrackingHook = isHookEnabled("tool-tracking")
254
275
  ? safeCreateHook(
255
276
  () =>
256
- createToolTrackingHook(getAuditState, ({ tool, findingsCount }) => {
257
- const currentState = getAuditState()
258
- if (currentState) {
259
- debouncedSave.save(currentState)
260
- }
261
-
262
- runJournal.log({
263
- type: "tool.executed",
264
- tool,
265
- timestamp: Date.now(),
266
- findingsCount,
267
- })
268
- }),
277
+ createToolTrackingHook(
278
+ getAuditState,
279
+ ({ tool, findingsCount }) => {
280
+ const currentState = getAuditState()
281
+ if (currentState) {
282
+ debouncedSave.save(currentState)
283
+ }
284
+
285
+ runJournal.log({
286
+ type: "tool.executed",
287
+ tool,
288
+ timestamp: Date.now(),
289
+ findingsCount,
290
+ })
291
+ },
292
+ {
293
+ getEventSink: () => currentEventSink,
294
+ getSessionId: () => currentOpencodeSessionId,
295
+ },
296
+ ),
269
297
  "tool-tracking",
270
298
  )
271
299
  : undefined
272
300
 
273
301
  const safeEventHook = isHookEnabled("event")
274
- ? safeCreateHook(() => eventHook, "event")
302
+ ? safeCreateHook(
303
+ () => async (input: Parameters<typeof eventHook>[0]) => {
304
+ const isSessionDeleted = input.event.type === "session.deleted"
305
+
306
+ try {
307
+ await eventHook(input)
308
+ } finally {
309
+ if (isSessionDeleted) {
310
+ await auditStateManager.archive()
311
+
312
+ const deletedSessionId = input.event.sessionId
313
+ if (deletedSessionId) {
314
+ agentTracker.clearSession(deletedSessionId)
315
+ }
316
+
317
+ runJournal.log({
318
+ type: "session.deleted",
319
+ timestamp: Date.now(),
320
+ archived: true,
321
+ finalizationPassed: getLastFinalizationResult()?.invariantsPassed ?? null,
322
+ })
323
+
324
+ currentEventSink = null
325
+ currentOpencodeSessionId = ""
326
+ }
327
+ }
328
+ },
329
+ "event",
330
+ )
275
331
  : undefined
276
332
 
277
333
  return {
@@ -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
+ }
@@ -0,0 +1,133 @@
1
+ import { stableHash } from "../../state/projectors"
2
+ import type { CanonicalFinding } from "../../state/schemas"
3
+ import type { Finding, FindingSeverity } from "../../state/types"
4
+
5
+ const SEVERITIES: readonly FindingSeverity[] = [
6
+ "Critical",
7
+ "High",
8
+ "Medium",
9
+ "Low",
10
+ "Informational",
11
+ ] as const
12
+
13
+ export interface SeverityDistribution {
14
+ Critical: number
15
+ High: number
16
+ Medium: number
17
+ Low: number
18
+ Informational: number
19
+ }
20
+
21
+ export interface ParityMetrics {
22
+ legacyFindingCount: number
23
+ canonicalFindingCount: number
24
+ findingCountDiff: number
25
+ legacySeverityDistribution: SeverityDistribution
26
+ canonicalSeverityDistribution: SeverityDistribution
27
+ severityDiffs: Partial<Record<FindingSeverity, number>>
28
+ legacyContentHash: string
29
+ canonicalContentHash: string
30
+ hashMatch: boolean
31
+ onlyInLegacy: string[]
32
+ onlyInCanonical: string[]
33
+ timestamp: number
34
+ }
35
+
36
+ function computeSeverityDistribution(
37
+ findings: Array<{ severity: FindingSeverity }>,
38
+ ): SeverityDistribution {
39
+ const dist: SeverityDistribution = {
40
+ Critical: 0,
41
+ High: 0,
42
+ Medium: 0,
43
+ Low: 0,
44
+ Informational: 0,
45
+ }
46
+ for (const f of findings) {
47
+ if (f.severity in dist) {
48
+ dist[f.severity]++
49
+ }
50
+ }
51
+ return dist
52
+ }
53
+
54
+ function findingIds(findings: Array<{ id: string }>): Set<string> {
55
+ return new Set(findings.map((f) => f.id))
56
+ }
57
+
58
+ export function computeParityMetrics(
59
+ legacyFindings: Finding[],
60
+ canonicalFindings: CanonicalFinding[],
61
+ ): ParityMetrics {
62
+ const legacySeverity = computeSeverityDistribution(legacyFindings)
63
+ const canonicalSeverity = computeSeverityDistribution(canonicalFindings)
64
+
65
+ const severityDiffs: Partial<Record<FindingSeverity, number>> = {}
66
+ for (const sev of SEVERITIES) {
67
+ const diff = canonicalSeverity[sev] - legacySeverity[sev]
68
+ if (diff !== 0) {
69
+ severityDiffs[sev] = diff
70
+ }
71
+ }
72
+
73
+ const legacyIds = findingIds(legacyFindings)
74
+ const canonicalIds = findingIds(canonicalFindings)
75
+
76
+ const onlyInLegacy = [...legacyIds].filter((id) => !canonicalIds.has(id))
77
+ const onlyInCanonical = [...canonicalIds].filter((id) => !legacyIds.has(id))
78
+
79
+ const legacyContentHash = stableHash(
80
+ legacyFindings.map((f) => ({ id: f.id, check: f.check, severity: f.severity, file: f.file })),
81
+ )
82
+ const canonicalContentHash = stableHash(
83
+ canonicalFindings.map((f) => ({
84
+ id: f.id,
85
+ check: f.check,
86
+ severity: f.severity,
87
+ file: f.file,
88
+ })),
89
+ )
90
+
91
+ return {
92
+ legacyFindingCount: legacyFindings.length,
93
+ canonicalFindingCount: canonicalFindings.length,
94
+ findingCountDiff: canonicalFindings.length - legacyFindings.length,
95
+ legacySeverityDistribution: legacySeverity,
96
+ canonicalSeverityDistribution: canonicalSeverity,
97
+ severityDiffs,
98
+ legacyContentHash,
99
+ canonicalContentHash,
100
+ hashMatch: legacyContentHash === canonicalContentHash,
101
+ onlyInLegacy,
102
+ onlyInCanonical,
103
+ timestamp: Date.now(),
104
+ }
105
+ }
106
+
107
+ export function formatParityReport(metrics: ParityMetrics): string {
108
+ const lines: string[] = [
109
+ "=== Migration Parity Report ===",
110
+ `Finding count: legacy=${metrics.legacyFindingCount} canonical=${metrics.canonicalFindingCount} diff=${metrics.findingCountDiff}`,
111
+ `Content hash match: ${metrics.hashMatch}`,
112
+ ]
113
+
114
+ const sevDiffs = Object.entries(metrics.severityDiffs)
115
+ if (sevDiffs.length > 0) {
116
+ lines.push(
117
+ `Severity diffs: ${sevDiffs.map(([k, v]) => `${k}=${v > 0 ? "+" : ""}${v}`).join(", ")}`,
118
+ )
119
+ }
120
+
121
+ if (metrics.onlyInLegacy.length > 0) {
122
+ lines.push(
123
+ `Only in legacy (${metrics.onlyInLegacy.length}): ${metrics.onlyInLegacy.join(", ")}`,
124
+ )
125
+ }
126
+ if (metrics.onlyInCanonical.length > 0) {
127
+ lines.push(
128
+ `Only in canonical (${metrics.onlyInCanonical.length}): ${metrics.onlyInCanonical.join(", ")}`,
129
+ )
130
+ }
131
+
132
+ return lines.join("\n")
133
+ }