solidity-argus 0.3.5 → 0.3.6
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 +50 -0
- 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 +2 -1
- 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 +3 -0
- package/src/hooks/system-prompt-hook.ts +2 -7
- package/src/hooks/tool-tracking-hook.ts +168 -10
- 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 +51 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "solidity-argus",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
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
|
@@ -310,12 +310,58 @@ export function createHooks(args: {
|
|
|
310
310
|
{
|
|
311
311
|
getEventSink: () => currentEventSink,
|
|
312
312
|
getSessionId: () => currentOpencodeSessionId,
|
|
313
|
+
getAgentName: () => {
|
|
314
|
+
if (!currentOpencodeSessionId) {
|
|
315
|
+
return undefined
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const agent = agentTracker.getAgentForSession(currentOpencodeSessionId)
|
|
319
|
+
if (
|
|
320
|
+
agent === "argus" ||
|
|
321
|
+
agent === "sentinel" ||
|
|
322
|
+
agent === "pythia" ||
|
|
323
|
+
agent === "scribe" ||
|
|
324
|
+
agent === "unknown"
|
|
325
|
+
) {
|
|
326
|
+
return agent
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return "unknown"
|
|
330
|
+
},
|
|
313
331
|
},
|
|
314
332
|
),
|
|
315
333
|
"tool-tracking",
|
|
316
334
|
)
|
|
317
335
|
: undefined
|
|
318
336
|
|
|
337
|
+
const materializeCurrentFindings = async (
|
|
338
|
+
trigger: "session.idle" | "tool.execute.after",
|
|
339
|
+
failFast = false,
|
|
340
|
+
): Promise<void> => {
|
|
341
|
+
const state = getAuditState()
|
|
342
|
+
if (!state || state.sessionId.length === 0) {
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
await materializeFindings(
|
|
348
|
+
state.sessionId,
|
|
349
|
+
state.projectDir,
|
|
350
|
+
currentOpencodeSessionId.length > 0 ? currentOpencodeSessionId : undefined,
|
|
351
|
+
)
|
|
352
|
+
} catch (error) {
|
|
353
|
+
if (failFast) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
`Failed to materialize findings artifact on ${trigger} for run ${state.sessionId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
356
|
+
)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
logger.warn(
|
|
360
|
+
`Failed to materialize findings artifact on ${trigger} for run ${state.sessionId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
361
|
+
)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
319
365
|
const safeEventHook = isHookEnabled("event")
|
|
320
366
|
? safeCreateHook(
|
|
321
367
|
() => async (input: Parameters<typeof eventHook>[0]) => {
|
|
@@ -393,6 +439,10 @@ export function createHooks(args: {
|
|
|
393
439
|
result: output.output,
|
|
394
440
|
})
|
|
395
441
|
|
|
442
|
+
if (input.tool === "argus_generate_report") {
|
|
443
|
+
await materializeCurrentFindings("tool.execute.after", true)
|
|
444
|
+
}
|
|
445
|
+
|
|
396
446
|
const outputWithHint = recoveryHint ? `${output.output}${recoveryHint}` : output.output
|
|
397
447
|
output.output = outputTruncator(outputWithHint)
|
|
398
448
|
}
|
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"
|
|
@@ -25,7 +26,7 @@ export async function materializeFindings(
|
|
|
25
26
|
sessionId?: string,
|
|
26
27
|
): Promise<FindingsArtifact> {
|
|
27
28
|
const events = await readEvents(runId, projectDir)
|
|
28
|
-
const findings = projectFindings(events)
|
|
29
|
+
const findings = dedupeFindingsForFinalOutput(projectFindings(events))
|
|
29
30
|
const toolsExecuted = projectToolExecutions(events)
|
|
30
31
|
const contentHash = stableHash(JSON.stringify(findings))
|
|
31
32
|
const generatedAt = events.at(-1)?.timestamp ?? 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
|
|
package/src/hooks/event-hook.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { EventSink } from "../features/persistent-state/event-sink"
|
|
|
2
2
|
import type { FinalizationResult } from "../features/persistent-state/run-finalizer"
|
|
3
3
|
import { finalizeRun } from "../features/persistent-state/run-finalizer"
|
|
4
4
|
import { createLogger } from "../shared/logger"
|
|
5
|
+
import { ARGUS_PLUGIN_VERSION } from "../shared/plugin-metadata"
|
|
5
6
|
import { createAuditState } from "../state/audit-state"
|
|
6
7
|
import type { AuditEvent } from "../state/schemas"
|
|
7
8
|
import { SCHEMA_VERSION } from "../state/schemas"
|
|
@@ -140,6 +141,7 @@ export function createEventHook(
|
|
|
140
141
|
await emitToSink("session.created", currentAuditState.sessionId, sessionId, {
|
|
141
142
|
projectDir: currentAuditState.projectDir,
|
|
142
143
|
sessionId: currentAuditState.sessionId,
|
|
144
|
+
plugin_version: ARGUS_PLUGIN_VERSION,
|
|
143
145
|
})
|
|
144
146
|
}
|
|
145
147
|
break
|
|
@@ -160,6 +162,7 @@ export function createEventHook(
|
|
|
160
162
|
if (preDeleteState) {
|
|
161
163
|
await emitToSink("session.deleted", preDeleteState.sessionId, sessionId, {
|
|
162
164
|
archived: true,
|
|
165
|
+
plugin_version: ARGUS_PLUGIN_VERSION,
|
|
163
166
|
})
|
|
164
167
|
|
|
165
168
|
if (eventSink) {
|
|
@@ -12,7 +12,6 @@ const TOOL_SHORT_NAMES: Record<string, string> = {
|
|
|
12
12
|
}
|
|
13
13
|
const KEY_TOOLS = ["slither", "forge-test", "patterns", "solodit", "analyzer"]
|
|
14
14
|
|
|
15
|
-
|
|
16
15
|
/** Maps unavailable-tool short names to their KEY_TOOLS counterpart */
|
|
17
16
|
const UNAVAILABLE_TO_KEY_TOOL: Record<string, string> = {
|
|
18
17
|
slither: "slither",
|
|
@@ -77,12 +76,8 @@ export function buildDynamicContext(
|
|
|
77
76
|
(t) => `${t}=${executedToolNames.has(t) ? "done" : "pending"}`,
|
|
78
77
|
).join(" ")
|
|
79
78
|
const unavailable = auditState.unavailableTools ?? []
|
|
80
|
-
const excusedTools = new Set(
|
|
81
|
-
|
|
82
|
-
)
|
|
83
|
-
const pendingKeyTools = KEY_TOOLS.filter(
|
|
84
|
-
(t) => !executedToolNames.has(t) && !excusedTools.has(t),
|
|
85
|
-
)
|
|
79
|
+
const excusedTools = new Set(unavailable.map((t) => UNAVAILABLE_TO_KEY_TOOL[t]).filter(Boolean))
|
|
80
|
+
const pendingKeyTools = KEY_TOOLS.filter((t) => !executedToolNames.has(t) && !excusedTools.has(t))
|
|
86
81
|
const gateStatus =
|
|
87
82
|
pendingKeyTools.length > 0
|
|
88
83
|
? `REPORTING GATE: BLOCKED \u2014 key tools pending: ${pendingKeyTools.join(", ")}`
|