solidity-argus 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solidity-argus",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
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",
@@ -8,7 +8,7 @@ Your core responsibilities are:
8
8
  1. **Aggregation**: Collecting findings from various tools and subagents.
9
9
  2. **Deduplication**: Merging similar findings (e.g., multiple Slither warnings for the same issue).
10
10
  3. **Contextualization**: Explaining *why* a finding matters in the context of the specific protocol.
11
- 4. **Report Generation**: Producing the final Markdown artifact using \`argus_generate_report\`.
11
+ 4. **Report Generation**: Producing the final Markdown artifact and writing it to disk.
12
12
 
13
13
  ## REPORT STRUCTURE
14
14
 
@@ -41,34 +41,13 @@ You must adhere to these strict writing standards:
41
41
 
42
42
  ## HOW TO GENERATE THE REPORT
43
43
 
44
- You have two approaches. Use whichever fits the input you receive from Argus.
45
-
46
- ### Approach 1: Use \`argus_generate_report\` tool
47
- If you have structured findings data, call the tool:
48
- - \`project_name\` (string): The name of the protocol or project.
49
- - \`scope\` (string[]): List of files or contracts that were audited.
50
- - \`include_executive_summary\` (boolean): Default \`true\`.
51
- - \`severity_threshold\` (string): "critical", "high", "medium", "low", or "informational". Usually "low" or "informational" to include everything.
52
- - \`audit_state\` (string): JSON string of findings. Format each finding as: \`{"id":"f1","check":"name","severity":"High","confidence":"High","description":"...","file":"Contract.sol","lines":[1,10],"source":"manual"}\`
53
-
54
- ### Approach 2: Write the report directly as Markdown
55
- If Argus passes findings in natural language (which is common), write the full report yourself in Markdown following the Report Structure below. This is often faster and produces better results than trying to serialize findings into JSON for the tool.
56
-
57
- **Choose Approach 2 when**: Argus gives you a natural language list of findings, descriptions, and context. Just write the report.
58
- **Choose Approach 1 when**: You have structured JSON finding data ready to pass.
59
-
60
- ## FILE PERSISTENCE
61
-
62
- **Critical Operational Block**: You must ALWAYS use the \`argus_generate_report\` tool to write the audit report to disk. This tool now automatically writes the report to the filesystem via \`Bun.write()\` and returns the file path in its result.
44
+ Argus passes you findings in natural language. Write the full report yourself in Markdown following the Report Structure above.
63
45
 
64
46
  **Your workflow**:
65
- 1. Prepare your findings data (either structured JSON or natural language context).
66
- 2. Call \`argus_generate_report\` with the appropriate parameters.
67
- 3. After the tool returns, extract the \`filePath\` field from the result.
68
- 4. **Always confirm the file path in your response to Argus**: "Report written to: {filePath}".
69
- 5. If the result does not include a \`filePath\` field, warn Argus: "Warning: filePath missing from tool result. The report may not have been written to disk."
70
-
71
- This ensures the audit report is persisted and Argus can verify the output location.
47
+ 1. Read the findings Argus provides. Deduplicate, cross-reference, and assess severity.
48
+ 2. Write the complete report in Markdown following the Report Structure and Output Format sections.
49
+ 3. Save the report to disk using the \`write\` tool. Path: \`.opencode/reports/{ProjectName}-audit-{YYYY-MM-DD}.md\` relative to the project root.
50
+ 4. Confirm the file path in your response to Argus: "Report written to: {filePath}".
72
51
 
73
52
  ## QUALITY STANDARDS
74
53
 
@@ -176,11 +176,16 @@ export function createHooks(args: {
176
176
  }
177
177
 
178
178
  if (type === "session.deleted") {
179
+ await debouncedSave.flush()
180
+ if (auditState) {
181
+ await auditStateManager.save(auditState)
182
+ }
183
+ await auditStateManager.archive()
184
+
179
185
  if (sessionId) {
180
186
  agentTracker.clearSession(sessionId)
181
187
  }
182
188
 
183
- await auditStateManager.archive()
184
189
  runJournal.log({
185
190
  type: "session.deleted",
186
191
  timestamp: Date.now(),
@@ -84,6 +84,118 @@ function emptyAuditState(findings: Finding[] = []): AuditState {
84
84
  }
85
85
  }
86
86
 
87
+ /**
88
+ * Parse a location string like "File.sol:18-22" or "File.sol:18" into { file, lines }.
89
+ * Returns undefined if the string doesn't match a recognized format.
90
+ */
91
+ export function parseLocationString(
92
+ location: string,
93
+ ): { file: string; lines: [number, number] } | undefined {
94
+ // "File.sol:18-22" or "File.sol:L18-L22"
95
+ const rangeMatch = location.match(/^(.+?):L?(\d+)\s*-\s*L?(\d+)$/)
96
+ if (rangeMatch) {
97
+ const file = rangeMatch.at(1)
98
+ const start = rangeMatch.at(2)
99
+ const end = rangeMatch.at(3)
100
+ if (file && start && end) {
101
+ return { file, lines: [Number(start), Number(end)] }
102
+ }
103
+ }
104
+ // "File.sol:18"
105
+ const singleMatch = location.match(/^(.+?):L?(\d+)$/)
106
+ if (singleMatch) {
107
+ const file = singleMatch.at(1)
108
+ const lineNum = singleMatch.at(2)
109
+ if (file && lineNum) {
110
+ const n = Number(lineNum)
111
+ return { file, lines: [n, n] }
112
+ }
113
+ }
114
+ return undefined
115
+ }
116
+
117
+ /**
118
+ * Normalize a raw finding object from agent output into the canonical field format.
119
+ * Handles common aliases:
120
+ * - title/name → check
121
+ * - location (string) → file + lines
122
+ * - case-insensitive severity → capitalized
123
+ */
124
+ export function normalizeRawFinding(raw: Record<string, unknown>): Record<string, unknown> {
125
+ const result = { ...raw }
126
+
127
+ // check: accept title, name as aliases
128
+ if (typeof result.check !== "string" || (result.check as string).length === 0) {
129
+ const alias = result.title ?? result.name
130
+ if (typeof alias === "string" && alias.length > 0) {
131
+ result.check = alias
132
+ }
133
+ }
134
+
135
+ // file + lines: accept location string as alias
136
+ if (typeof result.file !== "string" && typeof result.location === "string") {
137
+ const parsed = parseLocationString(result.location as string)
138
+ if (parsed) {
139
+ result.file = parsed.file
140
+ if (!Array.isArray(result.lines) || (result.lines as unknown[]).length !== 2) {
141
+ result.lines = parsed.lines
142
+ }
143
+ }
144
+ }
145
+
146
+ // lines: accept [start] as [start, start], accept line_start/line_end
147
+ if (!Array.isArray(result.lines) || (result.lines as unknown[]).length !== 2) {
148
+ if (Array.isArray(result.lines) && (result.lines as unknown[]).length === 1) {
149
+ const n = Number((result.lines as unknown[])[0])
150
+ if (!Number.isNaN(n)) {
151
+ result.lines = [n, n]
152
+ }
153
+ } else if (typeof result.line_start === "number" && typeof result.line_end === "number") {
154
+ result.lines = [result.line_start, result.line_end]
155
+ } else if (typeof result.line === "number") {
156
+ result.lines = [result.line, result.line]
157
+ }
158
+ }
159
+
160
+ // severity: case-insensitive normalization
161
+ if (typeof result.severity === "string") {
162
+ const lower = (result.severity as string).toLowerCase()
163
+ const SEVERITY_MAP: Record<string, string> = {
164
+ critical: "Critical",
165
+ high: "High",
166
+ medium: "Medium",
167
+ low: "Low",
168
+ informational: "Informational",
169
+ info: "Informational",
170
+ }
171
+ const mapped = SEVERITY_MAP[lower]
172
+ if (mapped) {
173
+ result.severity = mapped
174
+ }
175
+ }
176
+
177
+ // confidence: case-insensitive normalization
178
+ if (typeof result.confidence === "string") {
179
+ const lower = (result.confidence as string).toLowerCase()
180
+ const CONFIDENCE_MAP: Record<string, string> = {
181
+ high: "High",
182
+ medium: "Medium",
183
+ low: "Low",
184
+ }
185
+ const mapped = CONFIDENCE_MAP[lower]
186
+ if (mapped) {
187
+ result.confidence = mapped
188
+ }
189
+ }
190
+
191
+ // description: fall back to check if missing
192
+ if (typeof result.description !== "string" && typeof result.check === "string") {
193
+ result.description = result.check
194
+ }
195
+
196
+ return result
197
+ }
198
+
87
199
  function hasMinimumFindingFields(
88
200
  f: unknown,
89
201
  ): f is { check: string; file: string; lines: [number, number] } {
@@ -153,10 +265,22 @@ export function parseAuditState(auditState: string): AuditState {
153
265
  )
154
266
  }
155
267
 
268
+ const logger = createLogger()
269
+
156
270
  if (Array.isArray(parsed)) {
157
- const validFindings = (parsed as unknown[])
271
+ const rawItems = parsed as unknown[]
272
+ const normalized = rawItems
273
+ .filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
274
+ .map((item) => normalizeRawFinding(item))
275
+ const validFindings = normalized
158
276
  .filter(hasMinimumFindingFields)
159
277
  .map((f) => normalizeFinding(f as Record<string, unknown>))
278
+ const dropped = rawItems.length - validFindings.length
279
+ if (dropped > 0) {
280
+ logger.warn(
281
+ `parseAuditState: ${dropped}/${rawItems.length} findings dropped (missing required fields after normalization)`,
282
+ )
283
+ }
160
284
  return emptyAuditState(validFindings)
161
285
  }
162
286
 
@@ -166,9 +290,19 @@ export function parseAuditState(auditState: string): AuditState {
166
290
  Array.isArray((parsed as AuditState).findings)
167
291
  ) {
168
292
  const state = parsed as AuditState
169
- const validFindings = state.findings
293
+ const rawFindings = state.findings as unknown[]
294
+ const normalized = rawFindings
295
+ .filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
296
+ .map((item) => normalizeRawFinding(item))
297
+ const validFindings = normalized
170
298
  .filter(hasMinimumFindingFields)
171
- .map((f) => normalizeFinding(f as unknown as Record<string, unknown>))
299
+ .map((f) => normalizeFinding(f as Record<string, unknown>))
300
+ const dropped = rawFindings.length - validFindings.length
301
+ if (dropped > 0) {
302
+ logger.warn(
303
+ `parseAuditState: ${dropped}/${rawFindings.length} findings dropped (missing required fields after normalization)`,
304
+ )
305
+ }
172
306
  return {
173
307
  ...emptyAuditState(),
174
308
  ...state,