solidity-argus 0.5.9 → 0.5.10

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/AGENTS.md CHANGED
@@ -13,7 +13,7 @@ CLI: `argus doctor`, `argus init`, `argus install`.
13
13
  **Role**: Primary security audit orchestrator
14
14
  **Description**: Argus Panoptes, the All-Seeing Guardian. Coordinates full Solidity security audits by dispatching Sentinel (analysis), Pythia (research), Scribe (reporting), and Themis (validation). Follows a rigorous 7-step methodology: Reconnaissance, Automated Scanning, Manual Review, Attack Surface Mapping, Vulnerability Research, Testing & Verification, and Reporting.
15
15
  **Model**: anthropic/claude-opus-4-7
16
- **Tools**: 14 orchestrator-accessible argus_* tools (argus_slither_analyze, argus_analyze_contract, argus_check_patterns, argus_proxy_detection, argus_solodit_search, argus_forge_test, argus_gas_analysis, argus_forge_fuzz, argus_forge_coverage, argus_skill_load, argus_generate_report, argus_record_finding, argus_read_findings, argus_sync_knowledge). `argus_persist_deduped` is reserved for Scribe.
16
+ **Tools**: 15 orchestrator-accessible argus_* tools (argus_slither_analyze, argus_analyze_contract, argus_check_patterns, argus_proxy_detection, argus_solodit_search, argus_forge_test, argus_gas_analysis, argus_forge_fuzz, argus_forge_coverage, argus_skill_load, argus_generate_report, argus_record_finding, argus_read_findings, argus_sync_knowledge, argus_themis_disposition). `argus_persist_deduped` is reserved for Scribe.
17
17
 
18
18
  ## sentinel
19
19
 
package/README.md CHANGED
@@ -106,6 +106,7 @@ Validates the completed audit by comparing raw findings, deduped findings, and t
106
106
  | `argus_read_findings` | Scribe, Themis | Reads persisted findings and audit artifacts for report generation and validation |
107
107
  | `argus_persist_deduped` | Scribe | Persists deduplicated findings before final report generation and validation |
108
108
  | `argus_generate_report` | Scribe | Generates the final structured audit report in professional markdown format |
109
+ | `argus_themis_disposition` | Argus | Records Argus' resolved disposition for Themis validation: approved, remediated, or explicitly overridden |
109
110
  | `argus_sync_knowledge` | Argus | Syncs the local vulnerability database from SCVD (api.scvd.dev) |
110
111
 
111
112
  ---
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "solidity-argus",
3
- "version": "0.5.9",
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",
3
+ "version": "0.5.10",
4
+ "description": "Solidity smart contract security auditing plugin for OpenCode — 5 specialized agents, 16 tools (15 core + optional Solodit), and a curated vulnerability knowledge base",
5
5
  "keywords": [
6
6
  "solidity",
7
7
  "security",
@@ -198,6 +198,7 @@ Task(subagent_type="scribe", prompt="Generate the final audit report for Project
198
198
  - \`argus_analyze_contract\`, \`argus_check_patterns\`, \`argus_proxy_detection\` → delegate to **sentinel**
199
199
  - \`argus_solodit_search\`, Solodit MCP search → delegate to **pythia**
200
200
  - \`argus_read_findings\`, \`argus_persist_deduped\`, \`argus_generate_report\` \u2192 delegate to **scribe**
201
+ - \`argus_themis_disposition\` → call after Themis returns to record Argus' resolved quality-gate disposition
201
202
  - Audit quality validation \u2192 delegate to **themis** (after Scribe completes)
202
203
 
203
204
  ### **@sentinel** (The Executor)
@@ -570,13 +571,17 @@ Themis will:
570
571
  3. Apply vulnerability skill checklists to assess finding validity
571
572
  4. Return a verdict: approved or issues found
572
573
 
573
- **If Themis flags issues**, YOU are the final judge:
574
- - If Themis found genuinely dropped findings → re-dispatch Scribe with specific correction instructions
575
- - If Themis disagrees on severity → evaluate the evidence and make the final call
576
- - If Themis found potential false positives → assess and note in the report if warranted
577
- - If Themis approves → audit is complete
574
+ **If Themis flags issues**, YOU are the final judge, but you must record a resolved disposition before the audit is complete:
575
+ - If Themis found genuinely dropped findings → re-dispatch Scribe with specific correction instructions, then record status="remediated" with notes.
576
+ - If Themis disagrees on severity → evaluate the evidence and either remediate the report or record status="overridden" with a concrete justification.
577
+ - If Themis found potential false positives → assess and remediate or explicitly override with justification.
578
+ - If Themis approves → record status="approved" with the Themis verdict.
578
579
 
579
- **An audit is NOT complete until Themis has validated the output.**
580
+ Record the disposition by calling \`argus_themis_disposition\` with \`status\`, \`verdict_json\`, and either \`notes\` for remediation or \`justification\` for overrides.
581
+
582
+ If Themis returns approved=false, Argus remains the final judge but must record a disposition before the audit is complete: remediate the issue and record status="remediated", or deliberately override with status="overridden" and a concrete justification. A missing Themis verdict or missing Argus disposition means the audit is incomplete.
583
+
584
+ **An audit is NOT complete until Themis has validated the output and Argus has recorded a resolved disposition.**
580
585
 
581
586
  You are the guardian. Nothing escapes your gaze. Begin the audit.
582
587
  `
@@ -98,6 +98,7 @@ Verdict rules:
98
98
  - If approved with no issues, state it concisely.
99
99
  - If issues exist, list each issue with concrete evidence (file path, finding id, field mismatch, or historical precedent).
100
100
  - Be precise and adversarial, but do not overreach. Recommend; do not override.
101
+ - Return the JSON verdict as the final fenced code block in your response. Do not add a second JSON object after it. Argus uses this verdict to decide whether to accept it, remediate it, or explicitly override it.
101
102
 
102
103
  ## AUTHORITY BOUNDARY
103
104
 
@@ -1092,11 +1092,13 @@ export function createHooks(args: {
1092
1092
  )
1093
1093
  }
1094
1094
 
1095
- // Trigger finalization immediately after report generation.
1096
- // The session.idle handler also checks reportGenerated, but in
1097
- // `opencode run` mode the process may exit before another idle
1098
- // event fires. Finalizing here guarantees the run is closed.
1099
- if (state.reportGenerated) {
1095
+ // The report is materialized here, but finalization waits until
1096
+ // Argus records a resolved Themis disposition.
1097
+ }
1098
+
1099
+ if (toolName === "argus_themis_disposition") {
1100
+ const state = getAuditState(input.sessionID)
1101
+ if (state?.reportGenerated) {
1100
1102
  const runSink =
1101
1103
  eventSinksByRunId.get(state.sessionId) ??
1102
1104
  (input.sessionID
@@ -1120,12 +1122,12 @@ export function createHooks(args: {
1120
1122
  )
1121
1123
  if (!reportFinalization.invariantsPassed) {
1122
1124
  logger.warn(
1123
- `Report-triggered finalization for run ${state.sessionId} has invariant errors: ${reportFinalization.errors.join("; ")}`,
1125
+ `Themis-disposition finalization for run ${state.sessionId} has invariant errors: ${reportFinalization.errors.join("; ")}`,
1124
1126
  )
1125
1127
  }
1126
1128
  } catch (error) {
1127
1129
  logger.warn(
1128
- `Report-triggered finalization failed for run ${state.sessionId}: ${error instanceof Error ? error.message : String(error)}`,
1130
+ `Themis-disposition finalization failed for run ${state.sessionId}: ${error instanceof Error ? error.message : String(error)}`,
1129
1131
  )
1130
1132
  }
1131
1133
  }
@@ -15,6 +15,7 @@ import { reportGeneratorTool } from "./tools/report-generator-tool"
15
15
  import { slitherTool } from "./tools/slither-tool"
16
16
  import { createSoloditSearchTool } from "./tools/solodit-search-tool"
17
17
  import { syncKnowledgeTool } from "./tools/sync-knowledge-tool"
18
+ import { themisDispositionTool } from "./tools/themis-disposition-tool"
18
19
 
19
20
  export function createTools(config: ArgusConfig): Record<string, ToolDefinition> {
20
21
  const tools: Record<string, ToolDefinition> = {
@@ -31,6 +32,7 @@ export function createTools(config: ArgusConfig): Record<string, ToolDefinition>
31
32
  argus_read_findings: readFindingsTool,
32
33
  argus_persist_deduped: persistDedupedTool,
33
34
  argus_generate_report: reportGeneratorTool,
35
+ argus_themis_disposition: themisDispositionTool,
34
36
  argus_sync_knowledge: syncKnowledgeTool,
35
37
  }
36
38
 
@@ -1,23 +1,9 @@
1
1
  import { PHASE_ORDER } from "../../shared/audit-phases"
2
+ import { computeMissingKeyTools } from "../../shared/key-tools"
2
3
  import type { AuditPhase, AuditState } from "../../state/types"
3
4
 
4
5
  const REPORTING_PHASES: AuditPhase[] = ["reporting", "complete"]
5
6
 
6
- const KEY_TOOL_FAMILIES: Array<{ family: string; prefixes: string[] }> = [
7
- { family: "slither", prefixes: ["argus_slither_analyze", "slither"] },
8
- { family: "forge_test", prefixes: ["argus_forge_test", "forge_test"] },
9
- { family: "forge_fuzz", prefixes: ["argus_forge_fuzz", "forge_fuzz"] },
10
- { family: "forge_coverage", prefixes: ["argus_forge_coverage", "forge_coverage"] },
11
- ]
12
-
13
- function getMissingToolFamilies(auditState: AuditState): string[] {
14
- const executedTools = auditState.toolsExecuted.map((t) => t.tool)
15
- return KEY_TOOL_FAMILIES.filter(
16
- ({ prefixes }) =>
17
- !executedTools.some((tool) => prefixes.some((prefix) => tool.startsWith(prefix))),
18
- ).map(({ family }) => family)
19
- }
20
-
21
7
  function getNextPhase(current: AuditPhase): AuditPhase | null {
22
8
  const idx = PHASE_ORDER.indexOf(current)
23
9
  if (idx === -1 || idx >= PHASE_ORDER.length - 1) return null
@@ -39,7 +25,7 @@ export function createAuditEnforcer() {
39
25
  ]
40
26
 
41
27
  if (REPORTING_PHASES.includes(auditState.currentPhase)) {
42
- const missing = getMissingToolFamilies(auditState)
28
+ const missing = computeMissingKeyTools(auditState.toolsExecuted, auditState.unavailableTools)
43
29
  if (missing.length > 0) {
44
30
  parts.push(
45
31
  `\u26a0\ufe0f Tool coverage incomplete: ${missing.join(", ")} have not been executed. Do not proceed to report generation until required tools are complete.`,
@@ -131,6 +131,79 @@ function collectReportQualityGateErrors(events: AuditEvent[]): string[] {
131
131
  return errors
132
132
  }
133
133
 
134
+ type ThemisVerdict = {
135
+ approved?: unknown
136
+ pipeline_issues?: unknown
137
+ false_positives?: unknown
138
+ missed_findings?: unknown
139
+ severity_adjustments?: unknown
140
+ }
141
+
142
+ type ThemisDisposition = {
143
+ status?: unknown
144
+ verdict?: ThemisVerdict
145
+ notes?: unknown
146
+ justification?: unknown
147
+ }
148
+
149
+ function hasText(value: unknown): value is string {
150
+ return typeof value === "string" && value.trim().length > 0
151
+ }
152
+
153
+ function isResolvedThemisDisposition(value: unknown): boolean {
154
+ const disposition = asRecord(value) as ThemisDisposition | null
155
+ if (disposition?.status === "approved") {
156
+ return disposition.verdict?.approved === true
157
+ }
158
+ if (disposition?.status === "remediated") {
159
+ return disposition.verdict?.approved === false && hasText(disposition.notes)
160
+ }
161
+ if (disposition?.status === "overridden") {
162
+ return disposition.verdict?.approved === false && hasText(disposition.justification)
163
+ }
164
+ return false
165
+ }
166
+
167
+ function hasRejectedThemisVerdict(value: unknown): boolean {
168
+ const verdict = asRecord(value) as ThemisVerdict | null
169
+ return verdict?.approved === false
170
+ }
171
+
172
+ function collectThemisDispositionErrors(events: AuditEvent[]): string[] {
173
+ let reportIndex = -1
174
+ for (let index = events.length - 1; index >= 0; index -= 1) {
175
+ const event = events[index]
176
+ if (event && isGenerateReportCompletion(event)) {
177
+ reportIndex = index
178
+ break
179
+ }
180
+ }
181
+ if (reportIndex === -1) return []
182
+
183
+ const laterEvents = events.slice(reportIndex + 1)
184
+ const hasResolvedDisposition = laterEvents.some((event) => {
185
+ if (event.type !== "tool.completed") return false
186
+ const payload = asRecord(event.payload)
187
+ return isResolvedThemisDisposition(payload?.themisDisposition)
188
+ })
189
+
190
+ if (hasResolvedDisposition) return []
191
+
192
+ const hasUnresolvedRejection = laterEvents.some((event) => {
193
+ if (event.type !== "tool.completed") return false
194
+ const payload = asRecord(event.payload)
195
+ return (
196
+ payload?.tool === "task" &&
197
+ payload.subagent_type === "themis" &&
198
+ hasRejectedThemisVerdict(payload.themis)
199
+ )
200
+ })
201
+
202
+ return hasUnresolvedRejection
203
+ ? ["generated report has unresolved Themis issues"]
204
+ : ["generated report has no resolved Themis disposition"]
205
+ }
206
+
134
207
  function collectParentChildIntegrityErrors(events: AuditEvent[]): string[] {
135
208
  const errors: string[] = []
136
209
  const parentByChild = new Map<string, string>()
@@ -244,7 +317,7 @@ function collectInvariantErrors(events: AuditEvent[]): { errors: string[]; warni
244
317
 
245
318
  warnings.push(...collectOrphanedToolStarts(events))
246
319
  errors.push(...collectParentChildIntegrityErrors(events))
247
- errors.push(...collectMultiSessionErrors(events))
320
+ warnings.push(...collectMultiSessionErrors(events))
248
321
  return { errors, warnings }
249
322
  }
250
323
 
@@ -308,6 +381,7 @@ export async function finalizeRun(
308
381
  const reportErrors = [
309
382
  ...(await collectReportCompletenessErrors(events)),
310
383
  ...collectReportQualityGateErrors(events),
384
+ ...collectThemisDispositionErrors(events),
311
385
  ]
312
386
  if (reportErrors.length === 0) {
313
387
  return {
@@ -324,6 +398,7 @@ export async function finalizeRun(
324
398
  const { errors, warnings } = collectInvariantErrors(events)
325
399
  errors.push(...(await collectReportCompletenessErrors(events)))
326
400
  errors.push(...collectReportQualityGateErrors(events))
401
+ errors.push(...collectThemisDispositionErrors(events))
327
402
  const invariantsPassed = errors.length === 0
328
403
  const sessionId = events.at(-1)?.session_id ?? ""
329
404
 
@@ -426,6 +426,21 @@ function processFuzzResult(parsed: Record<string, unknown>, state: AuditState):
426
426
  }
427
427
  }
428
428
 
429
+ function countReadFindingsResult(parsed: Record<string, unknown>): number {
430
+ const summary = toRecord(parsed.summary)
431
+ if (
432
+ summary &&
433
+ typeof summary.findingsCount === "number" &&
434
+ Number.isFinite(summary.findingsCount)
435
+ ) {
436
+ return Math.max(0, summary.findingsCount)
437
+ }
438
+
439
+ const reportInput = toRecord(parsed.reportInput)
440
+ const findings = reportInput?.findings
441
+ return Array.isArray(findings) ? findings.length : 0
442
+ }
443
+
429
444
  function processSoloditResult(parsed: Record<string, unknown>, state: AuditState): void {
430
445
  const query = typeof parsed.query === "string" ? parsed.query : ""
431
446
  const results = Array.isArray(parsed.results) ? parsed.results : []
@@ -709,6 +724,7 @@ export function createToolTrackingHook(
709
724
  let findingsCount = 0
710
725
  let completedSuccess = false
711
726
  let completionError: string | undefined
727
+ let completedRecord: Record<string, unknown> | null = null
712
728
 
713
729
  try {
714
730
  if (input.tool === "argus_skill_load") {
@@ -763,6 +779,7 @@ export function createToolTrackingHook(
763
779
  }
764
780
  return
765
781
  }
782
+ completedRecord = record
766
783
 
767
784
  switch (input.tool) {
768
785
  case "argus_slither_analyze": {
@@ -812,6 +829,9 @@ export function createToolTrackingHook(
812
829
  projectDir,
813
830
  )
814
831
  break
832
+ case "argus_read_findings":
833
+ findingsCount = countReadFindingsResult(record)
834
+ break
815
835
  case "argus_analyze_contract": {
816
836
  processContractAnalyzerResult(record, auditState)
817
837
  const filePath = (input.args as Record<string, unknown>)?.file_path as string
@@ -996,6 +1016,11 @@ export function createToolTrackingHook(
996
1016
  case "argus_check_patterns":
997
1017
  if (auditState.patternVersion) enrichment.patternVersion = auditState.patternVersion
998
1018
  break
1019
+ case "argus_themis_disposition":
1020
+ if (completedRecord?.themisDisposition) {
1021
+ enrichment.themisDisposition = completedRecord.themisDisposition
1022
+ }
1023
+ break
999
1024
  }
1000
1025
  }
1001
1026
  await emitToSink(
@@ -23,15 +23,22 @@ export const UNAVAILABLE_TO_KEY_TOOL: Record<string, string> = {
23
23
  solodit: "solodit",
24
24
  }
25
25
 
26
+ type ToolCoverageRecord = {
27
+ tool: string
28
+ success?: boolean
29
+ }
30
+
26
31
  /**
27
32
  * Compute which key tools have not yet been executed, excusing any that are
28
33
  * declared unavailable.
29
34
  */
30
35
  export function computeMissingKeyTools(
31
- toolsExecuted: Array<{ tool: string }>,
36
+ toolsExecuted: ToolCoverageRecord[],
32
37
  unavailableTools?: string[],
33
38
  ): string[] {
34
- const executedShortNames = new Set(toolsExecuted.map((t) => TOOL_SHORT_NAMES[t.tool] ?? t.tool))
39
+ const executedShortNames = new Set(
40
+ toolsExecuted.filter((t) => t.success === true).map((t) => TOOL_SHORT_NAMES[t.tool] ?? t.tool),
41
+ )
35
42
  const excused = new Set(
36
43
  (unavailableTools ?? []).map((t) => UNAVAILABLE_TO_KEY_TOOL[t]).filter(Boolean),
37
44
  )
@@ -5,10 +5,14 @@ import { resolveProjectDir } from "../shared/project-utils"
5
5
 
6
6
  type ForgeCoverageArgs = {
7
7
  target?: string
8
+ match_path?: string
9
+ ir_minimum?: boolean
8
10
  }
9
11
 
10
12
  type NormalizedForgeCoverageArgs = {
11
13
  target: string
14
+ match_path?: string
15
+ ir_minimum: boolean
12
16
  }
13
17
 
14
18
  type ForgeCoverageFile = {
@@ -53,9 +57,22 @@ const EMPTY_SUMMARY: ForgeCoverageSummary = {
53
57
  function normalizeArgs(args: ForgeCoverageArgs, context: ToolContext): NormalizedForgeCoverageArgs {
54
58
  return {
55
59
  target: args.target ?? resolveProjectDir(context),
60
+ match_path: args.match_path,
61
+ ir_minimum: args.ir_minimum ?? false,
56
62
  }
57
63
  }
58
64
 
65
+ function buildCoverageCommand(args: NormalizedForgeCoverageArgs, forceIrMinimum = false): string[] {
66
+ const command = ["forge", "coverage", "--report", "summary"]
67
+ if (args.match_path) command.push("--match-path", args.match_path)
68
+ if (args.ir_minimum || forceIrMinimum) command.push("--ir-minimum")
69
+ return command
70
+ }
71
+
72
+ function isStackTooDeep(stderr: string): boolean {
73
+ return /stack too deep/i.test(stderr)
74
+ }
75
+
59
76
  function parsePercent(input: string): number {
60
77
  const match = input.match(/(\d+(?:\.\d+)?)%/)
61
78
  if (!match?.[1]) {
@@ -156,11 +173,22 @@ export async function executeForgeCoverage(
156
173
  })
157
174
 
158
175
  try {
159
- const runResult = await runCommand(["forge", "coverage"], {
176
+ let runResult = await runCommand(buildCoverageCommand(normalizedArgs), {
160
177
  signal: context.abort,
161
178
  cwd: normalizedArgs.target,
162
179
  })
163
180
 
181
+ if (
182
+ runResult.exitCode !== 0 &&
183
+ !normalizedArgs.ir_minimum &&
184
+ isStackTooDeep(runResult.stderr)
185
+ ) {
186
+ runResult = await runCommand(buildCoverageCommand(normalizedArgs, true), {
187
+ signal: context.abort,
188
+ cwd: normalizedArgs.target,
189
+ })
190
+ }
191
+
164
192
  if (runResult.exitCode !== 0) {
165
193
  return fail(
166
194
  runResult.stderr.trim() || `forge coverage exited with code ${runResult.exitCode}`,
@@ -193,6 +221,8 @@ export const forgeCoverageTool = tool({
193
221
  "Run forge coverage analysis and return structured per-file coverage metrics (lines, statements, branches, functions).",
194
222
  args: {
195
223
  target: tool.schema.string().optional(),
224
+ match_path: tool.schema.string().optional(),
225
+ ir_minimum: tool.schema.boolean().optional(),
196
226
  },
197
227
  async execute(args, context) {
198
228
  const result = await executeForgeCoverage(args, context)
@@ -746,6 +746,22 @@ function formatLocation(finding: Finding): string {
746
746
  return `${finding.file}:${finding.lines[0]}-${finding.lines[1]}`
747
747
  }
748
748
 
749
+ function sourceExcerpt(projectDir: string, finding: Finding): string | null {
750
+ if (!finding.file || !Array.isArray(finding.lines) || finding.lines.length < 2) return null
751
+ const start = finding.lines[0]
752
+ const end = finding.lines[1]
753
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start <= 0 || end < start) {
754
+ return null
755
+ }
756
+ const absolutePath = path.isAbsolute(finding.file)
757
+ ? finding.file
758
+ : path.join(projectDir, finding.file)
759
+ if (!existsSync(absolutePath) || !statSync(absolutePath).isFile()) return null
760
+ const contents = readFileSync(absolutePath, "utf-8").split(/\r?\n/)
761
+ const excerpt = contents.slice(start - 1, end).join("\n")
762
+ return excerpt.trim().length > 0 ? excerpt : null
763
+ }
764
+
749
765
  function shouldIncludeFinding(finding: Finding, threshold: SeverityThreshold): boolean {
750
766
  return FINDING_WEIGHT[finding.severity] >= THRESHOLD_WEIGHT[threshold]
751
767
  }
@@ -1005,7 +1021,7 @@ function buildRecommendations(counts: FindingsCount): string[] {
1005
1021
  return items
1006
1022
  }
1007
1023
 
1008
- function buildFindingsSection(findings: Finding[]): string {
1024
+ function buildFindingsSection(findings: Finding[], projectDir: string): string {
1009
1025
  if (findings.length === 0) {
1010
1026
  return "## Findings\nNo findings meet the configured severity threshold."
1011
1027
  }
@@ -1031,6 +1047,15 @@ function buildFindingsSection(findings: Finding[]): string {
1031
1047
  lines.push(`**Severity**: ${finding.severity}`)
1032
1048
  lines.push(`**Confidence**: ${finding.confidence}`)
1033
1049
  lines.push(`**Location**: ${formatLocation(finding)}`)
1050
+ const excerpt = sourceExcerpt(projectDir, finding)
1051
+ if (excerpt) {
1052
+ lines.push("")
1053
+ lines.push("**Source Excerpt**:")
1054
+ lines.push("")
1055
+ lines.push("```solidity")
1056
+ lines.push(excerpt)
1057
+ lines.push("```")
1058
+ }
1034
1059
  lines.push("")
1035
1060
  lines.push(`**Description**: ${finding.description}`)
1036
1061
  lines.push("")
@@ -1387,7 +1412,7 @@ export async function executeReportGeneration(
1387
1412
  "Approach: Findings are normalized, deterministically ordered by severity/file/line, and validated against report quality gates before emission.",
1388
1413
  )
1389
1414
 
1390
- sections.push(buildFindingsSection(findings))
1415
+ sections.push(buildFindingsSection(findings, reportInput.projectDir))
1391
1416
 
1392
1417
  sections.push("## Recommendations")
1393
1418
  for (const item of buildRecommendations(counts)) {
@@ -470,26 +470,6 @@ export async function executeSlitherAnalyze(
470
470
  }
471
471
  }
472
472
 
473
- if (args.via_ir) {
474
- const fallbackResult = await flattenFallback(args, context, {
475
- ...getDefaultFlattenDeps(),
476
- runCommand,
477
- cwd: projectDir,
478
- })
479
- if (fallbackResult) return fallbackResult
480
- return {
481
- success: false,
482
- findingsCount: 0,
483
- findings: [],
484
- executionTime: Date.now() - startedAt,
485
- errors: [
486
- "via_ir enabled — flatten fallback failed. Ensure forge and solc-select are installed.",
487
- ],
488
- error:
489
- "Project uses via_ir which is incompatible with Slither direct analysis. Flatten fallback also failed.",
490
- }
491
- }
492
-
493
473
  const command = buildCommand(args)
494
474
 
495
475
  try {
@@ -508,7 +488,7 @@ export async function executeSlitherAnalyze(
508
488
  payload = JSON.parse(runResult.stdout) as SlitherPayload
509
489
  } catch (error) {
510
490
  const message = error instanceof Error ? error.message : "Unknown parse error"
511
- if (shouldTryFlattenFallback(errors, runResult.stderr)) {
491
+ if (args.via_ir || shouldTryFlattenFallback(errors, runResult.stderr)) {
512
492
  const fallbackResult = await flattenFallback(args, context, {
513
493
  ...getDefaultFlattenDeps(),
514
494
  runCommand,
@@ -533,7 +513,11 @@ export async function executeSlitherAnalyze(
533
513
  const findings = parseFindings(payload)
534
514
  const success = findings.length > 0 || (runResult.exitCode === 0 && payload.success !== false)
535
515
 
536
- if (!success && findings.length === 0 && shouldTryFlattenFallback(errors, runResult.stderr)) {
516
+ if (
517
+ !success &&
518
+ findings.length === 0 &&
519
+ (args.via_ir || shouldTryFlattenFallback(errors, runResult.stderr))
520
+ ) {
537
521
  const fallbackResult = await flattenFallback(args, context, {
538
522
  ...getDefaultFlattenDeps(),
539
523
  runCommand,
@@ -0,0 +1,46 @@
1
+ import { type ToolContext, tool } from "@opencode-ai/plugin"
2
+
3
+ type ThemisDispositionStatus = "approved" | "remediated" | "overridden"
4
+
5
+ type ThemisDispositionArgs = {
6
+ status: ThemisDispositionStatus
7
+ verdict_json: string
8
+ notes?: string
9
+ justification?: string
10
+ }
11
+
12
+ function parseVerdict(verdictJson: string): unknown {
13
+ try {
14
+ return JSON.parse(verdictJson)
15
+ } catch (error) {
16
+ const message = error instanceof Error ? error.message : String(error)
17
+ throw new Error(`Invalid Themis verdict JSON: ${message}`)
18
+ }
19
+ }
20
+
21
+ export function executeThemisDisposition(args: ThemisDispositionArgs, context: ToolContext) {
22
+ context.metadata({ title: `Themis disposition: ${args.status}` })
23
+ return {
24
+ success: true,
25
+ themisDisposition: {
26
+ status: args.status,
27
+ verdict: parseVerdict(args.verdict_json),
28
+ ...(args.notes ? { notes: args.notes } : {}),
29
+ ...(args.justification ? { justification: args.justification } : {}),
30
+ },
31
+ }
32
+ }
33
+
34
+ export const themisDispositionTool = tool({
35
+ description:
36
+ "Record Argus' resolved disposition for a Themis quality-gate verdict: approved, remediated, or overridden.",
37
+ args: {
38
+ status: tool.schema.enum(["approved", "remediated", "overridden"]),
39
+ verdict_json: tool.schema.string(),
40
+ notes: tool.schema.string().optional(),
41
+ justification: tool.schema.string().optional(),
42
+ },
43
+ async execute(args, context) {
44
+ return JSON.stringify(executeThemisDisposition(args, context))
45
+ },
46
+ })