solidity-argus 0.5.6 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solidity-argus",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "description": "Solidity smart contract security auditing plugin for OpenCode — 5 specialized agents, 15 tools (14 core + optional Solodit), and a curated vulnerability knowledge base",
5
5
  "keywords": [
6
6
  "solidity",
@@ -124,7 +124,12 @@ This ensures Pythia always delivers research value, even when Solodit has no dir
124
124
 
125
125
  ## SKILLS SYSTEM
126
126
 
127
- OpenCode has a powerful **Skills** system that allows you to load specialized knowledge modules. The Argus knowledge base includes 75+ curated SKILL.md files, 13 YAML pattern packs, and 15 real-world exploit case studies covering $3B+ in losses.
127
+ The Argus knowledge base includes 75+ curated SKILL.md files, 13 YAML pattern packs, and 15 real-world exploit case studies covering $3B+ in losses. You load them with \`argus_skill_load\`.
128
+
129
+ **CRITICAL — use the right tool**:
130
+ - For ALL vulnerability, protocol, checklist, methodology, and case-study knowledge, use \`argus_skill_load\` with the exact skill name (e.g. \`argus_skill_load({ name: "reentrancy" })\`).
131
+ - **NEVER** call the generic OpenCode \`skill\` tool. It does not know about Argus skills like \`reentrancy\`, \`access-control\`, \`oracle-manipulation\`, etc., and will return "Skill or command not found" errors.
132
+ - If you are unsure whether a name is an Argus skill, default to \`argus_skill_load\` — it is the only correct loader for audit knowledge.
128
133
 
129
134
  **How to use**:
130
135
  - Load a relevant skill before deep research when protocol context is non-trivial.
@@ -65,10 +65,12 @@ Argus provides you with a \`run_id\`. Your job: read findings, deduplicate, enri
65
65
 
66
66
  This writes the source-of-truth JSON to disk at \`.argus/runs/{run_id}/deduped-findings.json\`.
67
67
 
68
- 5. **Generate report**: Call \`argus_generate_report\` with:
68
+ 5. **Generate report**: Call \`argus_generate_report\` with EXACTLY these arguments (and nothing else):
69
69
  - \`project_name\`: the project name
70
70
  - \`scope\`: list of audited files
71
- - \`run_id\`: the run ID (the tool reads your persisted deduped findings from disk)
71
+ - \`run_id\`: the run ID (the tool reads your persisted deduped findings from disk and resolves the canonical envelope automatically)
72
+
73
+ **DO NOT** pass \`report_input\`, \`findings\`, \`toolsExecuted\`, \`session_id\`, or any other field — the tool reads them from durable state on disk. Passing them risks contract-mismatch failures.
72
74
 
73
75
  6. **Limitations disclosure**: If any tool failed or was absent, add a \`## Limitations\` section.
74
76
 
@@ -1,57 +1,98 @@
1
- import { existsSync, readFileSync, writeFileSync } from "node:fs"
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
2
  import { homedir } from "node:os"
3
- import { join } from "node:path"
3
+ import { dirname, join } from "node:path"
4
4
  import { cliOutput } from "../cli-output"
5
+ import { confirm } from "../tui-prompts"
5
6
  import type { CliCommand } from "../types"
6
7
 
7
8
  const GREEN = "\x1b[32m"
8
9
  const YELLOW = "\x1b[33m"
9
10
  const RESET = "\x1b[0m"
10
11
 
12
+ function resolveHome(homeOverride?: string): string {
13
+ if (homeOverride && homeOverride.length > 0) return homeOverride
14
+ const envHome = process.env.HOME ?? process.env.USERPROFILE
15
+ if (envHome && envHome.length > 0) return envHome
16
+ return homedir()
17
+ }
18
+
19
+ function localConfigPath(): string {
20
+ return join(process.cwd(), "opencode.json")
21
+ }
22
+
23
+ function globalConfigPath(homeOverride?: string): string {
24
+ return join(resolveHome(homeOverride), ".config", "opencode", "opencode.json")
25
+ }
26
+
11
27
  export function findOpencodeConfig(homeOverride?: string): string | null {
12
- const cwd = process.cwd()
13
- const localPath = join(cwd, "opencode.json")
14
- if (existsSync(localPath)) return localPath
28
+ const local = localConfigPath()
29
+ if (existsSync(local)) return local
15
30
 
16
- const home = homeOverride ?? homedir()
17
- const globalPath = join(home, ".config", "opencode", "opencode.json")
18
- if (existsSync(globalPath)) return globalPath
31
+ const global = globalConfigPath(homeOverride)
32
+ if (existsSync(global)) return global
19
33
 
20
34
  return null
21
35
  }
22
36
 
37
+ function addPluginToConfig(configPath: string): { added: boolean; ok: boolean } {
38
+ try {
39
+ let config: Record<string, unknown>
40
+ if (existsSync(configPath)) {
41
+ const content = readFileSync(configPath, "utf-8")
42
+ config = JSON.parse(content)
43
+ } else {
44
+ mkdirSync(dirname(configPath), { recursive: true })
45
+ config = {}
46
+ }
47
+
48
+ const plugins = Array.isArray(config.plugin) ? (config.plugin as string[]) : []
49
+ if (plugins.includes("solidity-argus")) {
50
+ cliOutput.log(`${GREEN}✓${RESET} solidity-argus already registered in ${configPath}`)
51
+ return { added: false, ok: true }
52
+ }
53
+
54
+ plugins.push("solidity-argus")
55
+ config.plugin = plugins
56
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
57
+ cliOutput.log(`${GREEN}✓${RESET} Added solidity-argus to ${configPath}`)
58
+ return { added: true, ok: true }
59
+ } catch (_error) {
60
+ cliOutput.error(`${YELLOW}⚠${RESET} Failed to update ${configPath}`)
61
+ return { added: false, ok: false }
62
+ }
63
+ }
64
+
23
65
  export const installCommand: CliCommand = {
24
66
  name: "install",
25
- description: "Register solidity-argus in your OpenCode config",
26
- async execute(_args: string[]): Promise<number> {
27
- const configPath = findOpencodeConfig()
28
-
29
- if (!configPath) {
30
- cliOutput.error(
31
- `${YELLOW}⚠${RESET} opencode.json not found — create one first, or run: opencode init`,
32
- )
33
- return 1
34
- }
67
+ description: "Register solidity-argus in your OpenCode config (use --global for ~/.config/opencode)",
68
+ async execute(args: string[]): Promise<number> {
69
+ const isGlobal = args.includes("--global") || args.includes("-g")
70
+ const local = localConfigPath()
35
71
 
36
- try {
37
- const content = readFileSync(configPath, "utf-8")
38
- const config = JSON.parse(content)
39
- const plugins: string[] = config.plugin ?? []
72
+ if (existsSync(local) && !isGlobal) {
73
+ return addPluginToConfig(local).ok ? 0 : 1
74
+ }
40
75
 
41
- if (plugins.includes("solidity-argus")) {
42
- cliOutput.log(`${GREEN}✓${RESET} solidity-argus already registered in ${configPath}`)
43
- return 0
44
- }
76
+ if (isGlobal) {
77
+ return addPluginToConfig(globalConfigPath()).ok ? 0 : 1
78
+ }
45
79
 
46
- plugins.push("solidity-argus")
47
- config.plugin = plugins
48
- writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
80
+ const global = globalConfigPath()
81
+ cliOutput.warn(
82
+ `${YELLOW}⚠${RESET} No opencode.json found in current directory (${process.cwd()}).`,
83
+ )
84
+ cliOutput.warn(
85
+ ` Installing globally would write to ${global} and load solidity-argus in EVERY OpenCode session.`,
86
+ )
87
+ cliOutput.warn(` To install globally on purpose, re-run with: argus install --global`)
88
+ cliOutput.warn(` To install for this project, first create an opencode.json in this directory.`)
49
89
 
50
- cliOutput.log(`${GREEN}✓${RESET} Added solidity-argus to ${configPath}`)
90
+ const proceed = await confirm("Install globally anyway?", false)
91
+ if (!proceed) {
92
+ cliOutput.log("Aborted. No changes made.")
51
93
  return 0
52
- } catch (_error) {
53
- cliOutput.error(`${YELLOW}⚠${RESET} Failed to update ${configPath}`)
54
- return 1
55
94
  }
95
+
96
+ return addPluginToConfig(global).ok ? 0 : 1
56
97
  },
57
98
  }
@@ -15,6 +15,7 @@ import {
15
15
  } from "./features/persistent-state/event-sink"
16
16
  import {
17
17
  materializeFindings,
18
+ materializeFindingsForRun,
18
19
  materializeReportInput,
19
20
  } from "./features/persistent-state/findings-materializer"
20
21
  import { recordRun, updateRunStatus } from "./features/persistent-state/global-run-index"
@@ -874,34 +875,17 @@ export function createHooks(args: {
874
875
  )
875
876
  : undefined
876
877
 
877
- const materializeFindingsForRun = async (
878
+ const runMaterializeFindings = (
878
879
  runId: string,
879
880
  projectDirForRun: string,
880
881
  sessionIdForRun: string | undefined,
881
882
  trigger: "session.idle" | "session.deleted" | "tool.execute.after",
882
883
  failFast = false,
883
- ): Promise<void> => {
884
- if (!runId || runId.length === 0) {
885
- return
886
- }
887
-
888
- try {
889
- await materializeFindings(runId, projectDirForRun, sessionIdForRun, {
890
- validateSessionId: false,
891
- requireEvents: true,
892
- })
893
- } catch (error) {
894
- if (failFast) {
895
- throw new Error(
896
- `Failed to materialize findings artifact on ${trigger} for run ${runId}: ${error instanceof Error ? error.message : String(error)}`,
897
- )
898
- }
899
-
900
- logger.warn(
901
- `Failed to materialize findings artifact on ${trigger} for run ${runId}: ${error instanceof Error ? error.message : String(error)}`,
902
- )
903
- }
904
- }
884
+ ): Promise<void> =>
885
+ materializeFindingsForRun(runId, projectDirForRun, sessionIdForRun, trigger, {
886
+ failFast,
887
+ warn: (msg) => logger.warn(msg),
888
+ })
905
889
 
906
890
  const safeEventHook = isHookEnabled("event")
907
891
  ? safeCreateHook(
@@ -920,7 +904,7 @@ export function createHooks(args: {
920
904
 
921
905
  if (hasNewFinalization && finalizationResult.runId.length > 0) {
922
906
  try {
923
- await materializeFindingsForRun(
907
+ await runMaterializeFindings(
924
908
  finalizationResult.runId,
925
909
  projectDir,
926
910
  eventSessionId,
@@ -1093,12 +1077,12 @@ export function createHooks(args: {
1093
1077
  )
1094
1078
  }
1095
1079
 
1096
- await materializeFindingsForRun(
1080
+ await runMaterializeFindings(
1097
1081
  state.sessionId,
1098
1082
  state.projectDir,
1099
1083
  input.sessionID,
1100
1084
  "tool.execute.after",
1101
- true,
1085
+ false,
1102
1086
  )
1103
1087
 
1104
1088
  try {
@@ -12,6 +12,16 @@ import type { CanonicalFinding, CanonicalToolExecution, ReportInput } from "../.
12
12
  import { SCHEMA_VERSION } from "../../state/schemas"
13
13
  import { readEvents } from "./event-sink"
14
14
 
15
+ export type MaterializeFindingsTrigger =
16
+ | "session.idle"
17
+ | "session.deleted"
18
+ | "tool.execute.after"
19
+
20
+ export interface MaterializeFindingsForRunOptions {
21
+ failFast?: boolean
22
+ warn?: (message: string) => void
23
+ }
24
+
15
25
  export interface FindingsArtifact {
16
26
  run_id: string
17
27
  session_id: string
@@ -78,6 +88,30 @@ export async function materializeFindings(
78
88
  return artifact
79
89
  }
80
90
 
91
+ export async function materializeFindingsForRun(
92
+ runId: string,
93
+ projectDir: string,
94
+ sessionId: string | undefined,
95
+ trigger: MaterializeFindingsTrigger,
96
+ options: MaterializeFindingsForRunOptions = {},
97
+ ): Promise<void> {
98
+ if (!runId || runId.length === 0) return
99
+
100
+ const { failFast = false, warn } = options
101
+ try {
102
+ await materializeFindings(runId, projectDir, sessionId, {
103
+ validateSessionId: false,
104
+ requireEvents: true,
105
+ })
106
+ } catch (error) {
107
+ const message = `Failed to materialize findings artifact on ${trigger} for run ${runId}: ${error instanceof Error ? error.message : String(error)}`
108
+ if (failFast) {
109
+ throw new Error(message)
110
+ }
111
+ warn?.(message)
112
+ }
113
+ }
114
+
81
115
  export async function materializeReportInput(
82
116
  runId: string,
83
117
  projectDir: string,
@@ -348,6 +348,11 @@ function processToolResult(
348
348
  }
349
349
 
350
350
  if (config.extractOptionalFields) {
351
+ findingPayload.impact = typeof item.impact === "string" ? item.impact : undefined
352
+ findingPayload.recommendation =
353
+ typeof item.recommendation === "string" ? item.recommendation : undefined
354
+ findingPayload.proofOfConcept =
355
+ typeof item.proofOfConcept === "string" ? item.proofOfConcept : undefined
351
356
  findingPayload.remediation =
352
357
  typeof item.remediation === "string" ? item.remediation : undefined
353
358
  findingPayload.exploitReference =
@@ -16,10 +16,15 @@ type RecordFindingResponse = {
16
16
  id: string
17
17
  check: string
18
18
  severity: string
19
+ confidence: string
19
20
  file: string
20
21
  description: string
21
22
  lines: [number, number]
22
23
  source: string
24
+ reported_by_agent: string
25
+ impact?: string
26
+ recommendation?: string
27
+ proofOfConcept?: string
23
28
  }>
24
29
  schema_version: string
25
30
  note: string
@@ -178,10 +183,15 @@ export async function executeRecordFinding(
178
183
  id: f.id,
179
184
  check: f.check,
180
185
  severity: f.severity,
186
+ confidence: f.confidence,
181
187
  file: f.file,
182
188
  description: f.description,
183
189
  lines: f.lines,
184
190
  source: f.source,
191
+ reported_by_agent: f.reported_by_agent,
192
+ ...(f.impact !== undefined ? { impact: f.impact } : {}),
193
+ ...(f.recommendation !== undefined ? { recommendation: f.recommendation } : {}),
194
+ ...(f.proofOfConcept !== undefined ? { proofOfConcept: f.proofOfConcept } : {}),
185
195
  })),
186
196
  schema_version: SCHEMA_VERSION,
187
197
  note: "Findings recorded to event journal. The system assigns the canonical run_id automatically — use the run_id from <argus-context> for Scribe dispatch.",
@@ -14,13 +14,14 @@ import { resolveProjectDir } from "../shared/project-utils"
14
14
  import { resolveReportPath } from "../shared/report-path-resolver"
15
15
  import { isNonEmptyString } from "../shared/type-guards"
16
16
  import { SEVERITY_RANK } from "../shared/validation-constants"
17
+ import { normalizeToCanonicalFinding } from "../state/adapters"
17
18
  import {
18
19
  compareIssueFingerprintSets,
19
20
  dedupeFindingsForFinalOutput,
20
21
  } from "../state/finding-aggregation"
21
22
  import { projectFindings, stableHash } from "../state/projectors"
22
23
  import { type ReportInput, SCHEMA_VERSION, validateReportInput } from "../state/schemas"
23
- import type { AuditState, Finding, FindingSeverity } from "../state/types"
24
+ import type { ArgusAgentName, AuditState, Finding, FindingSeverity } from "../state/types"
24
25
  import { checkReportPreflight } from "./report-preflight"
25
26
 
26
27
  type SeverityThreshold = "critical" | "high" | "medium" | "low" | "informational"
@@ -304,6 +305,37 @@ type ParseReportInputResult = {
304
305
  diagnostics: DropDiagnostic[]
305
306
  }
306
307
 
308
+ const VALID_AGENT_VALUES = new Set<ArgusAgentName>([
309
+ "argus",
310
+ "sentinel",
311
+ "pythia",
312
+ "scribe",
313
+ "unknown",
314
+ ])
315
+
316
+ function normalizeDedupedFindings(
317
+ rawFindings: unknown[],
318
+ runId: string,
319
+ projectDir: string,
320
+ dedupedBy: string,
321
+ ): Record<string, unknown>[] {
322
+ const reportedByAgent: ArgusAgentName = VALID_AGENT_VALUES.has(dedupedBy as ArgusAgentName)
323
+ ? (dedupedBy as ArgusAgentName)
324
+ : "scribe"
325
+ return rawFindings.map((raw, index) => {
326
+ const input = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {}
327
+ const normalized = normalizeRawFinding(input)
328
+ const result = normalizeToCanonicalFinding(
329
+ normalized,
330
+ runId,
331
+ index + 1,
332
+ { reportedByAgent },
333
+ projectDir,
334
+ )
335
+ return result.data as unknown as Record<string, unknown>
336
+ })
337
+ }
338
+
307
339
  function diagnosticsSummary(diagnostics: DropDiagnostic[]): string {
308
340
  return diagnostics.map((diag) => `${diag.reason.code}:${diag.reason.message}`).join("; ")
309
341
  }
@@ -576,6 +608,7 @@ function parseReportInputPayload(
576
608
  try {
577
609
  const dedupedArtifact = JSON.parse(readFileSync(dedupedFile, "utf-8")) as {
578
610
  findings?: unknown[]
611
+ deduped_by?: string
579
612
  }
580
613
  if (Array.isArray(dedupedArtifact.findings) && dedupedArtifact.findings.length > 0) {
581
614
  const reportInputFile = resolver.paths().reportInputFile
@@ -590,15 +623,64 @@ function parseReportInputPayload(
590
623
  /* use empty base */
591
624
  }
592
625
  }
593
- const merged = {
626
+ const normalizedFindings = normalizeDedupedFindings(
627
+ dedupedArtifact.findings,
628
+ effectiveRunId,
629
+ projectDir,
630
+ typeof dedupedArtifact.deduped_by === "string"
631
+ ? dedupedArtifact.deduped_by
632
+ : "scribe",
633
+ )
634
+ const merged: Record<string, unknown> = {
594
635
  ...baseInput,
595
636
  run_id: effectiveRunId,
596
- findings: dedupedArtifact.findings,
637
+ findings: normalizedFindings,
638
+ }
639
+ normalizeToolsExecutedDefaults(merged, effectiveRunId, diagnostics)
640
+ if (typeof merged.seq !== "number" || (merged.seq as number) < 0) {
641
+ merged.seq = 0
642
+ }
643
+ if (typeof merged.session_id !== "string" || (merged.session_id as string).length === 0) {
644
+ merged.session_id = "unknown"
645
+ }
646
+ if (
647
+ typeof merged.tool_call_id !== "string" ||
648
+ (merged.tool_call_id as string).length === 0
649
+ ) {
650
+ merged.tool_call_id = `deduped:${effectiveRunId}`
651
+ }
652
+ if (typeof merged.source !== "string" || (merged.source as string).length === 0) {
653
+ merged.source = "deduped-findings"
654
+ }
655
+ if (
656
+ typeof merged.schema_version !== "string" ||
657
+ merged.schema_version !== SCHEMA_VERSION
658
+ ) {
659
+ merged.schema_version = SCHEMA_VERSION
660
+ }
661
+ if (
662
+ typeof merged.projectDir !== "string" ||
663
+ (merged.projectDir as string).length === 0
664
+ ) {
665
+ merged.projectDir = projectDir
666
+ }
667
+ if (!Array.isArray(merged.scope)) {
668
+ merged.scope = []
669
+ }
670
+ if (!Array.isArray(merged.toolsExecuted)) {
671
+ merged.toolsExecuted = []
597
672
  }
598
673
  const validation = validateReportInput(merged)
599
674
  if (validation.success) {
600
675
  return finalizeReportInputSelection(validation.data, diagnostics, expectedRunId)
601
676
  }
677
+ for (const error of validation.errors) {
678
+ diagnostics.warn(
679
+ "REPORT_INPUT_DEDUPED_VALIDATION_FAILED",
680
+ `${error.field}: ${error.message}`,
681
+ error.field,
682
+ )
683
+ }
602
684
  }
603
685
  } catch {
604
686
  /* deduped file unreadable — fall through to report-input.json */