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 +4 -4
- package/src/agents/argus-prompt.ts +10 -2
- package/src/agents/pythia-prompt.ts +11 -0
- package/src/agents/scribe-prompt.ts +6 -5
- package/src/agents/sentinel-prompt.ts +10 -0
- package/src/create-hooks.ts +111 -1
- package/src/create-tools.ts +2 -0
- package/src/features/audit-enforcer/audit-enforcer.ts +0 -1
- package/src/features/persistent-state/audit-state-manager.ts +157 -9
- package/src/features/persistent-state/event-sink.ts +11 -6
- package/src/features/persistent-state/findings-materializer.ts +25 -2
- package/src/features/persistent-state/run-finalizer.ts +2 -0
- package/src/features/persistent-state/run-journal.ts +1 -4
- package/src/hooks/event-hook.ts +4 -1
- package/src/hooks/system-prompt-hook.ts +2 -7
- package/src/hooks/tool-tracking-hook.ts +176 -12
- package/src/shared/plugin-metadata.ts +23 -0
- package/src/state/adapters.ts +99 -5
- package/src/state/finding-aggregation.ts +100 -0
- package/src/state/finding-fingerprint.ts +47 -0
- package/src/state/finding-store.ts +19 -29
- package/src/state/projectors.ts +18 -4
- package/src/state/schemas.ts +49 -2
- package/src/state/types.ts +11 -1
- package/src/tools/record-finding-tool.ts +125 -0
- package/src/tools/report-generator-tool.ts +53 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "solidity-argus",
|
|
3
|
-
"version": "0.3.
|
|
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": "
|
|
29
|
-
"argus": "
|
|
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
|
|
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": "
|
|
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
|
|
48
|
-
2.
|
|
49
|
-
3.
|
|
50
|
-
4.
|
|
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
|
-
|
|
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.
|
package/src/create-hooks.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/create-tools.ts
CHANGED
|
@@ -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:
|
|
247
|
+
source_of_truth: snapshotSourceOfTruth,
|
|
133
248
|
last_event_seq: snapshotSeq,
|
|
134
|
-
event_stream_hash:
|
|
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
|
-
|
|
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
|
-
...
|
|
316
|
+
...consistent.state,
|
|
174
317
|
savedAt: Date.now(),
|
|
175
318
|
version: STATE_VERSION,
|
|
176
319
|
filePath: stateFilePath,
|
|
177
|
-
source_of_truth:
|
|
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 ===
|
|
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
|
-
...
|
|
371
|
+
...consistent.state,
|
|
226
372
|
savedAt: Date.now(),
|
|
227
373
|
version: STATE_VERSION,
|
|
228
374
|
filePath: archivePath,
|
|
229
|
-
source_of_truth:
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 ??
|
|
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
|
|