solidity-argus 0.3.5 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solidity-argus",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
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",
@@ -25,8 +25,8 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "bin": {
28
- "solidity-argus": "./src/cli/index.ts",
29
- "argus": "./src/cli/index.ts"
28
+ "solidity-argus": "src/cli/index.ts",
29
+ "argus": "src/cli/index.ts"
30
30
  },
31
31
  "files": [
32
32
  "src/",
@@ -66,7 +66,7 @@
66
66
  },
67
67
  "repository": {
68
68
  "type": "git",
69
- "url": "https://github.com/Apegurus/solidity-argus"
69
+ "url": "git+https://github.com/Apegurus/solidity-argus.git"
70
70
  },
71
71
  "engines": {
72
72
  "bun": ">=1.0.0"
@@ -233,6 +233,9 @@ When building the final report or synthesizing findings:
233
233
  1. **Primary source**: \`toolsExecuted\` records, \`findings\` from state, and event stream data persisted via argus_* tool outputs.
234
234
  2. **Secondary source**: Tool transcript text (use only when durable evidence is unavailable or incomplete).
235
235
  3. **Never** synthesize findings from ephemeral background transcript retrieval alone if durable state evidence exists.
236
+ 4. **Manual-finding durability**: If Argus, Sentinel, or Pythia identifies a finding outside analyzer tool payloads, they must call \
237
+ \`argus_record_finding\` before proceeding.
238
+ 5. **Report parity rule**: Scribe must not include findings in \`report_input\` unless they are event-backed (recorded via tools/events).
236
239
 
237
240
  **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
241
 
@@ -315,7 +318,12 @@ Your subagents have access to these specialized tools. Know when to delegate eac
315
318
  - **\`argus_generate_report\`**:
316
319
  - **Use**: During Reporting.
317
320
  - **Purpose**: Generates the final artifact.
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.
321
+ - **Note**: Requires a versioned report_input JSON string matching the ReportInput contract (schema_version 2.0.0). Do not send natural-language-only findings to Scribe for tool invocation.
322
+
323
+ - **\`argus_record_finding\`**:
324
+ - **Use**: Whenever a manual/non-tool finding is identified.
325
+ - **Purpose**: Persist manually identified findings as canonical event-backed observations before reporting.
326
+ - **Note**: Accepts a single finding or an array. Call it immediately when the finding is identified.
319
327
 
320
328
  - **\`argus_sync_knowledge\`**:
321
329
  - **Use**: Maintenance.
@@ -481,7 +489,7 @@ ReportInput JSON (pass EXACTLY, no prose substitution):
481
489
  "session_id": "{session-id}",
482
490
  "tool_call_id": "{tool-call-id}",
483
491
  "source": "argus",
484
- "schema_version": "1.0.0",
492
+ "schema_version": "2.0.0",
485
493
  "projectDir": "{project-dir}",
486
494
  "findings": [canonical findings],
487
495
  "toolsExecuted": [canonical tool executions],
@@ -49,6 +49,7 @@ You must follow this structured research process:
49
49
  ### 4. Reporting
50
50
  - **Objective**: Deliver actionable intelligence to Argus.
51
51
  - **Actions**:
52
+ - If you identify a manual finding from precedent/pattern reasoning, call \`argus_record_finding\` before reporting back.
52
53
  - Format findings clearly, citing the precedent (e.g., "Similar to the Cream Finance hack").
53
54
  - Assess severity based on the *likelihood* of exploitation in this specific context.
54
55
 
@@ -84,6 +85,16 @@ You have two primary tools. Master them.
84
85
  - Returns a list of matches with line numbers.
85
86
  - **Crucial**: You must verify the context. A regex match for \`selfdestruct\` is not a bug if it's in a test file or a legitimate upgrade mechanism (though still risky).
86
87
 
88
+ ### 3. \`argus_record_finding\`
89
+ **Purpose**: Persist research/manual findings into durable event-backed observations.
90
+ **When to use**:
91
+ - Whenever your finding is derived from precedent analysis or manual reasoning rather than a direct analyzer payload.
92
+ **Arguments**:
93
+ - \`finding\` (string): Serialized JSON object for one finding.
94
+ - \`findings\` (string): Serialized JSON array for multiple findings.
95
+ **Interpretation**:
96
+ - A finding is not report-ready until it has been recorded through this tool.
97
+
87
98
  ## EMPTY RESULTS STRATEGY
88
99
 
89
100
  When \`argus_solodit_search\` returns zero results for a query:
@@ -44,14 +44,15 @@ You must adhere to these strict writing standards:
44
44
  Argus passes you structured report data. Use that payload directly and keep it schema-accurate.
45
45
 
46
46
  **Your workflow**:
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
- 2. Write the complete report in Markdown following the Report Structure and Output Format sections.
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:
47
+ 1. Validate Argus provided a serialized ReportInput JSON string (schema_version 2.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
+ 2. Enforce parity: do not include findings unless they are event-backed observations (recorded through tool/event flow, including \`argus_record_finding\`).
49
+ 3. Write the complete report in Markdown following the Report Structure and Output Format sections.
50
+ 4. Call \`argus_generate_report\` with arguments { project_name, scope, report_input }. Use legacy \`audit_state\` only for transitional compatibility and treat it as deprecated.
51
+ 5. **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
52
  - \`**Tool name**: [reason \u2014 unavailable/failed/timed out]. [Impact on finding coverage if any.]\`
52
53
  - Example: \`**argus_solodit_search**: External database was unavailable. Known-vulnerability cross-referencing was performed using local patterns only.\`
53
54
  - 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
+ 6. Confirm the report was generated in your response to Argus: "Report generated via argus_generate_report: {filePath}".
55
56
 
56
57
  ## SINGLE-WRITER POLICY
57
58
 
@@ -32,6 +32,7 @@ You operate in a loop of **Scan -> Analyze -> Verify**.
32
32
 
33
33
  4. **Reporting**:
34
34
  - Format your findings strictly according to the Output Format section.
35
+ - If you identify a manual finding outside analyzer payloads, call \`argus_record_finding\` immediately.
35
36
  - Report back to Argus with confirmed findings.
36
37
 
37
38
  ## POC VERIFICATION
@@ -127,6 +128,15 @@ You have access to a specific set of tools. Use them effectively.
127
128
  - High gas consumption often correlates with complex logic, unbounded loops, or storage-heavy operations.
128
129
  - Gas hotspots are prime candidates for DoS vulnerabilities.
129
130
 
131
+ ### 9. \`argus_record_finding\`
132
+ **Purpose**: Persist manual/non-tool findings as canonical event-backed observations.
133
+ **When to use**: Any time you manually confirm a finding that did not come from \`argus_slither_analyze\` or \`argus_check_patterns\` payloads.
134
+ **Arguments**:
135
+ - \`finding\` (string): Serialized JSON object for a single finding.
136
+ - \`findings\` (string): Serialized JSON array for multiple findings.
137
+ **Interpretation**:
138
+ - Recording is mandatory before handing findings to Argus for final synthesis.
139
+
130
140
  ## SKILL SYSTEM
131
141
 
132
142
  Use \`argus_skill_load\` only when specialized context is needed before deep verification work.
@@ -41,6 +41,31 @@ export type AgentTrackerRef = {
41
41
 
42
42
  let _agentTrackerRef: AgentTrackerRef | undefined
43
43
 
44
+ const REPORT_METADATA_REGEX = /<!-- argus:report_metadata (.+?) -->/
45
+
46
+ function extractRunIdFromReportToolOutput(result: string): string | undefined {
47
+ try {
48
+ const parsed = JSON.parse(result) as Record<string, unknown>
49
+ if (typeof parsed.run_id === "string" && parsed.run_id.length > 0) {
50
+ return parsed.run_id
51
+ }
52
+
53
+ if (typeof parsed.report === "string") {
54
+ const match = parsed.report.match(REPORT_METADATA_REGEX)
55
+ if (match?.[1]) {
56
+ const metadata = JSON.parse(match[1]) as Record<string, unknown>
57
+ if (typeof metadata.run_id === "string" && metadata.run_id.length > 0) {
58
+ return metadata.run_id
59
+ }
60
+ }
61
+ }
62
+ } catch {
63
+ return undefined
64
+ }
65
+
66
+ return undefined
67
+ }
68
+
44
69
  export function getAgentForSession(sessionID: string): string | undefined {
45
70
  return _agentTrackerRef?.getAgentForSession(sessionID)
46
71
  }
@@ -310,12 +335,73 @@ export function createHooks(args: {
310
335
  {
311
336
  getEventSink: () => currentEventSink,
312
337
  getSessionId: () => currentOpencodeSessionId,
338
+ getAgentName: () => {
339
+ if (!currentOpencodeSessionId) {
340
+ return undefined
341
+ }
342
+
343
+ const agent = agentTracker.getAgentForSession(currentOpencodeSessionId)
344
+ if (
345
+ agent === "argus" ||
346
+ agent === "sentinel" ||
347
+ agent === "pythia" ||
348
+ agent === "scribe" ||
349
+ agent === "unknown"
350
+ ) {
351
+ return agent
352
+ }
353
+
354
+ return "unknown"
355
+ },
356
+ getAgentNameForSession: (sessionId: string) => {
357
+ const agent = agentTracker.getAgentForSession(sessionId)
358
+ if (
359
+ agent === "argus" ||
360
+ agent === "sentinel" ||
361
+ agent === "pythia" ||
362
+ agent === "scribe" ||
363
+ agent === "unknown"
364
+ ) {
365
+ return agent
366
+ }
367
+
368
+ return "unknown"
369
+ },
313
370
  },
314
371
  ),
315
372
  "tool-tracking",
316
373
  )
317
374
  : undefined
318
375
 
376
+ const materializeFindingsForRun = async (
377
+ runId: string,
378
+ projectDirForRun: string,
379
+ sessionIdForRun: string | undefined,
380
+ trigger: "session.idle" | "tool.execute.after",
381
+ failFast = false,
382
+ ): Promise<void> => {
383
+ if (!runId || runId.length === 0) {
384
+ return
385
+ }
386
+
387
+ try {
388
+ await materializeFindings(runId, projectDirForRun, sessionIdForRun, {
389
+ validateSessionId: sessionIdForRun != null && sessionIdForRun.length > 0,
390
+ requireEvents: true,
391
+ })
392
+ } catch (error) {
393
+ if (failFast) {
394
+ throw new Error(
395
+ `Failed to materialize findings artifact on ${trigger} for run ${runId}: ${error instanceof Error ? error.message : String(error)}`,
396
+ )
397
+ }
398
+
399
+ logger.warn(
400
+ `Failed to materialize findings artifact on ${trigger} for run ${runId}: ${error instanceof Error ? error.message : String(error)}`,
401
+ )
402
+ }
403
+ }
404
+
319
405
  const safeEventHook = isHookEnabled("event")
320
406
  ? safeCreateHook(
321
407
  () => async (input: Parameters<typeof eventHook>[0]) => {
@@ -332,7 +418,13 @@ export function createHooks(args: {
332
418
 
333
419
  if (hasNewFinalization && finalizationResult.runId.length > 0) {
334
420
  try {
335
- await materializeFindings(finalizationResult.runId, projectDir)
421
+ await materializeFindingsForRun(
422
+ finalizationResult.runId,
423
+ projectDir,
424
+ input.event.sessionId,
425
+ "session.idle",
426
+ true,
427
+ )
336
428
  } catch (error) {
337
429
  logger.warn(
338
430
  `Failed to materialize findings artifact for run ${finalizationResult.runId}: ${error instanceof Error ? error.message : String(error)}`,
@@ -391,8 +483,26 @@ export function createHooks(args: {
391
483
  tool: input.tool,
392
484
  args: input.args,
393
485
  result: output.output,
486
+ sessionID: input.sessionID,
487
+ callID: input.callID,
394
488
  })
395
489
 
490
+ if (input.tool === "argus_generate_report") {
491
+ const state = getAuditState()
492
+ if (!state || state.sessionId.length === 0) {
493
+ throw new Error("argus_generate_report completed without active audit state")
494
+ }
495
+
496
+ const runId = extractRunIdFromReportToolOutput(output.output) ?? state.sessionId
497
+ await materializeFindingsForRun(
498
+ runId,
499
+ state.projectDir,
500
+ input.sessionID,
501
+ "tool.execute.after",
502
+ true,
503
+ )
504
+ }
505
+
396
506
  const outputWithHint = recoveryHint ? `${output.output}${recoveryHint}` : output.output
397
507
  output.output = outputTruncator(outputWithHint)
398
508
  }
@@ -8,6 +8,7 @@ import { forgeTestTool } from "./tools/forge-test-tool"
8
8
  import { gasAnalysisTool } from "./tools/gas-analysis-tool"
9
9
  import { patternCheckerTool } from "./tools/pattern-checker-tool"
10
10
  import { proxyDetectionTool } from "./tools/proxy-detection-tool"
11
+ import { recordFindingTool } from "./tools/record-finding-tool"
11
12
  import { reportGeneratorTool } from "./tools/report-generator-tool"
12
13
  import { slitherTool } from "./tools/slither-tool"
13
14
  import { createSoloditSearchTool } from "./tools/solodit-search-tool"
@@ -24,6 +25,7 @@ export function createTools(config: ArgusConfig): Record<string, ToolDefinition>
24
25
  argus_check_patterns: patternCheckerTool,
25
26
  argus_proxy_detection: proxyDetectionTool,
26
27
  argus_skill_load: argusSkillLoadTool,
28
+ argus_record_finding: recordFindingTool,
27
29
  argus_generate_report: reportGeneratorTool,
28
30
  argus_sync_knowledge: syncKnowledgeTool,
29
31
  }
@@ -13,7 +13,6 @@ const PHASE_ORDER: AuditPhase[] = [
13
13
 
14
14
  const REPORTING_PHASES: AuditPhase[] = ["reporting", "complete"]
15
15
 
16
-
17
16
  const KEY_TOOL_FAMILIES: Array<{ family: string; prefixes: string[] }> = [
18
17
  { family: "slither", prefixes: ["argus_slither_analyze", "slither"] },
19
18
  { family: "forge_test", prefixes: ["argus_forge_test", "forge_test"] },
@@ -4,11 +4,26 @@ import type { AuditStateManager } from "../../managers/types"
4
4
  import { createLogger } from "../../shared/logger"
5
5
  import { type ArgusRootResolver, defaultRootResolver } from "../../shared/path-root-resolver"
6
6
  import { createAuditState } from "../../state/audit-state"
7
+ import { projectAuditState, stableHash } from "../../state/projectors"
7
8
  import type { AuditState, PersistentAuditState } from "../../state/types"
9
+ import { readEvents } from "./event-sink"
8
10
 
9
11
  const STATE_FILE_NAME = "argus-state.json"
10
12
  const STATE_VERSION = "2"
11
13
 
14
+ type ProjectedAuditCore = Pick<
15
+ AuditState,
16
+ "contractsReviewed" | "findings" | "toolsExecuted" | "currentPhase" | "scope"
17
+ >
18
+
19
+ interface ConsistentStateResult {
20
+ state: AuditState
21
+ sourceOfTruth: "events" | "snapshot"
22
+ lastEventSeq?: number
23
+ eventStreamHash?: string
24
+ repaired: boolean
25
+ }
26
+
12
27
  function isObject(value: unknown): value is Record<string, unknown> {
13
28
  return typeof value === "object" && value !== null
14
29
  }
@@ -46,6 +61,54 @@ function isPersistentAuditState(value: unknown): value is PersistentAuditState {
46
61
  )
47
62
  }
48
63
 
64
+ function projectCoreState(
65
+ state: AuditState,
66
+ events: Awaited<ReturnType<typeof readEvents>>,
67
+ ): ProjectedAuditCore {
68
+ const projected = projectAuditState(events, state.projectDir)
69
+
70
+ return {
71
+ contractsReviewed: projected.contractsReviewed,
72
+ findings: projected.findings,
73
+ toolsExecuted: projected.toolsExecuted,
74
+ currentPhase: projected.currentPhase,
75
+ scope: projected.scope,
76
+ }
77
+ }
78
+
79
+ function hasProjectedCoreMismatch(state: AuditState, projectedCore: ProjectedAuditCore): boolean {
80
+ const stateCore: ProjectedAuditCore = {
81
+ contractsReviewed: state.contractsReviewed,
82
+ findings: state.findings,
83
+ toolsExecuted: state.toolsExecuted,
84
+ currentPhase: state.currentPhase,
85
+ scope: state.scope,
86
+ }
87
+
88
+ return stableHash(stateCore) !== stableHash(projectedCore)
89
+ }
90
+
91
+ function hasSnapshotStampMismatch(
92
+ snapshotSeq: number | undefined,
93
+ snapshotHash: string | undefined,
94
+ derivedSeq: number | undefined,
95
+ derivedHash: string | undefined,
96
+ ): boolean {
97
+ if (snapshotSeq === undefined && snapshotHash === undefined) {
98
+ return false
99
+ }
100
+
101
+ if (snapshotSeq !== undefined && derivedSeq !== undefined && snapshotSeq !== derivedSeq) {
102
+ return true
103
+ }
104
+
105
+ if (snapshotHash !== undefined && derivedHash !== undefined && snapshotHash !== derivedHash) {
106
+ return true
107
+ }
108
+
109
+ return false
110
+ }
111
+
49
112
  export function createDebouncedSave(
50
113
  saveState: (state: AuditState) => Promise<void>,
51
114
  delayMs = 5_000,
@@ -104,6 +167,58 @@ export function createAuditStateManager(
104
167
  const stateFilePath = join(resolver.writeRoot(projectDir), STATE_FILE_NAME)
105
168
  let currentState: AuditState = createAuditState(projectDir).state
106
169
 
170
+ async function deriveConsistentState(state: AuditState): Promise<ConsistentStateResult> {
171
+ if (!state.sessionId || !state.projectDir) {
172
+ return {
173
+ state,
174
+ sourceOfTruth: "snapshot",
175
+ repaired: false,
176
+ }
177
+ }
178
+
179
+ try {
180
+ const events = await readEvents(state.sessionId, state.projectDir, resolver)
181
+ const lastEventSeq = events.at(-1)?.seq ?? 0
182
+ const eventStreamHash = stableHash(events)
183
+
184
+ if (events.length === 0) {
185
+ return {
186
+ state,
187
+ sourceOfTruth: "events",
188
+ lastEventSeq,
189
+ eventStreamHash,
190
+ repaired: false,
191
+ }
192
+ }
193
+
194
+ const projectedCore = projectCoreState(state, events)
195
+ const repaired = hasProjectedCoreMismatch(state, projectedCore)
196
+
197
+ return {
198
+ state: repaired
199
+ ? {
200
+ ...state,
201
+ ...projectedCore,
202
+ }
203
+ : state,
204
+ sourceOfTruth: "events",
205
+ lastEventSeq,
206
+ eventStreamHash,
207
+ repaired,
208
+ }
209
+ } catch (error) {
210
+ logger.warn(
211
+ `Failed to derive state from events for run ${state.sessionId}; using snapshot fallback`,
212
+ error,
213
+ )
214
+ return {
215
+ state,
216
+ sourceOfTruth: "snapshot",
217
+ repaired: false,
218
+ }
219
+ }
220
+ }
221
+
107
222
  async function load(): Promise<AuditState | null> {
108
223
  try {
109
224
  const resolvedPath = resolver.resolveReadPath(projectDir, STATE_FILE_NAME)
@@ -129,9 +244,9 @@ export function createAuditStateManager(
129
244
  savedAt: _savedAt,
130
245
  version,
131
246
  filePath: _filePath,
132
- source_of_truth: _sourceOfTruth,
247
+ source_of_truth: snapshotSourceOfTruth,
133
248
  last_event_seq: snapshotSeq,
134
- event_stream_hash: _eventStreamHash,
249
+ event_stream_hash: snapshotEventHash,
135
250
  ...state
136
251
  } = parsed
137
252
 
@@ -144,12 +259,32 @@ export function createAuditStateManager(
144
259
  }
145
260
  }
146
261
 
147
-
148
262
  if (snapshotSeq !== undefined) {
149
263
  logger.debug(`Loaded snapshot with last_event_seq=${snapshotSeq} from ${readPath}`)
150
264
  }
151
265
 
152
- currentState = state
266
+ const consistent = await deriveConsistentState(state)
267
+ const stampMismatch =
268
+ consistent.sourceOfTruth === "events" &&
269
+ hasSnapshotStampMismatch(
270
+ snapshotSeq,
271
+ snapshotEventHash,
272
+ consistent.lastEventSeq,
273
+ consistent.eventStreamHash,
274
+ )
275
+
276
+ if (consistent.repaired || stampMismatch) {
277
+ const mismatchReason = consistent.repaired ? "projected core mismatch" : "stamp mismatch"
278
+ logger.warn(
279
+ `Recovered audit state from event stream for run ${state.sessionId}: ${mismatchReason}`,
280
+ )
281
+ } else if (snapshotSourceOfTruth === "events" && consistent.sourceOfTruth !== "events") {
282
+ logger.warn(
283
+ `Snapshot for run ${state.sessionId} was marked event-derived but could not be validated against events`,
284
+ )
285
+ }
286
+
287
+ currentState = consistent.state
153
288
  return currentState
154
289
  } catch (err) {
155
290
  logger.warn("Failed to load persisted audit state", err)
@@ -168,13 +303,23 @@ export function createAuditStateManager(
168
303
  try {
169
304
  while (true) {
170
305
  const stateToSave = currentState
306
+ const consistent = await deriveConsistentState(stateToSave)
307
+
308
+ if (consistent.repaired) {
309
+ logger.warn(
310
+ `State/core divergence detected for run ${stateToSave.sessionId}; auto-repairing`,
311
+ )
312
+ currentState = consistent.state
313
+ }
171
314
 
172
315
  const persistentState: PersistentAuditState = {
173
- ...stateToSave,
316
+ ...consistent.state,
174
317
  savedAt: Date.now(),
175
318
  version: STATE_VERSION,
176
319
  filePath: stateFilePath,
177
- source_of_truth: "events",
320
+ source_of_truth: consistent.sourceOfTruth,
321
+ last_event_seq: consistent.lastEventSeq,
322
+ event_stream_hash: consistent.eventStreamHash,
178
323
  }
179
324
 
180
325
  const tempFilePath = `${stateFilePath}.${Date.now()}.tmp`
@@ -182,7 +327,7 @@ export function createAuditStateManager(
182
327
  await Bun.write(tempFilePath, `${JSON.stringify(persistentState, null, 2)}\n`)
183
328
  await rename(tempFilePath, stateFilePath)
184
329
 
185
- if (currentState === stateToSave) break
330
+ if (currentState === consistent.state) break
186
331
  }
187
332
  } catch (err) {
188
333
  logger.warn("Failed to persist audit state", err)
@@ -218,15 +363,18 @@ export function createAuditStateManager(
218
363
 
219
364
  if (hasContent) {
220
365
  try {
366
+ const consistent = await deriveConsistentState(currentState)
221
367
  const archivesDir = join(dirname(stateFilePath), "archives")
222
368
  await mkdir(archivesDir, { recursive: true })
223
369
  const archivePath = join(archivesDir, `argus-state.${Date.now()}.json`)
224
370
  const persistentState: PersistentAuditState = {
225
- ...currentState,
371
+ ...consistent.state,
226
372
  savedAt: Date.now(),
227
373
  version: STATE_VERSION,
228
374
  filePath: archivePath,
229
- source_of_truth: "events",
375
+ source_of_truth: consistent.sourceOfTruth,
376
+ last_event_seq: consistent.lastEventSeq,
377
+ event_stream_hash: consistent.eventStreamHash,
230
378
  }
231
379
  await Bun.write(archivePath, `${JSON.stringify(persistentState, null, 2)}\n`)
232
380
  } catch {
@@ -1,9 +1,6 @@
1
1
  import { mkdir, rename } from "node:fs/promises"
2
2
  import { dirname, join } from "node:path"
3
- import {
4
- type ArgusRootResolver,
5
- defaultRootResolver,
6
- } from "../../shared/path-root-resolver"
3
+ import { type ArgusRootResolver, defaultRootResolver } from "../../shared/path-root-resolver"
7
4
  import type { AuditEvent, AuditEventType } from "../../state/schemas"
8
5
 
9
6
  export type EventSinkErrorCode = "SEQUENCE_CONFLICT" | "INVALID_EVENT" | "IO_ERROR"
@@ -89,7 +86,11 @@ function parseJournalLines(content: string): AuditEvent[] {
89
86
  /**
90
87
  * Replay-safe stateless read — returns all events for a run sorted by seq.
91
88
  */
92
- export async function readEvents(runId: string, projectDir: string, resolver: ArgusRootResolver = defaultRootResolver): Promise<AuditEvent[]> {
89
+ export async function readEvents(
90
+ runId: string,
91
+ projectDir: string,
92
+ resolver: ArgusRootResolver = defaultRootResolver,
93
+ ): Promise<AuditEvent[]> {
93
94
  const journalPath = buildJournalPath(runId, projectDir, resolver)
94
95
  const content = await readRawContent(journalPath)
95
96
  return parseJournalLines(content)
@@ -99,7 +100,11 @@ export async function readEvents(runId: string, projectDir: string, resolver: Ar
99
100
  * Append-only event sink with monotonic seq allocation, in-process mutex,
100
101
  * and atomic temp-file-then-rename writes. Restart-safe via journal replay.
101
102
  */
102
- export function createEventSink(runId: string, projectDir: string, resolver: ArgusRootResolver = defaultRootResolver): EventSink {
103
+ export function createEventSink(
104
+ runId: string,
105
+ projectDir: string,
106
+ resolver: ArgusRootResolver = defaultRootResolver,
107
+ ): EventSink {
103
108
  const journalPath = buildJournalPath(runId, projectDir, resolver)
104
109
  const mutex = createMutex()
105
110
  let lastSeq = 0
@@ -1,6 +1,7 @@
1
1
  import { mkdir, writeFile } from "node:fs/promises"
2
2
  import { dirname } from "node:path"
3
3
  import { createAuditArtifactResolver } from "../../shared/audit-artifact-resolver"
4
+ import { dedupeFindingsForFinalOutput } from "../../state/finding-aggregation"
4
5
  import { projectFindings, projectToolExecutions, stableHash } from "../../state/projectors"
5
6
  import type { CanonicalFinding, CanonicalToolExecution } from "../../state/schemas"
6
7
  import { SCHEMA_VERSION } from "../../state/schemas"
@@ -19,20 +20,42 @@ export interface FindingsArtifact {
19
20
  toolsExecuted: CanonicalToolExecution[]
20
21
  }
21
22
 
23
+ export interface FindingsMaterializeOptions {
24
+ validateSessionId?: boolean
25
+ requireEvents?: boolean
26
+ }
27
+
22
28
  export async function materializeFindings(
23
29
  runId: string,
24
30
  projectDir: string,
25
31
  sessionId?: string,
32
+ options: FindingsMaterializeOptions = {},
26
33
  ): Promise<FindingsArtifact> {
27
34
  const events = await readEvents(runId, projectDir)
28
- const findings = projectFindings(events)
35
+ if (options.requireEvents && events.length === 0) {
36
+ throw new Error(`No events found for run ${runId}`)
37
+ }
38
+
39
+ const sessionIdFromEvents = events[0]?.session_id ?? ""
40
+ if (
41
+ options.validateSessionId &&
42
+ sessionId &&
43
+ sessionIdFromEvents.length > 0 &&
44
+ sessionId !== sessionIdFromEvents
45
+ ) {
46
+ throw new Error(
47
+ `Session mismatch for run ${runId}: provided ${sessionId}, event stream has ${sessionIdFromEvents}`,
48
+ )
49
+ }
50
+
51
+ const findings = dedupeFindingsForFinalOutput(projectFindings(events))
29
52
  const toolsExecuted = projectToolExecutions(events)
30
53
  const contentHash = stableHash(JSON.stringify(findings))
31
54
  const generatedAt = events.at(-1)?.timestamp ?? 0
32
55
 
33
56
  const artifact: FindingsArtifact = {
34
57
  run_id: runId,
35
- session_id: sessionId ?? events[0]?.session_id ?? "",
58
+ session_id: sessionId ?? sessionIdFromEvents,
36
59
  schema_version: SCHEMA_VERSION,
37
60
  seq_first: events[0]?.seq ?? 0,
38
61
  seq_last: events.at(-1)?.seq ?? 0,
@@ -1,3 +1,4 @@
1
+ import { ARGUS_PLUGIN_VERSION } from "../../shared/plugin-metadata"
1
2
  import { validateEventSequence } from "../../state/projectors"
2
3
  import type { AuditEvent } from "../../state/schemas"
3
4
  import { SCHEMA_VERSION } from "../../state/schemas"
@@ -178,6 +179,7 @@ export async function finalizeRun(
178
179
  invariantsPassed,
179
180
  errors,
180
181
  status: invariantsPassed ? "finalized" : "failed-finalization",
182
+ plugin_version: ARGUS_PLUGIN_VERSION,
181
183
  },
182
184
  })
183
185
  }
@@ -1,10 +1,7 @@
1
1
  import { appendFile, mkdir } from "node:fs/promises"
2
2
  import { dirname, join } from "node:path"
3
- import {
4
- type ArgusRootResolver,
5
- defaultRootResolver,
6
- } from "../../shared/path-root-resolver"
7
3
  import { createLogger } from "../../shared/logger"
4
+ import { type ArgusRootResolver, defaultRootResolver } from "../../shared/path-root-resolver"
8
5
 
9
6
  const logger = createLogger()
10
7