solidity-argus 0.3.6 → 0.5.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.
Files changed (107) hide show
  1. package/AGENTS.md +13 -6
  2. package/README.md +24 -12
  3. package/package.json +7 -3
  4. package/skills/checklists/cyfrin-best-practices-runtime/SKILL.md +1 -0
  5. package/skills/checklists/cyfrin-best-practices-upgrades/SKILL.md +1 -0
  6. package/skills/checklists/cyfrin-defi-core/SKILL.md +1 -0
  7. package/skills/checklists/cyfrin-defi-integrations/SKILL.md +1 -0
  8. package/skills/checklists/cyfrin-gas/SKILL.md +1 -0
  9. package/skills/checklists/general-audit/SKILL.md +1 -0
  10. package/skills/methodology/audit-workflow/SKILL.md +1 -0
  11. package/skills/methodology/report-template/SKILL.md +1 -0
  12. package/skills/methodology/severity-classification/SKILL.md +1 -0
  13. package/skills/protocol-patterns/amm-dex/SKILL.md +1 -0
  14. package/skills/protocol-patterns/bridges-cross-chain/SKILL.md +1 -0
  15. package/skills/protocol-patterns/dao-governance/SKILL.md +1 -0
  16. package/skills/protocol-patterns/lending-borrowing/SKILL.md +1 -0
  17. package/skills/protocol-patterns/staking-vesting/SKILL.md +1 -0
  18. package/skills/vulnerability-patterns/flash-loan-attacks/SKILL.md +0 -50
  19. package/skills/vulnerability-patterns/oracle-manipulation/SKILL.md +0 -63
  20. package/src/agents/argus-prompt.ts +98 -33
  21. package/src/agents/pythia-prompt.ts +18 -1
  22. package/src/agents/scribe-prompt.ts +32 -10
  23. package/src/agents/sentinel-prompt.ts +19 -0
  24. package/src/agents/themis-prompt.ts +110 -0
  25. package/src/cli/commands/doctor.ts +29 -17
  26. package/src/config/loader.ts +29 -5
  27. package/src/config/schema.ts +45 -45
  28. package/src/constants/defaults.ts +1 -0
  29. package/src/create-hooks.ts +851 -142
  30. package/src/create-managers.ts +4 -2
  31. package/src/create-tools.ts +5 -1
  32. package/src/features/audit-enforcer/audit-enforcer.ts +1 -11
  33. package/src/features/background-agent/background-manager.ts +32 -5
  34. package/src/features/error-recovery/tool-error-recovery.ts +1 -0
  35. package/src/features/persistent-state/audit-state-manager.ts +272 -29
  36. package/src/features/persistent-state/event-sink.ts +96 -25
  37. package/src/features/persistent-state/findings-materializer.ts +57 -3
  38. package/src/features/persistent-state/global-run-index.ts +86 -8
  39. package/src/features/persistent-state/index.ts +7 -1
  40. package/src/features/persistent-state/run-finalizer.ts +116 -7
  41. package/src/features/persistent-state/run-pruner.ts +93 -0
  42. package/src/hooks/agent-tracker.ts +14 -2
  43. package/src/hooks/compaction-hook.ts +7 -16
  44. package/src/hooks/config-handler.ts +83 -29
  45. package/src/hooks/context-budget.ts +4 -5
  46. package/src/hooks/event-hook.ts +213 -57
  47. package/src/hooks/knowledge-sync-hook.ts +2 -3
  48. package/src/hooks/safe-create-hook.ts +13 -1
  49. package/src/hooks/system-prompt-hook.ts +20 -39
  50. package/src/hooks/tool-tracking-hook.ts +606 -326
  51. package/src/index.ts +15 -1
  52. package/src/knowledge/scvd-client.ts +2 -4
  53. package/src/knowledge/scvd-errors.ts +25 -2
  54. package/src/knowledge/scvd-index.ts +7 -5
  55. package/src/knowledge/scvd-sync.ts +6 -6
  56. package/src/managers/types.ts +20 -2
  57. package/src/shared/agent-names.ts +23 -0
  58. package/src/shared/audit-artifact-resolver.ts +8 -3
  59. package/src/shared/audit-phases.ts +12 -0
  60. package/src/shared/cache-paths.ts +41 -0
  61. package/src/shared/drop-diagnostics.ts +2 -2
  62. package/src/shared/forge-errors.ts +31 -0
  63. package/src/shared/forge-runner.ts +30 -0
  64. package/src/shared/format-error.ts +3 -0
  65. package/src/shared/index.ts +9 -0
  66. package/src/shared/key-tools.ts +39 -0
  67. package/src/shared/logger.ts +7 -7
  68. package/src/shared/path-containment.ts +25 -0
  69. package/src/shared/path-utils.ts +11 -0
  70. package/src/shared/report-path-resolver.ts +4 -2
  71. package/src/shared/safe-emit.ts +24 -0
  72. package/src/shared/token-utils.ts +5 -0
  73. package/src/shared/type-guards.ts +8 -0
  74. package/src/shared/validation-constants.ts +52 -0
  75. package/src/skills/analysis/cluster.ts +1 -114
  76. package/src/skills/analysis/normalize.ts +2 -114
  77. package/src/skills/analysis/stopwords.ts +109 -0
  78. package/src/skills/argus-skill-resolver.ts +6 -3
  79. package/src/solodit-lifecycle.ts +153 -37
  80. package/src/state/adapters.ts +60 -66
  81. package/src/state/finding-aggregation.ts +6 -8
  82. package/src/state/finding-fingerprint.ts +1 -1
  83. package/src/state/finding-store.ts +31 -9
  84. package/src/state/index.ts +1 -1
  85. package/src/state/projectors.ts +27 -19
  86. package/src/state/schemas.ts +8 -32
  87. package/src/state/types.ts +3 -0
  88. package/src/tools/contract-analyzer-tool.ts +4 -6
  89. package/src/tools/forge-coverage-tool.ts +10 -35
  90. package/src/tools/forge-fuzz-tool.ts +21 -51
  91. package/src/tools/forge-test-tool.ts +25 -47
  92. package/src/tools/gas-analysis-tool.ts +12 -41
  93. package/src/tools/pattern-checker-tool.ts +37 -15
  94. package/src/tools/pattern-loader.ts +18 -4
  95. package/src/tools/persist-deduped-tool.ts +94 -0
  96. package/src/tools/proxy-detection-tool.ts +35 -34
  97. package/src/tools/read-findings-tool.ts +390 -0
  98. package/src/tools/record-finding-tool.ts +120 -25
  99. package/src/tools/report-generator-tool.ts +396 -328
  100. package/src/tools/report-preflight.ts +5 -1
  101. package/src/tools/slither-tool.ts +55 -16
  102. package/src/tools/solodit-search-tool.ts +260 -112
  103. package/src/tools/sync-knowledge-tool.ts +2 -3
  104. package/src/utils/solidity-parser.ts +39 -24
  105. package/src/features/migration/index.ts +0 -14
  106. package/src/features/migration/migration-adapter.ts +0 -151
  107. package/src/features/migration/parity-telemetry.ts +0 -133
package/src/index.ts CHANGED
@@ -6,14 +6,25 @@ import { createTools } from "./create-tools"
6
6
  import type { Dispatcher } from "./features/background-agent/background-manager"
7
7
  import { createHookGuard } from "./hooks/hook-system"
8
8
  import { createPluginInterface } from "./plugin-interface"
9
+ import { createLogger } from "./shared/logger"
9
10
  import { startSoloditMcp } from "./solodit-lifecycle"
11
+ import { DEFAULT_SOLODIT_PORT } from "./tools/solodit-search-tool"
12
+
13
+ const logger = createLogger()
10
14
 
11
15
  const ArgusPlugin: Plugin = async (ctx) => {
12
16
  const projectDir = ctx.directory ?? process.cwd()
13
17
  const config = loadArgusConfig(projectDir)
14
18
 
19
+ const { ARGUS_PLUGIN_VERSION } = await import("./shared/plugin-metadata")
20
+ console.error(`[argus] v${ARGUS_PLUGIN_VERSION} loaded for ${projectDir}`)
21
+
15
22
  if (config.solodit?.enabled !== false) {
16
- await startSoloditMcp(config.solodit?.port ?? 3000)
23
+ // MCP bootstrap must not block plugin load; the Solodit search tool falls
24
+ // back to direct HTTP when the local MCP is still coming up.
25
+ void startSoloditMcp(config.solodit?.port ?? DEFAULT_SOLODIT_PORT, {
26
+ waitForHealth: false,
27
+ })
17
28
  }
18
29
 
19
30
  const isHookEnabled = createHookGuard(config.disabled_hooks)
@@ -31,6 +42,9 @@ const ArgusPlugin: Plugin = async (ctx) => {
31
42
  return taskId
32
43
  }
33
44
  }
45
+ logger.warn(
46
+ `ctx.task returned unexpected shape (${typeof result}), using fabricated task ID`,
47
+ )
34
48
  return `task-${Date.now()}`
35
49
  }
36
50
  : undefined
@@ -15,11 +15,9 @@ export interface ScvdStats {
15
15
  last_updated: string
16
16
  }
17
17
 
18
- const DEFAULT_PAGE_SIZE = 100
18
+ import { isRecord } from "../shared/type-guards"
19
19
 
20
- function isRecord(value: unknown): value is Record<string, unknown> {
21
- return typeof value === "object" && value !== null
22
- }
20
+ const DEFAULT_PAGE_SIZE = 100
23
21
 
24
22
  function toStringArray(value: unknown): string[] {
25
23
  if (!Array.isArray(value)) {
@@ -1,7 +1,7 @@
1
1
  export type SyncError = {
2
2
  status: "error"
3
3
  success: false
4
- reason: "network" | "api" | "parse"
4
+ reason: "network" | "api" | "parse" | "lock"
5
5
  message: string
6
6
  error: string
7
7
  httpStatus?: number
@@ -74,6 +74,19 @@ export function createParseError(message: string): SyncError {
74
74
  }
75
75
  }
76
76
 
77
+ export function createLockError(message: string): SyncError {
78
+ return {
79
+ status: "error",
80
+ success: false,
81
+ reason: "lock",
82
+ message,
83
+ error: message,
84
+ newFindings: 0,
85
+ totalIndexed: 0,
86
+ lastSync: new Date().toISOString(),
87
+ }
88
+ }
89
+
77
90
  export function createSyncSuccess(
78
91
  data: Omit<SyncSuccess, "status" | "success" | "error"> & { attempts?: number },
79
92
  ): SyncSuccess {
@@ -84,6 +97,16 @@ export function createSyncSuccess(
84
97
  }
85
98
  }
86
99
 
100
+ const RETRYABLE_HTTP_STATUSES = new Set([429, 502, 503, 504])
101
+
87
102
  export function isRetryableError(outcome: SyncOutcome): boolean {
88
- return outcome.status === "error" && outcome.reason === "network"
103
+ if (outcome.status !== "error") return false
104
+ if (outcome.reason === "network") return true
105
+ if (
106
+ outcome.reason === "api" &&
107
+ outcome.httpStatus &&
108
+ RETRYABLE_HTTP_STATUSES.has(outcome.httpStatus)
109
+ )
110
+ return true
111
+ return false
89
112
  }
@@ -1,3 +1,4 @@
1
+ import { isRecord } from "../shared/type-guards"
1
2
  import type { ScvdFinding } from "./scvd-client"
2
3
 
3
4
  export interface ScvdIndexEntry {
@@ -127,10 +128,6 @@ export async function saveIndex(index: ScvdIndex, filePath: string): Promise<voi
127
128
  renameSync(tmpPath, filePath)
128
129
  }
129
130
 
130
- function isRecord(value: unknown): value is Record<string, unknown> {
131
- return typeof value === "object" && value !== null
132
- }
133
-
134
131
  function parseStringArray(value: unknown): string[] {
135
132
  if (!Array.isArray(value)) {
136
133
  return []
@@ -191,7 +188,12 @@ export async function loadIndex(filePath: string): Promise<ScvdIndex | null> {
191
188
  return null
192
189
  }
193
190
 
194
- const raw = (await file.json()) as unknown
191
+ let raw: unknown
192
+ try {
193
+ raw = (await file.json()) as unknown
194
+ } catch {
195
+ return null
196
+ }
195
197
 
196
198
  if (!isRecord(raw)) {
197
199
  return null
@@ -4,6 +4,7 @@ import type { ScvdClient } from "./scvd-client"
4
4
  import { ScvdApiError, ScvdNetworkError } from "./scvd-client"
5
5
  import {
6
6
  createApiError,
7
+ createLockError,
7
8
  createNetworkError,
8
9
  createParseError,
9
10
  createSyncSuccess,
@@ -39,11 +40,10 @@ function buildErrorResult(error: unknown): SyncError {
39
40
  }
40
41
 
41
42
  function shouldRetrySyncError(error: unknown): boolean {
42
- if (!(error instanceof ScvdNetworkError)) {
43
- return false
43
+ if (error instanceof ScvdNetworkError || error instanceof ScvdApiError) {
44
+ return isRetryableError(buildErrorResult(error))
44
45
  }
45
-
46
- return isRetryableError(buildErrorResult(error))
46
+ return false
47
47
  }
48
48
 
49
49
  function errorReasonFromResult(result: SyncError): string {
@@ -111,7 +111,7 @@ export async function syncAll(client: ScvdClient, indexPath: string): Promise<Sy
111
111
  const logger = createLogger()
112
112
 
113
113
  if (!acquireSyncLock()) {
114
- return createParseError("Sync already in progress")
114
+ return createLockError("Sync already in progress")
115
115
  }
116
116
 
117
117
  logger.debug("[sync] starting", "source=scvd mode=full")
@@ -144,7 +144,7 @@ export async function syncIncremental(client: ScvdClient, indexPath: string): Pr
144
144
  const logger = createLogger()
145
145
 
146
146
  if (!acquireSyncLock()) {
147
- return createParseError("Sync already in progress")
147
+ return createLockError("Sync already in progress")
148
148
  }
149
149
 
150
150
  logger.debug("[sync] starting", "source=scvd mode=incremental")
@@ -46,13 +46,25 @@ export interface BackgroundManager {
46
46
  */
47
47
  export interface AuditStateManager {
48
48
  /**
49
- * Load audit state from persistent storage
49
+ * Bind this manager to a specific OpenCode session.
50
+ * After binding, save/load operate on a session-scoped state file
51
+ * (.argus/sessions/state-{sessionId}.json) instead of the shared file.
52
+ * This prevents multi-instance contamination.
53
+ * @param sessionId - The OpenCode session ID (e.g., "ses_abc123")
54
+ */
55
+ bindSession(sessionId: string): void
56
+
57
+ /**
58
+ * Load audit state from persistent storage.
59
+ * If bound to a session, tries the session-scoped file first,
60
+ * then falls back to the most recent state file from any session.
50
61
  * @returns Promise resolving to AuditState or null if not found
51
62
  */
52
63
  load(): Promise<AuditState | null>
53
64
 
54
65
  /**
55
- * Save audit state to persistent storage
66
+ * Save audit state to persistent storage.
67
+ * Writes to the session-scoped file if bound, otherwise the shared file.
56
68
  * @param state - The AuditState to persist
57
69
  */
58
70
  save(state: AuditState): Promise<void>
@@ -78,6 +90,12 @@ export interface AuditStateManager {
78
90
  * Archive current state (if meaningful) then reset
79
91
  */
80
92
  archive(): Promise<void>
93
+
94
+ /**
95
+ * Dispose the state manager, flushing pending state to disk and releasing resources.
96
+ * Safe to call multiple times; subsequent calls are no-ops.
97
+ */
98
+ dispose(): Promise<void>
81
99
  }
82
100
 
83
101
  /**
@@ -0,0 +1,23 @@
1
+ export const ARGUS_ORCHESTRATOR: ReadonlySet<string> = new Set(["argus"])
2
+ export const ARGUS_SUBAGENTS: ReadonlySet<string> = new Set([
3
+ "sentinel",
4
+ "pythia",
5
+ "scribe",
6
+ "themis",
7
+ ])
8
+ export const ARGUS_FAMILY: ReadonlySet<string> = new Set([
9
+ ...ARGUS_ORCHESTRATOR,
10
+ ...ARGUS_SUBAGENTS,
11
+ ])
12
+
13
+ export function isArgusFamily(agent: string): boolean {
14
+ return ARGUS_FAMILY.has(agent)
15
+ }
16
+
17
+ export function isOrchestratorAgent(agent: string): boolean {
18
+ return ARGUS_ORCHESTRATOR.has(agent)
19
+ }
20
+
21
+ export function isSubagent(agent: string): boolean {
22
+ return ARGUS_SUBAGENTS.has(agent)
23
+ }
@@ -1,4 +1,4 @@
1
- import { join } from "node:path"
1
+ import { basename, join } from "node:path"
2
2
  import { defaultRootResolver } from "./path-root-resolver"
3
3
 
4
4
  export class ArtifactResolverError extends Error {
@@ -15,6 +15,9 @@ export interface AuditArtifactPaths {
15
15
  journalFile: string
16
16
  /** {projectDir}/.argus/runs/{runId}/findings.json */
17
17
  findingsFile: string
18
+ reportInputFile: string
19
+ /** {projectDir}/.argus/runs/{runId}/deduped-findings.json */
20
+ dedupedFindingsFile: string
18
21
  /** {projectDir}/.argus/reports */
19
22
  reportDir: string
20
23
  /** {projectDir}/.argus/runs/{runId}/evidence */
@@ -53,6 +56,8 @@ export function createAuditArtifactResolver(
53
56
  stateFile: join(writeRoot, "argus-state.json"),
54
57
  journalFile: join(runDir, "events.jsonl"),
55
58
  findingsFile: join(runDir, "findings.json"),
59
+ reportInputFile: join(runDir, "report-input.json"),
60
+ dedupedFindingsFile: join(runDir, "deduped-findings.json"),
56
61
  reportDir: join(writeRoot, "reports"),
57
62
  evidenceDir: join(runDir, "evidence"),
58
63
  archiveDir: join(writeRoot, "archives"),
@@ -66,10 +71,10 @@ export function createAuditArtifactResolver(
66
71
  return cachedPaths
67
72
  },
68
73
  reportFilePath(filename: string): string {
69
- return join(cachedPaths.reportDir, filename)
74
+ return join(cachedPaths.reportDir, basename(filename))
70
75
  },
71
76
  evidenceFilePath(filename: string): string {
72
- return join(cachedPaths.evidenceDir, filename)
77
+ return join(cachedPaths.evidenceDir, basename(filename))
73
78
  },
74
79
  }
75
80
  }
@@ -0,0 +1,12 @@
1
+ import type { AuditPhase } from "../state/types"
2
+
3
+ export const PHASE_ORDER: readonly AuditPhase[] = [
4
+ "reconnaissance",
5
+ "scanning",
6
+ "manual-review",
7
+ "attack-surface",
8
+ "research",
9
+ "testing",
10
+ "reporting",
11
+ "complete",
12
+ ] as const
@@ -0,0 +1,41 @@
1
+ import { homedir } from "node:os"
2
+ import { dirname, join } from "node:path"
3
+
4
+ const DEFAULT_CACHE_DIR = join(homedir(), ".cache", "solidity-argus")
5
+
6
+ function normalizeOverride(value: string | undefined): string | null {
7
+ if (!value) {
8
+ return null
9
+ }
10
+
11
+ const trimmed = value.trim()
12
+ return trimmed.length > 0 ? trimmed : null
13
+ }
14
+
15
+ export function getArgusCacheDir(): string {
16
+ return normalizeOverride(process.env.ARGUS_CACHE_DIR) ?? DEFAULT_CACHE_DIR
17
+ }
18
+
19
+ export function getArgusLogFile(): string {
20
+ return normalizeOverride(process.env.ARGUS_LOG_FILE) ?? join(getArgusCacheDir(), "argus.log")
21
+ }
22
+
23
+ export function getArgusLogDir(): string {
24
+ return dirname(getArgusLogFile())
25
+ }
26
+
27
+ export function getScvdIndexPath(): string {
28
+ return join(getArgusCacheDir(), "scvd-index.json")
29
+ }
30
+
31
+ export function getTrailOfBitsCacheDir(): string {
32
+ return join(getArgusCacheDir(), "trailofbits-skills")
33
+ }
34
+
35
+ export function getGlobalRunIndexDir(): string {
36
+ return join(getArgusCacheDir(), "runs")
37
+ }
38
+
39
+ export function getGlobalRunIndexFile(): string {
40
+ return join(getGlobalRunIndexDir(), "index.jsonl")
41
+ }
@@ -76,9 +76,9 @@ export function createDropDiagnosticsCollector(
76
76
 
77
77
  const logMsg = `[${source}${tool ? `:${tool}` : ""}] ${code}${field ? ` (field: ${field})` : ""}: ${message}`
78
78
  if (level === "error") {
79
- logger.warn(logMsg)
79
+ logger.error(logMsg)
80
80
  } else {
81
- logger.info(logMsg)
81
+ logger.warn(logMsg)
82
82
  }
83
83
  }
84
84
 
@@ -0,0 +1,31 @@
1
+ import type { ToolContext } from "@opencode-ai/plugin"
2
+
3
+ export const FOUNDRY_NOT_FOUND_MESSAGE =
4
+ "Foundry not found. Install: curl -L https://foundry.paradigm.xyz | bash"
5
+
6
+ /**
7
+ * Classify a caught error from a forge command execution into a user-facing
8
+ * error string. Returns `undefined` when the error is not a recognized
9
+ * forge-specific failure and should be handled by the caller.
10
+ */
11
+ export function classifyForgeError(
12
+ error: unknown,
13
+ context: ToolContext,
14
+ toolLabel: string,
15
+ ): string | undefined {
16
+ if (context.abort.aborted || (error instanceof DOMException && error.name === "AbortError")) {
17
+ return `${toolLabel} aborted`
18
+ }
19
+
20
+ const maybeError = error as Error & { code?: string }
21
+
22
+ if (maybeError.code === "ENOENT") {
23
+ return FOUNDRY_NOT_FOUND_MESSAGE
24
+ }
25
+
26
+ if (maybeError.code === "ETIMEDOUT" || maybeError.message?.toLowerCase().includes("timed out")) {
27
+ return `${toolLabel} timed out`
28
+ }
29
+
30
+ return undefined
31
+ }
@@ -0,0 +1,30 @@
1
+ export type ForgeCommandResult = {
2
+ stdout: string
3
+ stderr: string
4
+ exitCode: number
5
+ }
6
+
7
+ export async function runForgeCommand(
8
+ command: string[],
9
+ options: { signal?: AbortSignal; cwd?: string; env?: Record<string, string> },
10
+ ): Promise<ForgeCommandResult> {
11
+ const child = Bun.spawn(command, {
12
+ cwd: options.cwd,
13
+ stdout: "pipe",
14
+ stderr: "pipe",
15
+ signal: options.signal,
16
+ env: options.env,
17
+ })
18
+
19
+ const [exitCode, stdout, stderr] = await Promise.all([
20
+ child.exited,
21
+ new Response(child.stdout).text(),
22
+ new Response(child.stderr).text(),
23
+ ])
24
+
25
+ return {
26
+ stdout,
27
+ stderr,
28
+ exitCode,
29
+ }
30
+ }
@@ -0,0 +1,3 @@
1
+ export function formatError(error: unknown): string {
2
+ return error instanceof Error ? error.message : String(error)
3
+ }
@@ -5,6 +5,15 @@ export {
5
5
  createAuditArtifactResolver,
6
6
  } from "./audit-artifact-resolver"
7
7
  export { extractContractNames, hasBinary, parseSolcVersion } from "./binary-utils"
8
+ export {
9
+ getArgusCacheDir,
10
+ getArgusLogDir,
11
+ getArgusLogFile,
12
+ getGlobalRunIndexDir,
13
+ getGlobalRunIndexFile,
14
+ getScvdIndexPath,
15
+ getTrailOfBitsCacheDir,
16
+ } from "./cache-paths"
8
17
  export { deepMerge } from "./deep-merge"
9
18
  export {
10
19
  type ConfigFileInfo,
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Canonical list of key audit tools and mappings used by the reporting gate
3
+ * and report preflight to determine which tools must complete before report
4
+ * generation is allowed.
5
+ */
6
+
7
+ /** Maps full tool names to short names used in the reporting gate. */
8
+ export const TOOL_SHORT_NAMES: Record<string, string> = {
9
+ argus_slither_analyze: "slither",
10
+ argus_forge_test: "forge-test",
11
+ argus_check_patterns: "patterns",
12
+ argus_solodit_search: "solodit",
13
+ argus_analyze_contract: "analyzer",
14
+ }
15
+
16
+ /** The short names of tools that must complete before report generation. */
17
+ export const KEY_TOOLS = ["slither", "forge-test", "patterns", "solodit", "analyzer"]
18
+
19
+ /** Maps unavailable-tool short names to their KEY_TOOLS counterpart. */
20
+ export const UNAVAILABLE_TO_KEY_TOOL: Record<string, string> = {
21
+ slither: "slither",
22
+ forge: "forge-test",
23
+ solodit: "solodit",
24
+ }
25
+
26
+ /**
27
+ * Compute which key tools have not yet been executed, excusing any that are
28
+ * declared unavailable.
29
+ */
30
+ export function computeMissingKeyTools(
31
+ toolsExecuted: Array<{ tool: string }>,
32
+ unavailableTools?: string[],
33
+ ): string[] {
34
+ const executedShortNames = new Set(toolsExecuted.map((t) => TOOL_SHORT_NAMES[t.tool] ?? t.tool))
35
+ const excused = new Set(
36
+ (unavailableTools ?? []).map((t) => UNAVAILABLE_TO_KEY_TOOL[t]).filter(Boolean),
37
+ )
38
+ return KEY_TOOLS.filter((t) => !executedShortNames.has(t) && !excused.has(t))
39
+ }
@@ -1,6 +1,5 @@
1
1
  import { appendFileSync, existsSync, mkdirSync } from "node:fs"
2
- import { homedir } from "node:os"
3
- import { join } from "node:path"
2
+ import { getArgusLogDir, getArgusLogFile } from "./cache-paths"
4
3
 
5
4
  export interface LoggerConfig {
6
5
  debug?: boolean
@@ -15,12 +14,13 @@ export interface Logger {
15
14
 
16
15
  type LogSink = (line: string) => void
17
16
 
18
- const LOG_DIR = join(homedir(), ".cache", "solidity-argus")
19
- const LOG_FILE = join(LOG_DIR, "argus.log")
17
+ const LOG_DIR = getArgusLogDir()
18
+ const LOG_FILE = getArgusLogFile()
20
19
 
21
20
  function ensureLogDir(): void {
22
- if (!existsSync(LOG_DIR)) {
23
- mkdirSync(LOG_DIR, { recursive: true })
21
+ const logDir = getArgusLogDir()
22
+ if (!existsSync(logDir)) {
23
+ mkdirSync(logDir, { recursive: true })
24
24
  }
25
25
  }
26
26
 
@@ -51,7 +51,7 @@ function createFileSink(): LogSink {
51
51
  dirReady = true
52
52
  }
53
53
  try {
54
- appendFileSync(LOG_FILE, line)
54
+ appendFileSync(getArgusLogFile(), line)
55
55
  } catch {
56
56
  // if we can't write logs, we don't crash the plugin
57
57
  }
@@ -0,0 +1,25 @@
1
+ import { relative, resolve } from "node:path"
2
+
3
+ export function isContained(child: string, root: string): boolean {
4
+ const resolvedChild = resolve(root, child)
5
+ const resolvedRoot = resolve(root)
6
+ const rel = relative(resolvedRoot, resolvedChild)
7
+ return !rel.startsWith("..")
8
+ }
9
+
10
+ export function assertContained(child: string, root: string): string {
11
+ const resolvedChild = resolve(root, child)
12
+ if (!isContained(resolvedChild, root)) {
13
+ throw new Error(`Path "${child}" resolves outside project root "${root}"`)
14
+ }
15
+ return resolvedChild
16
+ }
17
+
18
+ export function validateUrlScheme(url: string): boolean {
19
+ try {
20
+ const parsed = new URL(url)
21
+ return parsed.protocol === "http:" || parsed.protocol === "https:"
22
+ } catch {
23
+ return false
24
+ }
25
+ }
@@ -0,0 +1,11 @@
1
+ import { isAbsolute, normalize, relative } from "node:path"
2
+
3
+ export function normalizeFilePath(filePath: string, projectDir: string): string {
4
+ if (!filePath) return ""
5
+ const normalized = normalize(filePath)
6
+ if (isAbsolute(normalized)) {
7
+ const rel = relative(projectDir, normalized)
8
+ return rel.startsWith("..") ? normalized : rel
9
+ }
10
+ return normalized.replace(/^\.\//, "")
11
+ }
@@ -37,11 +37,12 @@ export function formatReportDate(date: Date): string {
37
37
  }
38
38
 
39
39
  export function sanitizeContractName(name: string): string {
40
- return name
40
+ const sanitized = name
41
41
  .replace(/\s+/g, "-")
42
42
  .replace(/[^a-zA-Z0-9-]/g, "")
43
43
  .replace(/-+/g, "-")
44
44
  .replace(/^-|-$/g, "")
45
+ return sanitized || "unnamed-contract"
45
46
  }
46
47
 
47
48
  export function resolveReportPath(options: ReportPathOptions): ResolvedReportPath {
@@ -57,7 +58,8 @@ export function resolveReportPath(options: ReportPathOptions): ResolvedReportPat
57
58
  const resolvedDate = date ?? new Date()
58
59
  const dateStr = formatReportDate(resolvedDate)
59
60
  const sanitizedName = sanitizeContractName(contractName)
60
- const filename = `${sanitizedName}-security-audit-${dateStr}.md`
61
+ const runIdSuffix = runId ? `-${runId.substring(0, 8)}` : ""
62
+ const filename = `${sanitizedName}-security-audit-${dateStr}${runIdSuffix}.md`
61
63
  const filePath = join(outputDir, filename)
62
64
  const canonicalId = runId ?? filename
63
65
 
@@ -0,0 +1,24 @@
1
+ import type { EventSink } from "../features/persistent-state/event-sink"
2
+ import type { AuditEvent } from "../state/schemas"
3
+ import { formatError } from "./format-error"
4
+ import { createLogger } from "./logger"
5
+
6
+ const logger = createLogger()
7
+
8
+ export async function safeEmitToSink(
9
+ sink: EventSink | null,
10
+ event: AuditEvent,
11
+ options?: { failFast?: boolean },
12
+ ): Promise<void> {
13
+ if (!sink) return
14
+ try {
15
+ await sink.append(event)
16
+ } catch (error) {
17
+ const message = `Failed to emit ${event.type} event to sink: ${formatError(error)}`
18
+ logger.error(message)
19
+
20
+ if (options?.failFast) {
21
+ throw new Error(message)
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,5 @@
1
+ const CHARS_PER_TOKEN = 4
2
+
3
+ export function estimateTokens(text: string): number {
4
+ return Math.ceil(text.length / CHARS_PER_TOKEN)
5
+ }
@@ -0,0 +1,8 @@
1
+ export function isRecord(value: unknown): value is Record<string, unknown> {
2
+ return typeof value === "object" && value !== null && !Array.isArray(value)
3
+ }
4
+
5
+ /** Type guard: returns true when value is a non-empty string (after trimming). */
6
+ export function isNonEmptyString(value: unknown): value is string {
7
+ return typeof value === "string" && value.trim().length > 0
8
+ }
@@ -0,0 +1,52 @@
1
+ import type { ArgusAgentName, Finding, FindingSeverity } from "../state/types"
2
+ import { ARGUS_FAMILY } from "./agent-names"
3
+
4
+ export function countBySeverity(findings: Finding[]): Record<FindingSeverity, number> {
5
+ const counts: Record<FindingSeverity, number> = {
6
+ Critical: 0,
7
+ High: 0,
8
+ Medium: 0,
9
+ Low: 0,
10
+ Informational: 0,
11
+ }
12
+ for (const finding of findings) {
13
+ counts[finding.severity]++
14
+ }
15
+ return counts
16
+ }
17
+
18
+ export const VALID_SEVERITIES: ReadonlySet<FindingSeverity> = new Set([
19
+ "Critical",
20
+ "High",
21
+ "Medium",
22
+ "Low",
23
+ "Informational",
24
+ ])
25
+
26
+ export const VALID_CONFIDENCES: ReadonlySet<Finding["confidence"]> = new Set([
27
+ "High",
28
+ "Medium",
29
+ "Low",
30
+ ])
31
+
32
+ export const VALID_SOURCES: ReadonlySet<Finding["source"]> = new Set([
33
+ "slither",
34
+ "manual",
35
+ "pattern",
36
+ "scvd",
37
+ "solodit",
38
+ "fuzz",
39
+ ])
40
+
41
+ export const VALID_AGENTS: ReadonlySet<ArgusAgentName> = new Set([
42
+ ...ARGUS_FAMILY,
43
+ "unknown",
44
+ ] as ArgusAgentName[])
45
+
46
+ export const SEVERITY_RANK: Record<FindingSeverity, number> = {
47
+ Critical: 0,
48
+ High: 1,
49
+ Medium: 2,
50
+ Low: 3,
51
+ Informational: 4,
52
+ }