solidity-argus 0.3.4 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agents/argus-prompt.ts +46 -0
- package/src/agents/scribe-prompt.ts +6 -2
- package/src/cli/commands/init.ts +1 -1
- package/src/config/schema.ts +2 -2
- package/src/create-hooks.ts +45 -12
- package/src/features/audit-enforcer/audit-enforcer.ts +31 -2
- package/src/features/persistent-state/audit-state-manager.ts +28 -6
- package/src/features/persistent-state/event-sink.ts +10 -6
- package/src/features/persistent-state/findings-materializer.ts +51 -0
- package/src/features/persistent-state/index.ts +1 -1
- package/src/features/persistent-state/run-finalizer.ts +24 -7
- package/src/features/persistent-state/run-journal.ts +15 -4
- package/src/hooks/event-hook.ts +1 -1
- package/src/hooks/system-prompt-hook.ts +20 -0
- package/src/shared/audit-artifact-resolver.ts +13 -12
- package/src/shared/file-utils.ts +7 -2
- package/src/shared/index.ts +8 -8
- package/src/shared/path-root-resolver.ts +34 -0
- package/src/shared/report-path-resolver.ts +3 -3
- package/src/state/schemas.ts +97 -0
- package/src/state/types.ts +6 -0
- package/src/tools/report-generator-tool.ts +78 -5
- package/src/tools/report-preflight.ts +79 -0
- package/src/tools/solodit-search-tool.ts +6 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "solidity-argus",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "Solidity smart contract security auditing plugin for OpenCode — 4 specialized agents, 12 tools (11 core + optional Solodit), and a curated vulnerability knowledge base",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"solidity",
|
|
@@ -225,6 +225,49 @@ Task(subagent_type="scribe", prompt="Generate the final audit report for Project
|
|
|
225
225
|
\`\`\`
|
|
226
226
|
- Wait for both to complete before synthesizing their results.
|
|
227
227
|
|
|
228
|
+
### STATE-FIRST SYNTHESIS POLICY
|
|
229
|
+
|
|
230
|
+
**Synthesize and report from durable evidence — not transcript tails.**
|
|
231
|
+
|
|
232
|
+
When building the final report or synthesizing findings:
|
|
233
|
+
1. **Primary source**: \`toolsExecuted\` records, \`findings\` from state, and event stream data persisted via argus_* tool outputs.
|
|
234
|
+
2. **Secondary source**: Tool transcript text (use only when durable evidence is unavailable or incomplete).
|
|
235
|
+
3. **Never** synthesize findings from ephemeral background transcript retrieval alone if durable state evidence exists.
|
|
236
|
+
|
|
237
|
+
**Bounded background fan-out**: For deep audits, limit concurrent high-context background delegations to max 2 at a time. Split larger workloads into sequential waves. This prevents retrieval blind spots from simultaneous long-running tasks.
|
|
238
|
+
|
|
239
|
+
Example — correct fan-out:
|
|
240
|
+
- Wave 1: [Sentinel: slither + pattern check] + [Pythia: solodit search] (2 background tasks)
|
|
241
|
+
- Wait for both. Then Wave 2: [Sentinel: forge tests] (1 background task)
|
|
242
|
+
|
|
243
|
+
## SYNTHESIS BARRIER: MUST NOT PROCEED WITHOUT DURABLE EVIDENCE
|
|
244
|
+
|
|
245
|
+
You **must not proceed** to synthesis or report generation until required durable evidence is confirmed present:
|
|
246
|
+
- \`toolsExecuted\` records exist for all planned tools
|
|
247
|
+
- Expected findings coverage is populated in state
|
|
248
|
+
- Lifecycle invariants are satisfied (no orphaned tool starts)
|
|
249
|
+
|
|
250
|
+
### Adaptive Retrieval Budget
|
|
251
|
+
|
|
252
|
+
When waiting for background tasks, use bounded retrieval budgets by workload class:
|
|
253
|
+
|
|
254
|
+
| Class | Budget | Criteria |
|
|
255
|
+
|----------|---------|---------------------------------------------|
|
|
256
|
+
| quick | 60s | Single-tool or single-contract checks |
|
|
257
|
+
| standard | 180s | Multi-tool single-agent batches |
|
|
258
|
+
| deep | 600s | Multi-agent or synthesis-heavy runs |
|
|
259
|
+
|
|
260
|
+
Poll until the task reaches a terminal state: \`completed\`, \`error\`, \`cancelled\`, or \`interrupt\`.
|
|
261
|
+
|
|
262
|
+
### Re-dispatch (LAST RESORT)
|
|
263
|
+
|
|
264
|
+
Re-dispatch is only justified when ALL of these are true:
|
|
265
|
+
1. The task has reached terminal state OR retrieval budget has expired
|
|
266
|
+
2. Required durable evidence is STILL missing from state/events
|
|
267
|
+
3. The gap is specific and bounded (not a general "redo everything")
|
|
268
|
+
|
|
269
|
+
**When re-dispatching**: Target only missing evidence segments. Use \`run_in_background=false\` (foreground only) for re-dispatch pivots. Do NOT re-dispatch routinely after a single transcript retrieval miss if durable state evidence is already complete.
|
|
270
|
+
|
|
228
271
|
## TASK COMPLETION TRACKING
|
|
229
272
|
|
|
230
273
|
You must track which audit phases are complete to avoid redundant work and tool re-execution.
|
|
@@ -424,6 +467,8 @@ Tools may fail. You must be resilient.
|
|
|
424
467
|
|
|
425
468
|
After you have synthesized your findings, build a canonical ReportInput payload and invoke Scribe:
|
|
426
469
|
|
|
470
|
+
**State-first requirement**: Before invoking Scribe, verify that \`toolsExecuted\` in your ReportInput contains entries for each tool you ran. Do NOT proceed to report generation if required tool coverage is missing from durable state — re-run the missing tool instead. Use \`preflight_policy: "strict-fail"\` for the final report invocation.
|
|
471
|
+
|
|
427
472
|
\`\`\`
|
|
428
473
|
Task(subagent_type="scribe", prompt="Generate the final security audit report.
|
|
429
474
|
|
|
@@ -454,6 +499,7 @@ Scribe must call argus_generate_report with:
|
|
|
454
499
|
- project_name: project name
|
|
455
500
|
- scope: audited file list
|
|
456
501
|
- report_input: serialized ReportInput JSON string
|
|
502
|
+
- preflight_policy: "strict-fail" (non-negotiable for final report)
|
|
457
503
|
|
|
458
504
|
Legacy audit_state is transitional-only and deprecated.
|
|
459
505
|
|
|
@@ -44,10 +44,14 @@ 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.
|
|
47
|
+
1. Validate Argus provided a serialized ReportInput JSON string (schema_version 1.0.0) with required fields: run_id, seq, session_id, tool_call_id, source, schema_version, projectDir, findings, toolsExecuted, scope. **Execution integrity check**: \`toolsExecuted\` must be non-empty for the audit to be considered complete. If \`toolsExecuted\` is empty or missing key tool families (slither, forge, patterns), add a \`## Limitations\` section to the report noting which tool coverage is absent.
|
|
48
48
|
2. Write the complete report in Markdown following the Report Structure and Output Format sections.
|
|
49
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.
|
|
50
|
+
4. **Limitations disclosure** (MANDATORY when tools fail): If any tool was unavailable, timed out, or failed, add a \`## Limitations\` section to the report BEFORE \`## Findings\`. Use this format:
|
|
51
|
+
- \`**Tool name**: [reason \u2014 unavailable/failed/timed out]. [Impact on finding coverage if any.]\`
|
|
52
|
+
- Example: \`**argus_solodit_search**: External database was unavailable. Known-vulnerability cross-referencing was performed using local patterns only.\`
|
|
53
|
+
- Never silently omit limitations — incomplete coverage must be disclosed.
|
|
54
|
+
5. Confirm the report was generated in your response to Argus: "Report generated via argus_generate_report: {filePath}".
|
|
51
55
|
|
|
52
56
|
## SINGLE-WRITER POLICY
|
|
53
57
|
|
package/src/cli/commands/init.ts
CHANGED
|
@@ -20,7 +20,7 @@ export const initCommand: CliCommand = {
|
|
|
20
20
|
description: "Initialize Argus configuration for this project",
|
|
21
21
|
async execute(_args: string[]): Promise<number> {
|
|
22
22
|
const cwd = process.cwd()
|
|
23
|
-
const configDir = join(cwd, ".
|
|
23
|
+
const configDir = join(cwd, ".argus")
|
|
24
24
|
const configPath = join(configDir, "solidity-argus.json")
|
|
25
25
|
|
|
26
26
|
if (existsSync(configPath)) {
|
package/src/config/schema.ts
CHANGED
|
@@ -31,7 +31,7 @@ const ReportingConfigSchema = z.object({
|
|
|
31
31
|
format: z.enum(["markdown"]).default("markdown"),
|
|
32
32
|
severityThreshold: z.enum(["critical", "high", "medium", "low", "informational"]).default("low"),
|
|
33
33
|
gasAnalysis: z.boolean().default(false),
|
|
34
|
-
output_dir: z.string().default(".
|
|
34
|
+
output_dir: z.string().default(".argus/reports/"),
|
|
35
35
|
})
|
|
36
36
|
|
|
37
37
|
const SoloditConfigSchema = z.object({
|
|
@@ -74,7 +74,7 @@ export const ArgusConfigSchema = z.object({
|
|
|
74
74
|
format: "markdown",
|
|
75
75
|
severityThreshold: "low",
|
|
76
76
|
gasAnalysis: false,
|
|
77
|
-
output_dir: ".
|
|
77
|
+
output_dir: ".argus/reports/",
|
|
78
78
|
}),
|
|
79
79
|
solodit: SoloditConfigSchema.default({
|
|
80
80
|
enabled: true,
|
package/src/create-hooks.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { join } from "node:path"
|
|
2
1
|
import type { Hooks as PluginHooks } from "@opencode-ai/plugin"
|
|
3
2
|
import type { ArgusConfig } from "./config/types"
|
|
4
3
|
import { createAuditEnforcer } from "./features/audit-enforcer/audit-enforcer"
|
|
@@ -7,9 +6,12 @@ import {
|
|
|
7
6
|
createSessionRecoveryHandler,
|
|
8
7
|
createToolErrorRecoveryHandler,
|
|
9
8
|
} from "./features/error-recovery"
|
|
10
|
-
import { createDebouncedSave } from "./features/persistent-state/audit-state-manager"
|
|
11
9
|
import { getMigrationMode } from "./features/migration"
|
|
10
|
+
import { adaptLegacyFindings } from "./features/migration/migration-adapter"
|
|
11
|
+
import { computeParityMetrics, formatParityReport } from "./features/migration/parity-telemetry"
|
|
12
|
+
import { createDebouncedSave } from "./features/persistent-state/audit-state-manager"
|
|
12
13
|
import { createEventSink, type EventSink } from "./features/persistent-state/event-sink"
|
|
14
|
+
import { materializeFindings } from "./features/persistent-state/findings-materializer"
|
|
13
15
|
import { recordRun } from "./features/persistent-state/global-run-index"
|
|
14
16
|
import { createRunJournal } from "./features/persistent-state/run-journal"
|
|
15
17
|
import { createAgentTracker } from "./hooks/agent-tracker"
|
|
@@ -24,8 +26,8 @@ import { createSystemPromptHook } from "./hooks/system-prompt-hook"
|
|
|
24
26
|
import { createToolTrackingHook } from "./hooks/tool-tracking-hook"
|
|
25
27
|
import type { HookName } from "./hooks/types"
|
|
26
28
|
import type { Managers } from "./managers/types"
|
|
27
|
-
import { createLogger } from "./shared/logger"
|
|
28
29
|
import { createAuditArtifactResolver } from "./shared/audit-artifact-resolver"
|
|
30
|
+
import { createLogger } from "./shared/logger"
|
|
29
31
|
import type { AuditState } from "./state/types"
|
|
30
32
|
import { detectAuditArtifacts } from "./utils/audit-artifact-detector"
|
|
31
33
|
import { detectProject, type ProjectConfig } from "./utils/project-detector"
|
|
@@ -135,10 +137,8 @@ export function createHooks(args: {
|
|
|
135
137
|
|
|
136
138
|
const effectiveState = recoveredState ?? auditStateManager.get()
|
|
137
139
|
if (effectiveState) {
|
|
140
|
+
const resolver = createAuditArtifactResolver(effectiveState.sessionId, projectDir)
|
|
138
141
|
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
142
|
const journalFile = resolver.paths().journalFile
|
|
143
143
|
// createEventSink builds the same path internally; the resolver makes it explicit.
|
|
144
144
|
currentEventSink = createEventSink(effectiveState.sessionId, projectDir)
|
|
@@ -149,13 +149,12 @@ export function createHooks(args: {
|
|
|
149
149
|
`Failed to create event sink: ${error instanceof Error ? error.message : String(error)}`,
|
|
150
150
|
)
|
|
151
151
|
}
|
|
152
|
-
|
|
153
152
|
void recordRun({
|
|
154
153
|
runId: effectiveState.sessionId,
|
|
155
154
|
opencodeSessionId: sessionId,
|
|
156
155
|
projectDir: effectiveState.projectDir,
|
|
157
|
-
statePath:
|
|
158
|
-
journalPath:
|
|
156
|
+
statePath: resolver.paths().stateFile,
|
|
157
|
+
journalPath: resolver.paths().journalFile,
|
|
159
158
|
startedAt: effectiveState.startTime,
|
|
160
159
|
phase: effectiveState.currentPhase,
|
|
161
160
|
findingsCount: effectiveState.findings.length,
|
|
@@ -188,17 +187,36 @@ export function createHooks(args: {
|
|
|
188
187
|
toolsExecutedCount: auditState.toolsExecuted.length,
|
|
189
188
|
})
|
|
190
189
|
|
|
190
|
+
const idleResolver = createAuditArtifactResolver(
|
|
191
|
+
auditState.sessionId,
|
|
192
|
+
auditState.projectDir,
|
|
193
|
+
)
|
|
191
194
|
void recordRun({
|
|
192
195
|
runId: auditState.sessionId,
|
|
193
196
|
opencodeSessionId: sessionId,
|
|
194
197
|
projectDir: auditState.projectDir,
|
|
195
|
-
statePath:
|
|
196
|
-
journalPath:
|
|
198
|
+
statePath: idleResolver.paths().stateFile,
|
|
199
|
+
journalPath: idleResolver.paths().journalFile,
|
|
197
200
|
startedAt: auditState.startTime,
|
|
198
201
|
phase: auditState.currentPhase,
|
|
199
202
|
findingsCount: auditState.findings.length,
|
|
200
203
|
})
|
|
201
204
|
|
|
205
|
+
if (migrationMode !== "legacy") {
|
|
206
|
+
try {
|
|
207
|
+
const { legacyFindings, canonicalFindings } = adaptLegacyFindings(
|
|
208
|
+
auditState,
|
|
209
|
+
migrationMode,
|
|
210
|
+
auditState.sessionId,
|
|
211
|
+
)
|
|
212
|
+
const parityMetrics = computeParityMetrics(legacyFindings, canonicalFindings)
|
|
213
|
+
logger.debug(formatParityReport(parityMetrics))
|
|
214
|
+
} catch (error) {
|
|
215
|
+
logger.warn(
|
|
216
|
+
`Migration parity check failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
202
220
|
return
|
|
203
221
|
}
|
|
204
222
|
|
|
@@ -302,11 +320,26 @@ export function createHooks(args: {
|
|
|
302
320
|
? safeCreateHook(
|
|
303
321
|
() => async (input: Parameters<typeof eventHook>[0]) => {
|
|
304
322
|
const isSessionDeleted = input.event.type === "session.deleted"
|
|
323
|
+
const finalizationBeforeDelete = isSessionDeleted ? getLastFinalizationResult() : null
|
|
305
324
|
|
|
306
325
|
try {
|
|
307
326
|
await eventHook(input)
|
|
308
327
|
} finally {
|
|
309
328
|
if (isSessionDeleted) {
|
|
329
|
+
const finalizationResult = getLastFinalizationResult()
|
|
330
|
+
const hasNewFinalization =
|
|
331
|
+
finalizationResult !== null && finalizationResult !== finalizationBeforeDelete
|
|
332
|
+
|
|
333
|
+
if (hasNewFinalization && finalizationResult.runId.length > 0) {
|
|
334
|
+
try {
|
|
335
|
+
await materializeFindings(finalizationResult.runId, projectDir)
|
|
336
|
+
} catch (error) {
|
|
337
|
+
logger.warn(
|
|
338
|
+
`Failed to materialize findings artifact for run ${finalizationResult.runId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
310
343
|
await auditStateManager.archive()
|
|
311
344
|
|
|
312
345
|
const deletedSessionId = input.event.sessionId
|
|
@@ -318,7 +351,7 @@ export function createHooks(args: {
|
|
|
318
351
|
type: "session.deleted",
|
|
319
352
|
timestamp: Date.now(),
|
|
320
353
|
archived: true,
|
|
321
|
-
finalizationPassed:
|
|
354
|
+
finalizationPassed: finalizationResult?.invariantsPassed ?? null,
|
|
322
355
|
})
|
|
323
356
|
|
|
324
357
|
currentEventSink = null
|
|
@@ -11,6 +11,24 @@ const PHASE_ORDER: AuditPhase[] = [
|
|
|
11
11
|
"complete",
|
|
12
12
|
]
|
|
13
13
|
|
|
14
|
+
const REPORTING_PHASES: AuditPhase[] = ["reporting", "complete"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
const KEY_TOOL_FAMILIES: Array<{ family: string; prefixes: string[] }> = [
|
|
18
|
+
{ family: "slither", prefixes: ["argus_slither_analyze", "slither"] },
|
|
19
|
+
{ family: "forge_test", prefixes: ["argus_forge_test", "forge_test"] },
|
|
20
|
+
{ family: "forge_fuzz", prefixes: ["argus_forge_fuzz", "forge_fuzz"] },
|
|
21
|
+
{ family: "forge_coverage", prefixes: ["argus_forge_coverage", "forge_coverage"] },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
function getMissingToolFamilies(auditState: AuditState): string[] {
|
|
25
|
+
const executedTools = auditState.toolsExecuted.map((t) => t.tool)
|
|
26
|
+
return KEY_TOOL_FAMILIES.filter(
|
|
27
|
+
({ prefixes }) =>
|
|
28
|
+
!executedTools.some((tool) => prefixes.some((prefix) => tool.startsWith(prefix))),
|
|
29
|
+
).map(({ family }) => family)
|
|
30
|
+
}
|
|
31
|
+
|
|
14
32
|
function getNextPhase(current: AuditPhase): AuditPhase | null {
|
|
15
33
|
const idx = PHASE_ORDER.indexOf(current)
|
|
16
34
|
if (idx === -1 || idx >= PHASE_ORDER.length - 1) return null
|
|
@@ -25,10 +43,21 @@ export function createAuditEnforcer() {
|
|
|
25
43
|
const nextPhase = getNextPhase(auditState.currentPhase)
|
|
26
44
|
if (!nextPhase) return null
|
|
27
45
|
|
|
28
|
-
|
|
46
|
+
const parts: string[] = [
|
|
29
47
|
`[Argus Audit Enforcer] Audit in progress — current phase: ${auditState.currentPhase}.`,
|
|
30
48
|
`Next phase: ${nextPhase}. Do not stop until audit is complete.`,
|
|
31
49
|
`Progress: ${auditState.findings.length} findings, ${auditState.contractsReviewed.length} contracts reviewed.`,
|
|
32
|
-
]
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
if (REPORTING_PHASES.includes(auditState.currentPhase)) {
|
|
53
|
+
const missing = getMissingToolFamilies(auditState)
|
|
54
|
+
if (missing.length > 0) {
|
|
55
|
+
parts.push(
|
|
56
|
+
`\u26a0\ufe0f Tool coverage incomplete: ${missing.join(", ")} have not been executed. Do not proceed to report generation until required tools are complete.`,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return parts.join(" ")
|
|
33
62
|
}
|
|
34
63
|
}
|
|
@@ -2,10 +2,10 @@ import { mkdir, rename } from "node:fs/promises"
|
|
|
2
2
|
import { dirname, join } from "node:path"
|
|
3
3
|
import type { AuditStateManager } from "../../managers/types"
|
|
4
4
|
import { createLogger } from "../../shared/logger"
|
|
5
|
+
import { type ArgusRootResolver, defaultRootResolver } from "../../shared/path-root-resolver"
|
|
5
6
|
import { createAuditState } from "../../state/audit-state"
|
|
6
7
|
import type { AuditState, PersistentAuditState } from "../../state/types"
|
|
7
8
|
|
|
8
|
-
const STATE_FILE_DIR = ".opencode"
|
|
9
9
|
const STATE_FILE_NAME = "argus-state.json"
|
|
10
10
|
const STATE_VERSION = "2"
|
|
11
11
|
|
|
@@ -95,14 +95,21 @@ export function createDebouncedSave(
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
export function createAuditStateManager(
|
|
98
|
+
export function createAuditStateManager(
|
|
99
|
+
projectDir: string,
|
|
100
|
+
resolver: ArgusRootResolver = defaultRootResolver,
|
|
101
|
+
): AuditStateManager {
|
|
99
102
|
const logger = createLogger()
|
|
100
|
-
|
|
103
|
+
|
|
104
|
+
const stateFilePath = join(resolver.writeRoot(projectDir), STATE_FILE_NAME)
|
|
101
105
|
let currentState: AuditState = createAuditState(projectDir).state
|
|
102
106
|
|
|
103
107
|
async function load(): Promise<AuditState | null> {
|
|
104
108
|
try {
|
|
105
|
-
const
|
|
109
|
+
const resolvedPath = resolver.resolveReadPath(projectDir, STATE_FILE_NAME)
|
|
110
|
+
const readPath = resolvedPath ?? stateFilePath
|
|
111
|
+
|
|
112
|
+
const file = Bun.file(readPath)
|
|
106
113
|
if (!(await file.exists())) {
|
|
107
114
|
return null
|
|
108
115
|
}
|
|
@@ -114,11 +121,19 @@ export function createAuditStateManager(projectDir: string): AuditStateManager {
|
|
|
114
121
|
|
|
115
122
|
const parsed: unknown = JSON.parse(content)
|
|
116
123
|
if (!isPersistentAuditState(parsed)) {
|
|
117
|
-
logger.warn("Persistent audit state is invalid, ignoring",
|
|
124
|
+
logger.warn("Persistent audit state is invalid, ignoring", readPath)
|
|
118
125
|
return null
|
|
119
126
|
}
|
|
120
127
|
|
|
121
|
-
const {
|
|
128
|
+
const {
|
|
129
|
+
savedAt: _savedAt,
|
|
130
|
+
version,
|
|
131
|
+
filePath: _filePath,
|
|
132
|
+
source_of_truth: _sourceOfTruth,
|
|
133
|
+
last_event_seq: snapshotSeq,
|
|
134
|
+
event_stream_hash: _eventStreamHash,
|
|
135
|
+
...state
|
|
136
|
+
} = parsed
|
|
122
137
|
|
|
123
138
|
if (version === "1") {
|
|
124
139
|
if (!state.soloditResults) {
|
|
@@ -129,6 +144,11 @@ export function createAuditStateManager(projectDir: string): AuditStateManager {
|
|
|
129
144
|
}
|
|
130
145
|
}
|
|
131
146
|
|
|
147
|
+
|
|
148
|
+
if (snapshotSeq !== undefined) {
|
|
149
|
+
logger.debug(`Loaded snapshot with last_event_seq=${snapshotSeq} from ${readPath}`)
|
|
150
|
+
}
|
|
151
|
+
|
|
132
152
|
currentState = state
|
|
133
153
|
return currentState
|
|
134
154
|
} catch (err) {
|
|
@@ -154,6 +174,7 @@ export function createAuditStateManager(projectDir: string): AuditStateManager {
|
|
|
154
174
|
savedAt: Date.now(),
|
|
155
175
|
version: STATE_VERSION,
|
|
156
176
|
filePath: stateFilePath,
|
|
177
|
+
source_of_truth: "events",
|
|
157
178
|
}
|
|
158
179
|
|
|
159
180
|
const tempFilePath = `${stateFilePath}.${Date.now()}.tmp`
|
|
@@ -205,6 +226,7 @@ export function createAuditStateManager(projectDir: string): AuditStateManager {
|
|
|
205
226
|
savedAt: Date.now(),
|
|
206
227
|
version: STATE_VERSION,
|
|
207
228
|
filePath: archivePath,
|
|
229
|
+
source_of_truth: "events",
|
|
208
230
|
}
|
|
209
231
|
await Bun.write(archivePath, `${JSON.stringify(persistentState, null, 2)}\n`)
|
|
210
232
|
} catch {
|
|
@@ -1,5 +1,9 @@
|
|
|
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
7
|
import type { AuditEvent, AuditEventType } from "../../state/schemas"
|
|
4
8
|
|
|
5
9
|
export type EventSinkErrorCode = "SEQUENCE_CONFLICT" | "INVALID_EVENT" | "IO_ERROR"
|
|
@@ -52,8 +56,8 @@ function createMutex() {
|
|
|
52
56
|
}
|
|
53
57
|
}
|
|
54
58
|
|
|
55
|
-
function buildJournalPath(runId: string, projectDir: string): string {
|
|
56
|
-
return join(projectDir, "
|
|
59
|
+
function buildJournalPath(runId: string, projectDir: string, resolver: ArgusRootResolver): string {
|
|
60
|
+
return join(resolver.writeRoot(projectDir), "runs", runId, "events.jsonl")
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
async function readRawContent(path: string): Promise<string> {
|
|
@@ -85,8 +89,8 @@ function parseJournalLines(content: string): AuditEvent[] {
|
|
|
85
89
|
/**
|
|
86
90
|
* Replay-safe stateless read — returns all events for a run sorted by seq.
|
|
87
91
|
*/
|
|
88
|
-
export async function readEvents(runId: string, projectDir: string): Promise<AuditEvent[]> {
|
|
89
|
-
const journalPath = buildJournalPath(runId, projectDir)
|
|
92
|
+
export async function readEvents(runId: string, projectDir: string, resolver: ArgusRootResolver = defaultRootResolver): Promise<AuditEvent[]> {
|
|
93
|
+
const journalPath = buildJournalPath(runId, projectDir, resolver)
|
|
90
94
|
const content = await readRawContent(journalPath)
|
|
91
95
|
return parseJournalLines(content)
|
|
92
96
|
}
|
|
@@ -95,8 +99,8 @@ export async function readEvents(runId: string, projectDir: string): Promise<Aud
|
|
|
95
99
|
* Append-only event sink with monotonic seq allocation, in-process mutex,
|
|
96
100
|
* and atomic temp-file-then-rename writes. Restart-safe via journal replay.
|
|
97
101
|
*/
|
|
98
|
-
export function createEventSink(runId: string, projectDir: string): EventSink {
|
|
99
|
-
const journalPath = buildJournalPath(runId, projectDir)
|
|
102
|
+
export function createEventSink(runId: string, projectDir: string, resolver: ArgusRootResolver = defaultRootResolver): EventSink {
|
|
103
|
+
const journalPath = buildJournalPath(runId, projectDir, resolver)
|
|
100
104
|
const mutex = createMutex()
|
|
101
105
|
let lastSeq = 0
|
|
102
106
|
let initialized = false
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises"
|
|
2
|
+
import { dirname } from "node:path"
|
|
3
|
+
import { createAuditArtifactResolver } from "../../shared/audit-artifact-resolver"
|
|
4
|
+
import { projectFindings, projectToolExecutions, stableHash } from "../../state/projectors"
|
|
5
|
+
import type { CanonicalFinding, CanonicalToolExecution } from "../../state/schemas"
|
|
6
|
+
import { SCHEMA_VERSION } from "../../state/schemas"
|
|
7
|
+
import { readEvents } from "./event-sink"
|
|
8
|
+
|
|
9
|
+
export interface FindingsArtifact {
|
|
10
|
+
run_id: string
|
|
11
|
+
session_id: string
|
|
12
|
+
schema_version: string
|
|
13
|
+
seq_first: number
|
|
14
|
+
seq_last: number
|
|
15
|
+
event_count: number
|
|
16
|
+
content_hash: string
|
|
17
|
+
generated_at: number
|
|
18
|
+
findings: CanonicalFinding[]
|
|
19
|
+
toolsExecuted: CanonicalToolExecution[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function materializeFindings(
|
|
23
|
+
runId: string,
|
|
24
|
+
projectDir: string,
|
|
25
|
+
sessionId?: string,
|
|
26
|
+
): Promise<FindingsArtifact> {
|
|
27
|
+
const events = await readEvents(runId, projectDir)
|
|
28
|
+
const findings = projectFindings(events)
|
|
29
|
+
const toolsExecuted = projectToolExecutions(events)
|
|
30
|
+
const contentHash = stableHash(JSON.stringify(findings))
|
|
31
|
+
const generatedAt = events.at(-1)?.timestamp ?? 0
|
|
32
|
+
|
|
33
|
+
const artifact: FindingsArtifact = {
|
|
34
|
+
run_id: runId,
|
|
35
|
+
session_id: sessionId ?? events[0]?.session_id ?? "",
|
|
36
|
+
schema_version: SCHEMA_VERSION,
|
|
37
|
+
seq_first: events[0]?.seq ?? 0,
|
|
38
|
+
seq_last: events.at(-1)?.seq ?? 0,
|
|
39
|
+
event_count: events.length,
|
|
40
|
+
content_hash: contentHash,
|
|
41
|
+
generated_at: generatedAt,
|
|
42
|
+
findings,
|
|
43
|
+
toolsExecuted,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const findingsFile = createAuditArtifactResolver(runId, projectDir).paths().findingsFile
|
|
47
|
+
await mkdir(dirname(findingsFile), { recursive: true })
|
|
48
|
+
await writeFile(findingsFile, JSON.stringify(artifact, null, 2))
|
|
49
|
+
|
|
50
|
+
return artifact
|
|
51
|
+
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { createAuditStateManager } from "./audit-state-manager"
|
|
2
|
-
export { createEventSink, readEvents, EventSinkError } from "./event-sink"
|
|
3
2
|
export type { EventSink, EventSinkErrorCode } from "./event-sink"
|
|
3
|
+
export { createEventSink, EventSinkError, readEvents } from "./event-sink"
|
|
@@ -12,18 +12,23 @@ export type FinalizationResult = {
|
|
|
12
12
|
timestamp: number
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
function hasSessionCreated(events: AuditEvent[]): boolean {
|
|
15
|
+
export function hasSessionCreated(events: AuditEvent[]): boolean {
|
|
16
16
|
return events.some((event) => event.type === "session.created")
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
function hasSessionDeleted(events: AuditEvent[]): boolean {
|
|
19
|
+
export function hasSessionDeleted(events: AuditEvent[]): boolean {
|
|
20
20
|
return events.some((event) => event.type === "session.deleted")
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
export type ToolLifecycleCheckResult = {
|
|
24
|
+
orphanedToolCallIds: string[]
|
|
25
|
+
malformedEvents: string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function collectToolLifecycleIssues(events: AuditEvent[]): ToolLifecycleCheckResult {
|
|
24
29
|
const startedCallIds = new Set<string>()
|
|
25
30
|
const completedCallIds = new Set<string>()
|
|
26
|
-
const
|
|
31
|
+
const malformedEvents: string[] = []
|
|
27
32
|
|
|
28
33
|
for (const event of events) {
|
|
29
34
|
if (event.type !== "tool.started" && event.type !== "tool.completed") {
|
|
@@ -31,7 +36,7 @@ function collectOrphanedToolStarts(events: AuditEvent[]): string[] {
|
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
if (typeof event.tool_call_id !== "string" || event.tool_call_id.length === 0) {
|
|
34
|
-
|
|
39
|
+
malformedEvents.push(`${event.type} at seq ${event.seq} missing tool_call_id`)
|
|
35
40
|
continue
|
|
36
41
|
}
|
|
37
42
|
|
|
@@ -44,13 +49,25 @@ function collectOrphanedToolStarts(events: AuditEvent[]): string[] {
|
|
|
44
49
|
}
|
|
45
50
|
}
|
|
46
51
|
|
|
52
|
+
const orphanedToolCallIds: string[] = []
|
|
47
53
|
for (const toolCallId of startedCallIds) {
|
|
48
54
|
if (!completedCallIds.has(toolCallId)) {
|
|
49
|
-
|
|
55
|
+
orphanedToolCallIds.push(toolCallId)
|
|
50
56
|
}
|
|
51
57
|
}
|
|
52
58
|
|
|
53
|
-
return
|
|
59
|
+
return {
|
|
60
|
+
orphanedToolCallIds,
|
|
61
|
+
malformedEvents,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function collectOrphanedToolStarts(events: AuditEvent[]): string[] {
|
|
66
|
+
const { orphanedToolCallIds, malformedEvents } = collectToolLifecycleIssues(events)
|
|
67
|
+
const orphanedErrors = orphanedToolCallIds.map(
|
|
68
|
+
(toolCallId) => `orphaned tool.started without matching tool.completed: ${toolCallId}`,
|
|
69
|
+
)
|
|
70
|
+
return [...malformedEvents, ...orphanedErrors]
|
|
54
71
|
}
|
|
55
72
|
|
|
56
73
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
@@ -1,10 +1,13 @@
|
|
|
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"
|
|
3
7
|
import { createLogger } from "../../shared/logger"
|
|
4
8
|
|
|
5
9
|
const logger = createLogger()
|
|
6
10
|
|
|
7
|
-
const JOURNAL_DIR = ".opencode"
|
|
8
11
|
const JOURNAL_FILE = "argus-journal.jsonl"
|
|
9
12
|
|
|
10
13
|
export type JournalEvent =
|
|
@@ -15,7 +18,12 @@ export type JournalEvent =
|
|
|
15
18
|
findingsCount: number
|
|
16
19
|
toolsExecutedCount: number
|
|
17
20
|
}
|
|
18
|
-
| {
|
|
21
|
+
| {
|
|
22
|
+
type: "session.deleted"
|
|
23
|
+
timestamp: number
|
|
24
|
+
archived: boolean
|
|
25
|
+
finalizationPassed: boolean | null
|
|
26
|
+
}
|
|
19
27
|
| {
|
|
20
28
|
type: "tool.executed"
|
|
21
29
|
tool: string
|
|
@@ -30,12 +38,15 @@ export type JournalEvent =
|
|
|
30
38
|
findingsCount: number
|
|
31
39
|
}
|
|
32
40
|
|
|
33
|
-
export function createRunJournal(
|
|
41
|
+
export function createRunJournal(
|
|
42
|
+
projectDir: string,
|
|
43
|
+
resolver: ArgusRootResolver = defaultRootResolver,
|
|
44
|
+
): {
|
|
34
45
|
log(event: JournalEvent): void
|
|
35
46
|
close(): Promise<void>
|
|
36
47
|
getPath(): string
|
|
37
48
|
} {
|
|
38
|
-
const journalPath = join(projectDir,
|
|
49
|
+
const journalPath = join(resolver.writeRoot(projectDir), JOURNAL_FILE)
|
|
39
50
|
let ensureDirPromise: Promise<void> | null = null
|
|
40
51
|
const pendingWrites = new Set<Promise<void>>()
|
|
41
52
|
|
package/src/hooks/event-hook.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { EventSink } from "../features/persistent-state/event-sink"
|
|
2
|
-
import { finalizeRun } from "../features/persistent-state/run-finalizer"
|
|
3
2
|
import type { FinalizationResult } from "../features/persistent-state/run-finalizer"
|
|
3
|
+
import { finalizeRun } from "../features/persistent-state/run-finalizer"
|
|
4
4
|
import { createLogger } from "../shared/logger"
|
|
5
5
|
import { createAuditState } from "../state/audit-state"
|
|
6
6
|
import type { AuditEvent } from "../state/schemas"
|
|
@@ -12,6 +12,14 @@ const TOOL_SHORT_NAMES: Record<string, string> = {
|
|
|
12
12
|
}
|
|
13
13
|
const KEY_TOOLS = ["slither", "forge-test", "patterns", "solodit", "analyzer"]
|
|
14
14
|
|
|
15
|
+
|
|
16
|
+
/** Maps unavailable-tool short names to their KEY_TOOLS counterpart */
|
|
17
|
+
const UNAVAILABLE_TO_KEY_TOOL: Record<string, string> = {
|
|
18
|
+
slither: "slither",
|
|
19
|
+
forge: "forge-test",
|
|
20
|
+
solodit: "solodit",
|
|
21
|
+
}
|
|
22
|
+
|
|
15
23
|
export interface SystemPromptHookDeps {
|
|
16
24
|
getAuditState: () => AuditState | null
|
|
17
25
|
getAgentForSession: (sessionID: string) => string | undefined
|
|
@@ -69,8 +77,19 @@ export function buildDynamicContext(
|
|
|
69
77
|
(t) => `${t}=${executedToolNames.has(t) ? "done" : "pending"}`,
|
|
70
78
|
).join(" ")
|
|
71
79
|
const unavailable = auditState.unavailableTools ?? []
|
|
80
|
+
const excusedTools = new Set(
|
|
81
|
+
unavailable.map((t) => UNAVAILABLE_TO_KEY_TOOL[t]).filter(Boolean),
|
|
82
|
+
)
|
|
83
|
+
const pendingKeyTools = KEY_TOOLS.filter(
|
|
84
|
+
(t) => !executedToolNames.has(t) && !excusedTools.has(t),
|
|
85
|
+
)
|
|
86
|
+
const gateStatus =
|
|
87
|
+
pendingKeyTools.length > 0
|
|
88
|
+
? `REPORTING GATE: BLOCKED \u2014 key tools pending: ${pendingKeyTools.join(", ")}`
|
|
89
|
+
: "REPORTING GATE: ALLOWED"
|
|
72
90
|
const lines: string[] = [
|
|
73
91
|
`<argus-context agent="${agent}">`,
|
|
92
|
+
gateStatus,
|
|
74
93
|
`Phase: ${auditState.currentPhase}`,
|
|
75
94
|
`Contracts: ${auditState.contractsReviewed.length} reviewed`,
|
|
76
95
|
`Findings: Critical=${severityCounts.Critical} High=${severityCounts.High} Medium=${severityCounts.Medium} Low=${severityCounts.Low} Info=${severityCounts.Informational}`,
|
|
@@ -91,6 +110,7 @@ export function buildDynamicContext(
|
|
|
91
110
|
const doneCount = KEY_TOOLS.filter((t) => executedToolNames.has(t)).length
|
|
92
111
|
summary = [
|
|
93
112
|
`<argus-context agent="${agent}">`,
|
|
113
|
+
gateStatus,
|
|
94
114
|
`Phase: ${auditState.currentPhase} | Findings: ${auditState.findings.length} | Contracts: ${auditState.contractsReviewed.length} | Tasks: ${doneCount}/${KEY_TOOLS.length} done`,
|
|
95
115
|
"</argus-context>",
|
|
96
116
|
].join("\n")
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { join } from "node:path"
|
|
2
|
+
import { defaultRootResolver } from "./path-root-resolver"
|
|
2
3
|
|
|
3
4
|
export class ArtifactResolverError extends Error {
|
|
4
5
|
constructor(message: string) {
|
|
@@ -8,19 +9,19 @@ export class ArtifactResolverError extends Error {
|
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
export interface AuditArtifactPaths {
|
|
11
|
-
/** {projectDir}/.
|
|
12
|
+
/** {projectDir}/.argus/argus-state.json */
|
|
12
13
|
stateFile: string
|
|
13
|
-
/** {projectDir}/.
|
|
14
|
+
/** {projectDir}/.argus/runs/{runId}/events.jsonl */
|
|
14
15
|
journalFile: string
|
|
15
|
-
/** {projectDir}/.
|
|
16
|
+
/** {projectDir}/.argus/runs/{runId}/findings.json */
|
|
16
17
|
findingsFile: string
|
|
17
|
-
/** {projectDir}/.
|
|
18
|
+
/** {projectDir}/.argus/reports */
|
|
18
19
|
reportDir: string
|
|
19
|
-
/** {projectDir}/.
|
|
20
|
+
/** {projectDir}/.argus/runs/{runId}/evidence */
|
|
20
21
|
evidenceDir: string
|
|
21
|
-
/** {projectDir}/.
|
|
22
|
+
/** {projectDir}/.argus/archives */
|
|
22
23
|
archiveDir: string
|
|
23
|
-
/** {projectDir}/.
|
|
24
|
+
/** {projectDir}/.argus/runs/{runId} */
|
|
24
25
|
runDir: string
|
|
25
26
|
}
|
|
26
27
|
|
|
@@ -45,16 +46,16 @@ export function createAuditArtifactResolver(
|
|
|
45
46
|
throw new ArtifactResolverError("projectDir must not be empty")
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
const
|
|
49
|
-
const runDir = join(
|
|
49
|
+
const writeRoot = defaultRootResolver.writeRoot(projectDir)
|
|
50
|
+
const runDir = join(writeRoot, "runs", runId)
|
|
50
51
|
|
|
51
52
|
const cachedPaths: AuditArtifactPaths = {
|
|
52
|
-
stateFile: join(
|
|
53
|
+
stateFile: join(writeRoot, "argus-state.json"),
|
|
53
54
|
journalFile: join(runDir, "events.jsonl"),
|
|
54
55
|
findingsFile: join(runDir, "findings.json"),
|
|
55
|
-
reportDir: join(
|
|
56
|
+
reportDir: join(writeRoot, "reports"),
|
|
56
57
|
evidenceDir: join(runDir, "evidence"),
|
|
57
|
-
archiveDir: join(
|
|
58
|
+
archiveDir: join(writeRoot, "archives"),
|
|
58
59
|
runDir,
|
|
59
60
|
}
|
|
60
61
|
|
package/src/shared/file-utils.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs"
|
|
2
2
|
import { join } from "node:path"
|
|
3
3
|
import { stripJsoncComments } from "./jsonc-parser"
|
|
4
|
+
import { defaultRootResolver } from "./path-root-resolver"
|
|
4
5
|
|
|
5
6
|
export type ConfigFormat = "json" | "jsonc" | "none"
|
|
6
7
|
|
|
@@ -10,9 +11,13 @@ export interface ConfigFileInfo {
|
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
export function detectConfigFile(basePath: string): ConfigFileInfo {
|
|
14
|
+
const rootCandidates = defaultRootResolver.readRoots(basePath).flatMap((rootPath) => [
|
|
15
|
+
{ path: join(rootPath, "solidity-argus.jsonc"), format: "jsonc" as const },
|
|
16
|
+
{ path: join(rootPath, "solidity-argus.json"), format: "json" as const },
|
|
17
|
+
])
|
|
18
|
+
|
|
13
19
|
const candidates = [
|
|
14
|
-
|
|
15
|
-
{ path: join(basePath, ".opencode", "solidity-argus.json"), format: "json" as const },
|
|
20
|
+
...rootCandidates,
|
|
16
21
|
{ path: join(basePath, "solidity-argus.jsonc"), format: "jsonc" as const },
|
|
17
22
|
{ path: join(basePath, "solidity-argus.json"), format: "json" as const },
|
|
18
23
|
]
|
package/src/shared/index.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
export {
|
|
2
|
+
ArtifactResolverError,
|
|
3
|
+
type AuditArtifactPaths,
|
|
4
|
+
type AuditArtifactResolver,
|
|
5
|
+
createAuditArtifactResolver,
|
|
6
|
+
} from "./audit-artifact-resolver"
|
|
1
7
|
export { extractContractNames, hasBinary, parseSolcVersion } from "./binary-utils"
|
|
2
8
|
export { deepMerge } from "./deep-merge"
|
|
3
9
|
export {
|
|
@@ -10,16 +16,10 @@ export { stripJsoncComments } from "./jsonc-parser"
|
|
|
10
16
|
export { createLogger, type Logger, type LoggerConfig } from "./logger"
|
|
11
17
|
export { findFoundryProjectDir, resolveProjectDir } from "./project-utils"
|
|
12
18
|
export {
|
|
13
|
-
|
|
14
|
-
type AuditArtifactPaths,
|
|
15
|
-
type AuditArtifactResolver,
|
|
16
|
-
createAuditArtifactResolver,
|
|
17
|
-
} from "./audit-artifact-resolver"
|
|
18
|
-
export {
|
|
19
|
+
formatReportDate,
|
|
19
20
|
ReportPathError,
|
|
20
21
|
type ReportPathOptions,
|
|
21
22
|
type ResolvedReportPath,
|
|
22
|
-
formatReportDate,
|
|
23
|
-
sanitizeContractName,
|
|
24
23
|
resolveReportPath,
|
|
24
|
+
sanitizeContractName,
|
|
25
25
|
} from "./report-path-resolver"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
|
|
4
|
+
export interface ArgusRootResolver {
|
|
5
|
+
writeRoot(projectDir: string): string
|
|
6
|
+
readRoots(projectDir: string): string[]
|
|
7
|
+
resolveReadPath(projectDir: string, relativePath: string): string | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
class DefaultArgusRootResolver implements ArgusRootResolver {
|
|
11
|
+
writeRoot(projectDir: string): string {
|
|
12
|
+
return join(projectDir, ".argus")
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
readRoots(projectDir: string): string[] {
|
|
16
|
+
return [this.writeRoot(projectDir), join(projectDir, ".opencode")]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
resolveReadPath(projectDir: string, relativePath: string): string | null {
|
|
20
|
+
for (const root of this.readRoots(projectDir)) {
|
|
21
|
+
const candidatePath = join(root, relativePath)
|
|
22
|
+
if (existsSync(candidatePath)) {
|
|
23
|
+
return candidatePath
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createArgusRootResolver(): ArgusRootResolver {
|
|
31
|
+
return new DefaultArgusRootResolver()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const defaultRootResolver: ArgusRootResolver = createArgusRootResolver()
|
|
@@ -30,9 +30,9 @@ export interface ResolvedReportPath {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export function formatReportDate(date: Date): string {
|
|
33
|
-
const year = date.
|
|
34
|
-
const month = String(date.
|
|
35
|
-
const day = String(date.
|
|
33
|
+
const year = date.getUTCFullYear()
|
|
34
|
+
const month = String(date.getUTCMonth() + 1).padStart(2, "0")
|
|
35
|
+
const day = String(date.getUTCDate()).padStart(2, "0")
|
|
36
36
|
return `${year}-${month}-${day}`
|
|
37
37
|
}
|
|
38
38
|
|
package/src/state/schemas.ts
CHANGED
|
@@ -260,6 +260,91 @@ export function validateCanonicalFinding(raw: unknown): ValidationResult<Canonic
|
|
|
260
260
|
return { success: true, data: raw as unknown as CanonicalFinding }
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
+
|
|
264
|
+
export function validateCanonicalToolExecution(
|
|
265
|
+
raw: unknown,
|
|
266
|
+
): ValidationResult<CanonicalToolExecution> {
|
|
267
|
+
if (!isRecord(raw)) {
|
|
268
|
+
return {
|
|
269
|
+
success: false,
|
|
270
|
+
errors: [
|
|
271
|
+
{
|
|
272
|
+
field: "$root",
|
|
273
|
+
code: "type",
|
|
274
|
+
message: "canonical tool execution must be an object",
|
|
275
|
+
},
|
|
276
|
+
],
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const errors: ValidationError[] = []
|
|
281
|
+
|
|
282
|
+
if (typeof raw.tool !== "string" || raw.tool.trim().length === 0) {
|
|
283
|
+
errors.push({
|
|
284
|
+
field: "tool",
|
|
285
|
+
code: "required",
|
|
286
|
+
message: "tool is required and must be a non-empty string",
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (typeof raw.startTime !== "number" || !Number.isInteger(raw.startTime) || raw.startTime <= 0) {
|
|
291
|
+
errors.push({
|
|
292
|
+
field: "startTime",
|
|
293
|
+
code: "invalid",
|
|
294
|
+
message: "startTime must be a positive integer",
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (raw.endTime != null && (typeof raw.endTime !== "number" || !Number.isInteger(raw.endTime))) {
|
|
299
|
+
errors.push({
|
|
300
|
+
field: "endTime",
|
|
301
|
+
code: "invalid",
|
|
302
|
+
message: "endTime must be an integer when provided",
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (typeof raw.success !== "boolean") {
|
|
307
|
+
errors.push({
|
|
308
|
+
field: "success",
|
|
309
|
+
code: "required",
|
|
310
|
+
message: "success is required and must be a boolean",
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (
|
|
315
|
+
typeof raw.findingsCount !== "number" ||
|
|
316
|
+
!Number.isInteger(raw.findingsCount) ||
|
|
317
|
+
raw.findingsCount < 0
|
|
318
|
+
) {
|
|
319
|
+
errors.push({
|
|
320
|
+
field: "findingsCount",
|
|
321
|
+
code: "invalid",
|
|
322
|
+
message: "findingsCount must be a non-negative integer",
|
|
323
|
+
})
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (typeof raw.run_id !== "string" || raw.run_id.trim().length === 0) {
|
|
327
|
+
errors.push({
|
|
328
|
+
field: "run_id",
|
|
329
|
+
code: "required",
|
|
330
|
+
message: "run_id is required and must be a non-empty string",
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (typeof raw.schema_version !== "string" || raw.schema_version.trim().length === 0) {
|
|
335
|
+
errors.push({
|
|
336
|
+
field: "schema_version",
|
|
337
|
+
code: "required",
|
|
338
|
+
message: "schema_version is required and must be a non-empty string",
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (errors.length > 0) {
|
|
343
|
+
return { success: false, errors }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return { success: true, data: raw as unknown as CanonicalToolExecution }
|
|
347
|
+
}
|
|
263
348
|
export function validateReportInput(raw: unknown): ValidationResult<ReportInput> {
|
|
264
349
|
if (!isRecord(raw)) {
|
|
265
350
|
return {
|
|
@@ -306,6 +391,18 @@ export function validateReportInput(raw: unknown): ValidationResult<ReportInput>
|
|
|
306
391
|
code: "invalid",
|
|
307
392
|
message: "toolsExecuted must be an array",
|
|
308
393
|
})
|
|
394
|
+
} else {
|
|
395
|
+
for (const [index, entry] of raw.toolsExecuted.entries()) {
|
|
396
|
+
const toolValidation = validateCanonicalToolExecution(entry)
|
|
397
|
+
if (toolValidation.success) continue
|
|
398
|
+
for (const toolError of toolValidation.errors) {
|
|
399
|
+
errors.push({
|
|
400
|
+
field: `toolsExecuted[${index}].${toolError.field}`,
|
|
401
|
+
code: toolError.code,
|
|
402
|
+
message: toolError.message,
|
|
403
|
+
})
|
|
404
|
+
}
|
|
405
|
+
}
|
|
309
406
|
}
|
|
310
407
|
|
|
311
408
|
if (raw.patternVersion != null && typeof raw.patternVersion !== "string") {
|
package/src/state/types.ts
CHANGED
|
@@ -110,4 +110,10 @@ export interface PersistentAuditState extends AuditState {
|
|
|
110
110
|
savedAt: number
|
|
111
111
|
version: string
|
|
112
112
|
filePath: string
|
|
113
|
+
/** Whether this snapshot was projected from events or loaded from a prior snapshot */
|
|
114
|
+
source_of_truth?: "events" | "snapshot"
|
|
115
|
+
/** Sequence number of the last event included in this snapshot */
|
|
116
|
+
last_event_seq?: number
|
|
117
|
+
/** Hash of the event stream for staleness detection */
|
|
118
|
+
event_stream_hash?: string
|
|
113
119
|
}
|
|
@@ -3,6 +3,7 @@ import path from "node:path"
|
|
|
3
3
|
import { type ToolContext, tool } from "@opencode-ai/plugin"
|
|
4
4
|
import { loadArgusConfig } from "../config/loader"
|
|
5
5
|
import type { ArgusConfig } from "../config/types"
|
|
6
|
+
import { readEvents } from "../features/persistent-state/event-sink"
|
|
6
7
|
import type { DropDiagnostic, DropPolicy } from "../shared/drop-diagnostics"
|
|
7
8
|
import { createDropDiagnosticsCollector } from "../shared/drop-diagnostics"
|
|
8
9
|
import { createLogger } from "../shared/logger"
|
|
@@ -12,6 +13,7 @@ import { normalizeToCanonicalFinding } from "../state/adapters"
|
|
|
12
13
|
import { stableHash } from "../state/projectors"
|
|
13
14
|
import { type ReportInput, SCHEMA_VERSION, validateReportInput } from "../state/schemas"
|
|
14
15
|
import type { AuditState, Finding, FindingSeverity } from "../state/types"
|
|
16
|
+
import { checkReportPreflight } from "./report-preflight"
|
|
15
17
|
|
|
16
18
|
type SeverityThreshold = "critical" | "high" | "medium" | "low" | "informational"
|
|
17
19
|
|
|
@@ -23,6 +25,7 @@ type ReportGeneratorArgs = {
|
|
|
23
25
|
quality_gate_policy?: QualityGatePolicy
|
|
24
26
|
report_input?: string
|
|
25
27
|
audit_state?: string
|
|
28
|
+
preflight_policy?: PreflightPolicy
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
type FindingsCount = {
|
|
@@ -46,6 +49,8 @@ export type ReportGenerationResult = {
|
|
|
46
49
|
|
|
47
50
|
type QualityGatePolicy = "warn" | "strict-fail"
|
|
48
51
|
|
|
52
|
+
type PreflightPolicy = "warn" | "strict-fail"
|
|
53
|
+
|
|
49
54
|
type ReportQualityViolation = {
|
|
50
55
|
findingId: string
|
|
51
56
|
code: string
|
|
@@ -59,6 +64,10 @@ type ReportQualityValidation = {
|
|
|
59
64
|
|
|
60
65
|
export type ReportGenerationDependencies = {
|
|
61
66
|
loadConfig?: (projectDir: string) => ArgusConfig
|
|
67
|
+
readEvents?: (
|
|
68
|
+
runId: string,
|
|
69
|
+
projectDir: string,
|
|
70
|
+
) => Promise<import("../state/schemas").AuditEvent[]>
|
|
62
71
|
}
|
|
63
72
|
|
|
64
73
|
export const SINGLE_WRITER_POLICY_VERSION = "1.0.0"
|
|
@@ -993,9 +1002,25 @@ export function buildProvenanceAppendix(
|
|
|
993
1002
|
lines.push("| Tool | Duration | Status | Findings |")
|
|
994
1003
|
lines.push("| --- | --- | --- | ---: |")
|
|
995
1004
|
for (const exec of state.toolsExecuted) {
|
|
996
|
-
const
|
|
997
|
-
const
|
|
998
|
-
|
|
1005
|
+
const toolName = typeof exec.tool === "string" && exec.tool ? exec.tool : "(unknown tool)"
|
|
1006
|
+
const hasTimes =
|
|
1007
|
+
typeof exec.startTime === "number" &&
|
|
1008
|
+
!Number.isNaN(exec.startTime) &&
|
|
1009
|
+
exec.endTime != null &&
|
|
1010
|
+
typeof exec.endTime === "number" &&
|
|
1011
|
+
!Number.isNaN(exec.endTime)
|
|
1012
|
+
const duration = hasTimes ? formatDuration(exec.endTime! - exec.startTime) : "N/A"
|
|
1013
|
+
const status =
|
|
1014
|
+
typeof exec.success === "boolean"
|
|
1015
|
+
? exec.success
|
|
1016
|
+
? "\u2705 success"
|
|
1017
|
+
: "\u274C failure"
|
|
1018
|
+
: "\u26A0 malformed"
|
|
1019
|
+
const findings =
|
|
1020
|
+
typeof exec.findingsCount === "number" && !Number.isNaN(exec.findingsCount)
|
|
1021
|
+
? exec.findingsCount
|
|
1022
|
+
: "N/A"
|
|
1023
|
+
lines.push(`| ${toolName} | ${duration} | ${status} | ${findings} |`)
|
|
999
1024
|
}
|
|
1000
1025
|
}
|
|
1001
1026
|
|
|
@@ -1008,7 +1033,8 @@ export function buildProvenanceAppendix(
|
|
|
1008
1033
|
lines.push(`- Pattern pack version: \`${state.patternVersion}\``)
|
|
1009
1034
|
}
|
|
1010
1035
|
if (syncExec) {
|
|
1011
|
-
|
|
1036
|
+
const syncTime = typeof syncExec.startTime === "number" && !Number.isNaN(syncExec.startTime) ? new Date(syncExec.startTime).toISOString() : "N/A"
|
|
1037
|
+
lines.push(`- SCVD last synced: ${syncTime}`)
|
|
1012
1038
|
}
|
|
1013
1039
|
}
|
|
1014
1040
|
|
|
@@ -1066,6 +1092,48 @@ export async function executeReportGeneration(
|
|
|
1066
1092
|
const threshold = args.severity_threshold ?? "low"
|
|
1067
1093
|
const qualityGatePolicy = args.quality_gate_policy ?? "warn"
|
|
1068
1094
|
const { reportInput, diagnostics } = parseReportInputPayload(args, context)
|
|
1095
|
+
const preflightPolicy = args.preflight_policy ?? "warn"
|
|
1096
|
+
let preflightWarningSection: string | null = null
|
|
1097
|
+
try {
|
|
1098
|
+
const readEventsFn = deps.readEvents ?? readEvents
|
|
1099
|
+
const events = await readEventsFn(reportInput.run_id, reportInput.projectDir)
|
|
1100
|
+
const preflightResult = checkReportPreflight(events)
|
|
1101
|
+
if (!preflightResult.passed) {
|
|
1102
|
+
if (preflightPolicy === "strict-fail") {
|
|
1103
|
+
const parts: string[] = []
|
|
1104
|
+
if (preflightResult.orphanedTools.length > 0)
|
|
1105
|
+
parts.push(`orphaned tools: ${preflightResult.orphanedTools.join(", ")}`)
|
|
1106
|
+
if (preflightResult.missingLifecycle.length > 0)
|
|
1107
|
+
parts.push(`missing lifecycle: ${preflightResult.missingLifecycle.join(", ")}`)
|
|
1108
|
+
if (preflightResult.missingRequiredTools.length > 0)
|
|
1109
|
+
parts.push(`missing required tools: ${preflightResult.missingRequiredTools.join(", ")}`)
|
|
1110
|
+
throw new Error(`Preflight failed (strict-fail): ${parts.join("; ")}`)
|
|
1111
|
+
}
|
|
1112
|
+
const lines: string[] = [
|
|
1113
|
+
"## \u26A0 Completeness Warning",
|
|
1114
|
+
"",
|
|
1115
|
+
"This report was generated with incomplete orchestration state.",
|
|
1116
|
+
"",
|
|
1117
|
+
]
|
|
1118
|
+
if (preflightResult.orphanedTools.length > 0)
|
|
1119
|
+
lines.push(`- Orphaned tools: ${preflightResult.orphanedTools.join(", ")}`)
|
|
1120
|
+
if (preflightResult.missingLifecycle.length > 0)
|
|
1121
|
+
lines.push(`- Missing lifecycle: ${preflightResult.missingLifecycle.join(", ")}`)
|
|
1122
|
+
if (preflightResult.missingRequiredTools.length > 0)
|
|
1123
|
+
lines.push(`- Missing required tools: ${preflightResult.missingRequiredTools.join(", ")}`)
|
|
1124
|
+
if (preflightResult.warnings.length > 0)
|
|
1125
|
+
lines.push(`- Warnings: ${preflightResult.warnings.join(", ")}`)
|
|
1126
|
+
preflightWarningSection = lines.join("\n")
|
|
1127
|
+
}
|
|
1128
|
+
} catch (err) {
|
|
1129
|
+
if (err instanceof Error && err.message.startsWith("Preflight failed (strict-fail)")) {
|
|
1130
|
+
throw err
|
|
1131
|
+
}
|
|
1132
|
+
if (preflightPolicy === "strict-fail") {
|
|
1133
|
+
throw new Error("Preflight failed: unable to read event stream for completeness check")
|
|
1134
|
+
}
|
|
1135
|
+
// warn mode: skip preflight when events cannot be read
|
|
1136
|
+
}
|
|
1069
1137
|
const state = reportInputToAuditState(reportInput)
|
|
1070
1138
|
const scope = args.scope.length > 0 ? args.scope : reportInput.scope
|
|
1071
1139
|
const findings = sortFindingsDeterministically(
|
|
@@ -1129,6 +1197,10 @@ export async function executeReportGeneration(
|
|
|
1129
1197
|
sections.push(`- ${item}`)
|
|
1130
1198
|
}
|
|
1131
1199
|
|
|
1200
|
+
if (preflightWarningSection) {
|
|
1201
|
+
sections.push(preflightWarningSection)
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1132
1204
|
sections.push(buildProvenanceAppendix(state, threshold, findings.length))
|
|
1133
1205
|
|
|
1134
1206
|
// Embed report metadata for single-writer policy enforcement
|
|
@@ -1159,7 +1231,7 @@ export async function executeReportGeneration(
|
|
|
1159
1231
|
const loadConfig = deps.loadConfig ?? loadArgusConfig
|
|
1160
1232
|
const projectDir = resolveProjectDir(context)
|
|
1161
1233
|
const config = loadConfig(projectDir)
|
|
1162
|
-
const outputDir = config.reporting?.output_dir ?? ".
|
|
1234
|
+
const outputDir = config.reporting?.output_dir ?? ".argus/reports/"
|
|
1163
1235
|
const fullPath = path.join(projectDir, outputDir, canonicalFilename)
|
|
1164
1236
|
|
|
1165
1237
|
// Single-writer policy: check for duplicate writes with same run_id
|
|
@@ -1194,6 +1266,7 @@ export const reportGeneratorTool = tool({
|
|
|
1194
1266
|
.default("low"),
|
|
1195
1267
|
report_input: tool.schema.string().optional(),
|
|
1196
1268
|
audit_state: tool.schema.string().optional(),
|
|
1269
|
+
preflight_policy: tool.schema.enum(["warn", "strict-fail"]).optional(),
|
|
1197
1270
|
},
|
|
1198
1271
|
async execute(args, context) {
|
|
1199
1272
|
const result = await executeReportGeneration(args, context)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
collectToolLifecycleIssues,
|
|
3
|
+
hasSessionCreated,
|
|
4
|
+
hasSessionDeleted,
|
|
5
|
+
} from "../features/persistent-state/run-finalizer"
|
|
6
|
+
import type { AuditEvent } from "../state/schemas"
|
|
7
|
+
|
|
8
|
+
export interface PreflightResult {
|
|
9
|
+
passed: boolean
|
|
10
|
+
orphanedTools: string[]
|
|
11
|
+
missingLifecycle: string[]
|
|
12
|
+
missingRequiredTools: string[]
|
|
13
|
+
warnings: string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PreflightOptions {
|
|
17
|
+
requiredTools?: string[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
21
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
22
|
+
return value as Record<string, unknown>
|
|
23
|
+
}
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function hasCompletedTool(events: AuditEvent[], toolName: string): boolean {
|
|
28
|
+
for (const event of events) {
|
|
29
|
+
if (event.type !== "tool.completed") {
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const payload = asRecord(event.payload)
|
|
34
|
+
if (!payload) {
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const name = payload.name
|
|
39
|
+
const tool = payload.tool
|
|
40
|
+
if (name === toolName || tool === toolName) {
|
|
41
|
+
return true
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function checkReportPreflight(
|
|
49
|
+
events: AuditEvent[],
|
|
50
|
+
options: PreflightOptions = {},
|
|
51
|
+
): PreflightResult {
|
|
52
|
+
const missingLifecycle: string[] = []
|
|
53
|
+
if (!hasSessionCreated(events)) {
|
|
54
|
+
missingLifecycle.push("session.created")
|
|
55
|
+
}
|
|
56
|
+
if (!hasSessionDeleted(events)) {
|
|
57
|
+
missingLifecycle.push("session.deleted")
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const { orphanedToolCallIds, malformedEvents } = collectToolLifecycleIssues(events)
|
|
61
|
+
|
|
62
|
+
const missingRequiredTools: string[] = []
|
|
63
|
+
for (const requiredTool of options.requiredTools ?? []) {
|
|
64
|
+
if (!hasCompletedTool(events, requiredTool)) {
|
|
65
|
+
missingRequiredTools.push(requiredTool)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
passed:
|
|
71
|
+
orphanedToolCallIds.length === 0 &&
|
|
72
|
+
missingLifecycle.length === 0 &&
|
|
73
|
+
missingRequiredTools.length === 0,
|
|
74
|
+
orphanedTools: orphanedToolCallIds,
|
|
75
|
+
missingLifecycle,
|
|
76
|
+
missingRequiredTools,
|
|
77
|
+
warnings: malformedEvents,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -258,7 +258,9 @@ export async function executeSoloditSearch(
|
|
|
258
258
|
let hadMcpError = false
|
|
259
259
|
for (const toolName of SOLODIT_MCP_TOOLS) {
|
|
260
260
|
try {
|
|
261
|
-
logger.debug(
|
|
261
|
+
logger.debug(
|
|
262
|
+
`[solodit] Trying MCP tool '${toolName}' on server '${SOLODIT_MCP_SERVER}' for query: ${query}`,
|
|
263
|
+
)
|
|
262
264
|
const response = await mcpCaller(
|
|
263
265
|
SOLODIT_MCP_SERVER,
|
|
264
266
|
toolName,
|
|
@@ -289,7 +291,9 @@ export async function executeSoloditSearch(
|
|
|
289
291
|
}
|
|
290
292
|
|
|
291
293
|
// All MCP tools failed — fall back to HTTP
|
|
292
|
-
logger.debug(
|
|
294
|
+
logger.debug(
|
|
295
|
+
`[solodit] All MCP tools failed (hadMcpError=${hadMcpError}) — falling back to HTTP for query: ${query}`,
|
|
296
|
+
)
|
|
293
297
|
return callSoloditHttp(query, limit, args.severity, port)
|
|
294
298
|
}
|
|
295
299
|
|