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
@@ -1,15 +1,19 @@
1
- import { existsSync } from "node:fs"
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
2
2
  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
6
  import { readEvents } from "../features/persistent-state/event-sink"
7
- import type { DropDiagnostic, DropPolicy } from "../shared/drop-diagnostics"
7
+ import { resolveRunIdFromOpencodeSession } from "../features/persistent-state/global-run-index"
8
+ import { createAuditArtifactResolver } from "../shared/audit-artifact-resolver"
9
+ import type { DropDiagnostic } from "../shared/drop-diagnostics"
8
10
  import { createDropDiagnosticsCollector } from "../shared/drop-diagnostics"
11
+ import { computeMissingKeyTools } from "../shared/key-tools"
9
12
  import { createLogger } from "../shared/logger"
10
13
  import { resolveProjectDir } from "../shared/project-utils"
11
14
  import { resolveReportPath } from "../shared/report-path-resolver"
12
- import { normalizeToCanonicalFinding } from "../state/adapters"
15
+ import { isNonEmptyString } from "../shared/type-guards"
16
+ import { SEVERITY_RANK } from "../shared/validation-constants"
13
17
  import {
14
18
  compareIssueFingerprintSets,
15
19
  dedupeFindingsForFinalOutput,
@@ -21,6 +25,8 @@ import { checkReportPreflight } from "./report-preflight"
21
25
 
22
26
  type SeverityThreshold = "critical" | "high" | "medium" | "low" | "informational"
23
27
 
28
+ type ToolCoveragePolicy = "enforce" | "warn" | "skip"
29
+
24
30
  type ReportGeneratorArgs = {
25
31
  project_name: string
26
32
  scope: string[]
@@ -28,8 +34,9 @@ type ReportGeneratorArgs = {
28
34
  severity_threshold?: SeverityThreshold
29
35
  quality_gate_policy?: QualityGatePolicy
30
36
  report_input?: string
31
- audit_state?: string
32
37
  preflight_policy?: PreflightPolicy
38
+ tool_coverage_policy?: ToolCoveragePolicy
39
+ run_id?: string
33
40
  }
34
41
 
35
42
  type FindingsCount = {
@@ -44,6 +51,7 @@ export type ReportGenerationResult = {
44
51
  report: string
45
52
  findingsCount: FindingsCount
46
53
  filename: string
54
+ run_id: string
47
55
  contentHash: string
48
56
  qualityGates: ReportQualityValidation
49
57
  contractDiagnostics: DropDiagnostic[]
@@ -72,6 +80,7 @@ export type ReportGenerationDependencies = {
72
80
  runId: string,
73
81
  projectDir: string,
74
82
  ) => Promise<import("../state/schemas").AuditEvent[]>
83
+ resolveCanonicalRunId?: (sessionId: string, projectDir: string) => string | null | undefined
75
84
  }
76
85
 
77
86
  export const SINGLE_WRITER_POLICY_VERSION = "1.0.0"
@@ -147,13 +156,8 @@ const FINDING_WEIGHT: Record<FindingSeverity, number> = {
147
156
  Informational: 1,
148
157
  }
149
158
 
150
- const SEVERITY_RANK: Record<FindingSeverity, number> = {
151
- Critical: 0,
152
- High: 1,
153
- Medium: 2,
154
- Low: 3,
155
- Informational: 4,
156
- }
159
+ /** Sentinel for missing/unknown tool execution timestamps (schema requires startTime > 0). */
160
+ const UNKNOWN_TIMESTAMP_SENTINEL = 1
157
161
 
158
162
  const MISSING_IMPACT_TEXT = "Impact details were not provided in the finding payload."
159
163
  const MISSING_RECOMMENDATION_TEXT =
@@ -175,19 +179,6 @@ function emptyCounts(): FindingsCount {
175
179
  }
176
180
  }
177
181
 
178
- function emptyAuditState(findings: Finding[] = []): AuditState {
179
- return {
180
- sessionId: "",
181
- projectDir: "",
182
- contractsReviewed: [],
183
- findings,
184
- toolsExecuted: [],
185
- currentPhase: "complete",
186
- scope: [],
187
- startTime: 0,
188
- }
189
- }
190
-
191
182
  /**
192
183
  * Parse a location string like "File.sol:18-22" or "File.sol:18" into { file, lines }.
193
184
  * Returns undefined if the string doesn't match a recognized format.
@@ -236,11 +227,15 @@ export function normalizeRawFinding(raw: Record<string, unknown>): Record<string
236
227
  }
237
228
  }
238
229
 
239
- // file + lines: accept location string as alias
240
- if (typeof result.file !== "string" && typeof result.location === "string") {
230
+ // file + lines: accept location string as alias.
231
+ // Always attempt to extract lines from location, even when file is already set.
232
+ // LLMs commonly provide both file and location (e.g. file="src/Vault.sol", location="Vault.sol:18-23").
233
+ if (typeof result.location === "string") {
241
234
  const parsed = parseLocationString(result.location as string)
242
235
  if (parsed) {
243
- result.file = parsed.file
236
+ if (typeof result.file !== "string" || (result.file as string).length === 0) {
237
+ result.file = parsed.file
238
+ }
244
239
  if (!Array.isArray(result.lines) || (result.lines as unknown[]).length !== 2) {
245
240
  result.lines = parsed.lines
246
241
  }
@@ -297,79 +292,11 @@ export function normalizeRawFinding(raw: Record<string, unknown>): Record<string
297
292
  result.description = result.check
298
293
  }
299
294
 
300
- return result
301
- }
302
-
303
- function hasMinimumFindingFields(
304
- f: unknown,
305
- ): f is { check: string; file: string; lines: [number, number] } {
306
- if (typeof f !== "object" || f === null) return false
307
- const obj = f as Record<string, unknown>
308
- return (
309
- typeof obj.check === "string" &&
310
- obj.check.length > 0 &&
311
- typeof obj.file === "string" &&
312
- Array.isArray(obj.lines) &&
313
- obj.lines.length === 2
314
- )
315
- }
316
-
317
- const VALID_SEVERITIES: ReadonlySet<string> = new Set([
318
- "Critical",
319
- "High",
320
- "Medium",
321
- "Low",
322
- "Informational",
323
- ])
324
- const VALID_SOURCES: ReadonlySet<string> = new Set([
325
- "slither",
326
- "manual",
327
- "pattern",
328
- "scvd",
329
- "solodit",
330
- "fuzz",
331
- ])
332
-
333
- function normalizeFinding(f: Record<string, unknown>): Finding {
334
- const severity =
335
- typeof f.severity === "string" && VALID_SEVERITIES.has(f.severity)
336
- ? (f.severity as Finding["severity"])
337
- : "Informational"
338
- const confidence =
339
- typeof f.confidence === "string" && ["High", "Medium", "Low"].includes(f.confidence)
340
- ? (f.confidence as Finding["confidence"])
341
- : "Low"
342
- const source =
343
- typeof f.source === "string" && VALID_SOURCES.has(f.source)
344
- ? (f.source as Finding["source"])
345
- : "manual"
346
- const description = typeof f.description === "string" ? f.description : (f.check as string)
347
- const id = typeof f.id === "string" ? f.id : `${f.check}:${f.file}:${(f.lines as number[])[0]}`
348
- return {
349
- id,
350
- check: f.check as string,
351
- severity,
352
- confidence,
353
- description,
354
- file: f.file as string,
355
- lines: f.lines as [number, number],
356
- source,
357
- remediation: typeof f.remediation === "string" ? f.remediation : undefined,
358
- exploitReference: typeof f.exploitReference === "string" ? f.exploitReference : undefined,
359
- ...(typeof f.impact === "string" ? { impact: f.impact } : {}),
360
- ...(typeof f.recommendation === "string" ? { recommendation: f.recommendation } : {}),
361
- ...(typeof f.proofOfConcept === "string" ? { proofOfConcept: f.proofOfConcept } : {}),
362
- ...(typeof f.proof_of_concept === "string" ? { proofOfConcept: f.proof_of_concept } : {}),
363
- } as Finding
364
- }
365
-
366
- export type ParseAuditStateOptions = {
367
- dropPolicy?: DropPolicy
368
- }
295
+ if (!Array.isArray(result.lines) || (result.lines as unknown[]).length !== 2) {
296
+ result.lines = [0, 0]
297
+ }
369
298
 
370
- export type ParseAuditStateResult = {
371
- state: AuditState
372
- diagnostics: DropDiagnostic[]
299
+ return result
373
300
  }
374
301
 
375
302
  type ParseReportInputResult = {
@@ -406,80 +333,193 @@ function reportInputToAuditState(reportInput: ReportInput): AuditState {
406
333
  proxyContracts: reportInput.proxyContracts,
407
334
  patternVersion: reportInput.patternVersion,
408
335
  skillsLoaded: reportInput.skillsLoaded,
336
+ unavailableTools: reportInput.unavailableTools,
409
337
  }
410
338
  }
411
339
 
412
- function buildLegacyCompatibleReportInput(
413
- state: AuditState,
414
- context: ToolContext,
340
+ function normalizeToolsExecutedDefaults(
341
+ parsed: unknown,
342
+ expectedRunId: string | undefined,
415
343
  diagnostics: ReturnType<typeof createDropDiagnosticsCollector>,
416
- ): ReportInput {
417
- diagnostics.warn(
418
- "REPORT_INPUT_DEPRECATED_LEGACY_PAYLOAD",
419
- "Legacy audit_state payload is deprecated; pass report_input with canonical ReportInput schema.",
420
- "audit_state",
421
- )
422
-
423
- const runId = state.sessionId || context.sessionID || "legacy-run"
424
- const sessionId = state.sessionId || context.sessionID || runId
344
+ ): void {
345
+ if (!parsed || typeof parsed !== "object") return
346
+ const obj = parsed as Record<string, unknown>
347
+ if (!Array.isArray(obj.toolsExecuted)) return
348
+
349
+ const runId = (typeof obj.run_id === "string" && obj.run_id) || expectedRunId || "unknown"
350
+ let patched = false
351
+
352
+ for (const entry of obj.toolsExecuted) {
353
+ if (!entry || typeof entry !== "object") continue
354
+ const rec = entry as Record<string, unknown>
355
+ if (typeof rec.startTime !== "number" || rec.startTime <= 0) {
356
+ rec.startTime = UNKNOWN_TIMESTAMP_SENTINEL
357
+ patched = true
358
+ }
359
+ if (typeof rec.success !== "boolean") {
360
+ rec.success = true
361
+ patched = true
362
+ }
363
+ if (typeof rec.findingsCount !== "number" || rec.findingsCount < 0) {
364
+ rec.findingsCount = 0
365
+ patched = true
366
+ }
367
+ if (!isNonEmptyString(rec.run_id)) {
368
+ rec.run_id = runId
369
+ patched = true
370
+ }
371
+ if (!isNonEmptyString(rec.schema_version)) {
372
+ rec.schema_version = SCHEMA_VERSION
373
+ patched = true
374
+ }
375
+ }
425
376
 
426
- if (!state.sessionId) {
377
+ if (patched) {
427
378
  diagnostics.warn(
428
- "REPORT_INPUT_SYNTHESIZED_SESSION",
429
- "Legacy payload missing sessionId; synthesized session_id from tool context/run_id.",
430
- "session_id",
379
+ "REPORT_INPUT_TOOLS_EXECUTED_NORMALIZED",
380
+ "toolsExecuted entries were missing canonical fields (startTime, success, findingsCount, run_id, schema_version); defaults applied.",
381
+ "toolsExecuted",
431
382
  )
432
383
  }
433
- if (!state.projectDir) {
434
- diagnostics.warn(
435
- "REPORT_INPUT_SYNTHESIZED_PROJECT_DIR",
436
- "Legacy payload missing projectDir; synthesized projectDir from tool context.",
437
- "projectDir",
438
- )
384
+ }
385
+
386
+ function resolveExpectedRunId(
387
+ args: ReportGeneratorArgs,
388
+ context: ToolContext,
389
+ deps: ReportGenerationDependencies,
390
+ ): string | undefined {
391
+ // 1. Explicit run_id from LLM args (highest priority)
392
+ if (isNonEmptyString(args.run_id)) {
393
+ return args.run_id.trim()
394
+ }
395
+
396
+ // 2. Global run index lookup by session ID
397
+ const sessionId = context.sessionID
398
+ const projectDir = resolveProjectDir(context)
399
+ if (isNonEmptyString(sessionId)) {
400
+ const resolveCanonicalRunId = deps.resolveCanonicalRunId ?? resolveRunIdFromOpencodeSession
401
+ const resolved = resolveCanonicalRunId(sessionId, projectDir)
402
+ if (isNonEmptyString(resolved)) {
403
+ return resolved
404
+ }
439
405
  }
440
406
 
441
- const canonicalFindings = state.findings
442
- .map((finding, index) => {
443
- const normalized = normalizeToCanonicalFinding(finding, runId, index + 1)
444
- for (const diag of normalized.diagnostics) {
445
- diagnostics.warn(
446
- "REPORT_INPUT_LEGACY_FINDING_NORMALIZED",
447
- `[index:${index}] ${diag.message}`,
448
- diag.field,
449
- )
407
+ // When caller provides inline report_input, skip filesystem discovery —
408
+ // the caller already has their data and filesystem state may belong to a different run.
409
+ if (isNonEmptyString(args.report_input)) {
410
+ return undefined
411
+ }
412
+
413
+ // 3. Per-session state files (per-session managers write to sessions/state-{sessionId}.json)
414
+ const STALE_STATE_TTL_MS = 24 * 60 * 60 * 1000
415
+ const sessionsDir = path.join(projectDir, ".argus", "sessions")
416
+ try {
417
+ const entries = readdirSync(sessionsDir)
418
+ const stateFiles = entries.filter((e) => e.startsWith("state-") && e.endsWith(".json"))
419
+ const ranked = stateFiles
420
+ .map((name) => {
421
+ const filePath = path.join(sessionsDir, name)
422
+ try {
423
+ return { name, path: filePath, mtime: statSync(filePath).mtimeMs }
424
+ } catch {
425
+ return null
426
+ }
427
+ })
428
+ .filter((entry): entry is NonNullable<typeof entry> => entry !== null)
429
+ .sort((a, b) => b.mtime - a.mtime)
430
+
431
+ for (const entry of ranked) {
432
+ try {
433
+ const stateRaw = JSON.parse(readFileSync(entry.path, "utf-8")) as Record<string, unknown>
434
+ const stateSessionId = stateRaw.sessionId
435
+ const savedAt = typeof stateRaw.savedAt === "number" ? stateRaw.savedAt : 0
436
+ const isFresh = Date.now() - savedAt < STALE_STATE_TTL_MS
437
+ if (
438
+ typeof stateSessionId === "string" &&
439
+ stateSessionId.trim().length > 0 &&
440
+ !stateSessionId.startsWith("ses_") &&
441
+ isFresh
442
+ ) {
443
+ const resolver = createAuditArtifactResolver(stateSessionId, projectDir)
444
+ const hasArtifacts =
445
+ existsSync(resolver.paths().reportInputFile) || existsSync(resolver.paths().journalFile)
446
+ if (hasArtifacts) {
447
+ return stateSessionId
448
+ }
449
+ }
450
+ } catch {
451
+ /* skip unreadable session file */
450
452
  }
451
- return normalized.data
452
- })
453
- .filter((finding) => finding.check.length > 0 && finding.file.length > 0)
453
+ }
454
+ } catch {
455
+ /* sessions dir doesn't exist */
456
+ }
454
457
 
455
- return {
456
- run_id: runId,
457
- seq: state.toolsExecuted.length + canonicalFindings.length,
458
- session_id: sessionId,
459
- tool_call_id: "legacy-adapter",
460
- source: "report-generator-legacy-adapter",
461
- schema_version: SCHEMA_VERSION,
462
- projectDir: state.projectDir || resolveProjectDir(context),
463
- findings: canonicalFindings,
464
- toolsExecuted: state.toolsExecuted.map((toolExec) => ({
465
- ...toolExec,
466
- run_id: runId,
467
- schema_version: SCHEMA_VERSION,
468
- })),
469
- scope: state.scope,
470
- soloditResults: state.soloditResults,
471
- fuzzCounterexamples: state.fuzzCounterexamples,
472
- coverageReport: state.coverageReport,
473
- gasHotspots: state.gasHotspots,
474
- proxyContracts: state.proxyContracts,
475
- patternVersion: state.patternVersion,
476
- skillsLoaded: state.skillsLoaded,
458
+ // 4. Shared audit state (legacy fallback)
459
+ try {
460
+ const sharedStatePath = path.join(projectDir, ".argus", "argus-state.json")
461
+ if (existsSync(sharedStatePath)) {
462
+ const stateRaw = JSON.parse(readFileSync(sharedStatePath, "utf-8")) as Record<string, unknown>
463
+ const stateSessionId = stateRaw.sessionId
464
+ const savedAt = typeof stateRaw.savedAt === "number" ? stateRaw.savedAt : 0
465
+ const isFresh = Date.now() - savedAt < STALE_STATE_TTL_MS
466
+ if (
467
+ typeof stateSessionId === "string" &&
468
+ stateSessionId.trim().length > 0 &&
469
+ !stateSessionId.startsWith("ses_") &&
470
+ isFresh
471
+ ) {
472
+ const resolver = createAuditArtifactResolver(stateSessionId, projectDir)
473
+ const hasArtifacts =
474
+ existsSync(resolver.paths().reportInputFile) || existsSync(resolver.paths().journalFile)
475
+ if (hasArtifacts) {
476
+ return stateSessionId
477
+ }
478
+ }
479
+ }
480
+ } catch {
481
+ /* fallback path */
477
482
  }
483
+
484
+ return undefined
485
+ }
486
+
487
+ function finalizeReportInputSelection(
488
+ reportInput: ReportInput,
489
+ diagnostics: ReturnType<typeof createDropDiagnosticsCollector>,
490
+ expectedRunId?: string,
491
+ ): ParseReportInputResult {
492
+ if (reportInput.run_id.startsWith("ses_")) {
493
+ diagnostics.error(
494
+ "REPORT_INPUT_RUN_ID_MISMATCH",
495
+ "ReportInput run_id must be a canonical run identifier, not an OpenCode session id (ses_*).",
496
+ "run_id",
497
+ )
498
+ throwContractMismatch(
499
+ "ReportInput contract mismatch: run_id/session_id conflation detected",
500
+ diagnostics.getDiagnostics(),
501
+ )
502
+ }
503
+
504
+ if (expectedRunId && reportInput.run_id !== expectedRunId) {
505
+ diagnostics.error(
506
+ "REPORT_INPUT_CANONICAL_RUN_MISMATCH",
507
+ `ReportInput run_id ${reportInput.run_id} does not match canonical run_id ${expectedRunId}.`,
508
+ "run_id",
509
+ )
510
+ throwContractMismatch(
511
+ "ReportInput contract mismatch: report_input run_id diverges from canonical run_id",
512
+ diagnostics.getDiagnostics(),
513
+ )
514
+ }
515
+
516
+ return { reportInput, diagnostics: diagnostics.getDiagnostics() }
478
517
  }
479
518
 
480
519
  function parseReportInputPayload(
481
520
  args: ReportGeneratorArgs,
482
521
  context: ToolContext,
522
+ expectedRunId: string | undefined,
483
523
  ): ParseReportInputResult {
484
524
  const diagnostics = createDropDiagnosticsCollector(
485
525
  "warn",
@@ -487,7 +527,7 @@ function parseReportInputPayload(
487
527
  "argus_generate_report",
488
528
  )
489
529
 
490
- if (typeof args.report_input === "string" && args.report_input.trim().length > 0) {
530
+ if (isNonEmptyString(args.report_input)) {
491
531
  let parsed: unknown
492
532
  try {
493
533
  parsed = JSON.parse(args.report_input)
@@ -503,44 +543,109 @@ function parseReportInputPayload(
503
543
  )
504
544
  }
505
545
 
546
+ normalizeToolsExecutedDefaults(parsed, expectedRunId, diagnostics)
547
+
506
548
  const validation = validateReportInput(parsed)
507
549
  if (!validation.success) {
508
550
  for (const error of validation.errors) {
509
- diagnostics.error(
510
- "REPORT_INPUT_CONTRACT_MISMATCH",
551
+ diagnostics.warn(
552
+ "REPORT_INPUT_INLINE_VALIDATION_FAILED",
511
553
  `${error.field}: ${error.message}`,
512
554
  error.field,
513
555
  )
514
556
  }
515
- throwContractMismatch(
516
- "ReportInput contract mismatch: report_input failed schema validation",
517
- diagnostics.getDiagnostics(),
518
- )
519
- }
520
-
521
- if (typeof args.audit_state === "string" && args.audit_state.trim().length > 0) {
522
557
  diagnostics.warn(
523
- "REPORT_INPUT_LEGACY_FIELD_IGNORED",
524
- "Both report_input and audit_state were provided; audit_state is ignored.",
525
- "audit_state",
558
+ "REPORT_INPUT_INLINE_FALLTHROUGH",
559
+ `Inline report_input failed validation (${validation.errors.length} errors). Falling back to disk artifact.`,
560
+ "report_input",
526
561
  )
562
+ } else {
563
+ return finalizeReportInputSelection(validation.data, diagnostics, expectedRunId)
527
564
  }
528
-
529
- return { reportInput: validation.data, diagnostics: diagnostics.getDiagnostics() }
530
565
  }
531
566
 
532
- if (typeof args.audit_state === "string" && args.audit_state.trim().length > 0) {
533
- const legacy = parseAuditStateWithDiagnostics(args.audit_state, { dropPolicy: "warn" })
534
- for (const diagnostic of legacy.diagnostics) {
535
- diagnostics.warn(diagnostic.reason.code, diagnostic.reason.message, diagnostic.reason.field)
567
+ const effectiveRunId =
568
+ (isNonEmptyString(args.run_id) ? args.run_id.trim() : undefined) ?? expectedRunId
569
+
570
+ if (isNonEmptyString(effectiveRunId)) {
571
+ const projectDir = resolveProjectDir(context)
572
+ const resolver = createAuditArtifactResolver(effectiveRunId, projectDir)
573
+
574
+ const dedupedFile = resolver.paths().dedupedFindingsFile
575
+ if (existsSync(dedupedFile)) {
576
+ try {
577
+ const dedupedArtifact = JSON.parse(readFileSync(dedupedFile, "utf-8")) as {
578
+ findings?: unknown[]
579
+ }
580
+ if (Array.isArray(dedupedArtifact.findings) && dedupedArtifact.findings.length > 0) {
581
+ const reportInputFile = resolver.paths().reportInputFile
582
+ let baseInput: Record<string, unknown> = {}
583
+ if (existsSync(reportInputFile)) {
584
+ try {
585
+ baseInput = JSON.parse(readFileSync(reportInputFile, "utf-8")) as Record<
586
+ string,
587
+ unknown
588
+ >
589
+ } catch {
590
+ /* use empty base */
591
+ }
592
+ }
593
+ const merged = {
594
+ ...baseInput,
595
+ run_id: effectiveRunId,
596
+ findings: dedupedArtifact.findings,
597
+ }
598
+ const validation = validateReportInput(merged)
599
+ if (validation.success) {
600
+ return finalizeReportInputSelection(validation.data, diagnostics, expectedRunId)
601
+ }
602
+ }
603
+ } catch {
604
+ /* deduped file unreadable — fall through to report-input.json */
605
+ }
536
606
  }
537
- const reportInput = buildLegacyCompatibleReportInput(legacy.state, context, diagnostics)
538
- return { reportInput, diagnostics: diagnostics.getDiagnostics() }
539
- }
540
607
 
608
+ const reportInputFile = resolver.paths().reportInputFile
609
+ if (existsSync(reportInputFile)) {
610
+ diagnostics.warn(
611
+ "REPORT_INPUT_DISK_FALLBACK",
612
+ `No report_input provided; reading materialized report-input.json from disk for run ${effectiveRunId}.`,
613
+ "report_input",
614
+ )
615
+ let parsed: unknown
616
+ try {
617
+ parsed = JSON.parse(readFileSync(reportInputFile, "utf-8"))
618
+ } catch {
619
+ diagnostics.error(
620
+ "REPORT_INPUT_DISK_CORRUPT",
621
+ `Materialized report-input.json for run ${effectiveRunId} is not valid JSON.`,
622
+ "report_input",
623
+ )
624
+ throwContractMismatch(
625
+ "ReportInput contract mismatch: corrupted disk artifact",
626
+ diagnostics.getDiagnostics(),
627
+ )
628
+ }
629
+ const validation = validateReportInput(parsed)
630
+ if (!validation.success) {
631
+ for (const error of validation.errors) {
632
+ diagnostics.error(
633
+ "REPORT_INPUT_DISK_VALIDATION_FAILED",
634
+ `${error.field}: ${error.message}`,
635
+ error.field,
636
+ )
637
+ }
638
+ throwContractMismatch(
639
+ "ReportInput contract mismatch: disk artifact failed schema validation",
640
+ diagnostics.getDiagnostics(),
641
+ )
642
+ }
643
+ return finalizeReportInputSelection(validation.data, diagnostics, expectedRunId)
644
+ }
645
+ }
541
646
  diagnostics.error(
542
647
  "REPORT_INPUT_MISSING",
543
- "Missing report_input payload. Provide report_input (preferred) or legacy audit_state for transition.",
648
+ `Missing report_input payload. args.run_id=${args.run_id ?? "undefined"}, expectedRunId=${expectedRunId ?? "undefined"}. Provide report_input (preferred) or run_id for disk fallback.`,
544
649
  "report_input",
545
650
  )
546
651
  throwContractMismatch(
@@ -549,135 +654,6 @@ function parseReportInputPayload(
549
654
  )
550
655
  }
551
656
 
552
- function emitDropDiagnosticsForFindings(
553
- rawItems: unknown[],
554
- normalized: Record<string, unknown>[],
555
- validFindings: Finding[],
556
- diag: ReturnType<typeof createDropDiagnosticsCollector>,
557
- ): void {
558
- const droppedCount = rawItems.length - validFindings.length
559
- if (droppedCount <= 0) return
560
-
561
- for (const item of normalized) {
562
- if (hasMinimumFindingFields(item)) continue
563
- const missing: string[] = []
564
- if (typeof item.check !== "string" || (item.check as string).length === 0) missing.push("check")
565
- if (typeof item.file !== "string") missing.push("file")
566
- if (!Array.isArray(item.lines) || (item.lines as unknown[]).length !== 2) missing.push("lines")
567
- diag.error(
568
- "MISSING_REQUIRED_FIELD",
569
- `Finding dropped: missing ${missing.join(", ") || "unknown fields"} after normalization`,
570
- missing[0],
571
- )
572
- }
573
- }
574
-
575
- export function parseAuditState(auditState: string, options?: ParseAuditStateOptions): AuditState {
576
- const policy = options?.dropPolicy ?? "warn"
577
- const diag = createDropDiagnosticsCollector(policy, "report-generator")
578
-
579
- let parsed: unknown
580
- try {
581
- parsed = JSON.parse(auditState)
582
- } catch {
583
- diag.error("MALFORMED_JSON", "audit_state is not valid JSON")
584
- diag.throwIfStrict()
585
- throw new Error(
586
- "audit_state is not valid JSON — expected an AuditState object or Finding[] array",
587
- )
588
- }
589
-
590
- if (Array.isArray(parsed)) {
591
- const rawItems = parsed as unknown[]
592
- const normalized = rawItems
593
- .filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
594
- .map((item) => normalizeRawFinding(item))
595
- const validFindings = normalized
596
- .filter(hasMinimumFindingFields)
597
- .map((f) => normalizeFinding(f as Record<string, unknown>))
598
- emitDropDiagnosticsForFindings(rawItems, normalized, validFindings, diag)
599
- diag.throwIfStrict()
600
- return emptyAuditState(validFindings)
601
- }
602
-
603
- if (
604
- typeof parsed === "object" &&
605
- parsed !== null &&
606
- Array.isArray((parsed as AuditState).findings)
607
- ) {
608
- const state = parsed as AuditState
609
- const rawFindings = state.findings as unknown[]
610
- const normalized = rawFindings
611
- .filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
612
- .map((item) => normalizeRawFinding(item))
613
- const validFindings = normalized
614
- .filter(hasMinimumFindingFields)
615
- .map((f) => normalizeFinding(f as Record<string, unknown>))
616
- emitDropDiagnosticsForFindings(rawFindings, normalized, validFindings, diag)
617
- diag.throwIfStrict()
618
- return {
619
- ...emptyAuditState(),
620
- ...state,
621
- findings: validFindings,
622
- }
623
- }
624
-
625
- return emptyAuditState()
626
- }
627
-
628
- export function parseAuditStateWithDiagnostics(
629
- auditState: string,
630
- options?: ParseAuditStateOptions,
631
- ): ParseAuditStateResult {
632
- const policy = options?.dropPolicy ?? "warn"
633
- const diag = createDropDiagnosticsCollector(policy, "report-generator")
634
-
635
- let parsed: unknown
636
- try {
637
- parsed = JSON.parse(auditState)
638
- } catch {
639
- diag.error("MALFORMED_JSON", "audit_state is not valid JSON")
640
- diag.throwIfStrict()
641
- return { state: emptyAuditState(), diagnostics: diag.getDiagnostics() }
642
- }
643
-
644
- if (Array.isArray(parsed)) {
645
- const rawItems = parsed as unknown[]
646
- const normalized = rawItems
647
- .filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
648
- .map((item) => normalizeRawFinding(item))
649
- const validFindings = normalized
650
- .filter(hasMinimumFindingFields)
651
- .map((f) => normalizeFinding(f as Record<string, unknown>))
652
- emitDropDiagnosticsForFindings(rawItems, normalized, validFindings, diag)
653
- diag.throwIfStrict()
654
- return { state: emptyAuditState(validFindings), diagnostics: diag.getDiagnostics() }
655
- }
656
-
657
- if (
658
- typeof parsed === "object" &&
659
- parsed !== null &&
660
- Array.isArray((parsed as AuditState).findings)
661
- ) {
662
- const auditStateObj = parsed as AuditState
663
- const rawFindings = auditStateObj.findings as unknown[]
664
- const normalized = rawFindings
665
- .filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
666
- .map((item) => normalizeRawFinding(item))
667
- const validFindings = normalized
668
- .filter(hasMinimumFindingFields)
669
- .map((f) => normalizeFinding(f as Record<string, unknown>))
670
- emitDropDiagnosticsForFindings(rawFindings, normalized, validFindings, diag)
671
- diag.throwIfStrict()
672
- return {
673
- state: { ...emptyAuditState(), ...auditStateObj, findings: validFindings },
674
- diagnostics: diag.getDiagnostics(),
675
- }
676
- }
677
-
678
- return { state: emptyAuditState(), diagnostics: diag.getDiagnostics() }
679
- }
680
-
681
657
  function normalizeTitle(check: string): string {
682
658
  if (!check || typeof check !== "string") return "Unknown Check"
683
659
  return check
@@ -755,7 +731,7 @@ function getExtendedFinding(finding: Finding): Finding & ReportFindingFields {
755
731
 
756
732
  function getFindingImpact(finding: Finding): string {
757
733
  const extended = getExtendedFinding(finding)
758
- if (typeof extended.impact === "string" && extended.impact.trim().length > 0) {
734
+ if (isNonEmptyString(extended.impact)) {
759
735
  return extended.impact.trim()
760
736
  }
761
737
  return MISSING_IMPACT_TEXT
@@ -763,10 +739,10 @@ function getFindingImpact(finding: Finding): string {
763
739
 
764
740
  function getFindingRecommendation(finding: Finding): string {
765
741
  const extended = getExtendedFinding(finding)
766
- if (typeof extended.recommendation === "string" && extended.recommendation.trim().length > 0) {
742
+ if (isNonEmptyString(extended.recommendation)) {
767
743
  return extended.recommendation.trim()
768
744
  }
769
- if (typeof finding.remediation === "string" && finding.remediation.trim().length > 0) {
745
+ if (isNonEmptyString(finding.remediation)) {
770
746
  return finding.remediation.trim()
771
747
  }
772
748
  return MISSING_RECOMMENDATION_TEXT
@@ -774,10 +750,10 @@ function getFindingRecommendation(finding: Finding): string {
774
750
 
775
751
  function getPocEvidence(finding: Finding): string | undefined {
776
752
  const extended = getExtendedFinding(finding)
777
- if (typeof extended.proofOfConcept === "string" && extended.proofOfConcept.trim().length > 0) {
753
+ if (isNonEmptyString(extended.proofOfConcept)) {
778
754
  return extended.proofOfConcept.trim()
779
755
  }
780
- if (typeof finding.exploitReference === "string" && finding.exploitReference.trim().length > 0) {
756
+ if (isNonEmptyString(finding.exploitReference)) {
781
757
  return finding.exploitReference.trim()
782
758
  }
783
759
  return undefined
@@ -972,17 +948,17 @@ function formatDuration(ms: number): string {
972
948
  export function buildProvenanceAppendix(
973
949
  state: AuditState,
974
950
  threshold: SeverityThreshold,
975
- includedCount: number,
951
+ reportFindings: Finding[],
976
952
  ): string {
977
953
  const lines: string[] = ["## Appendix: Data Provenance"]
978
954
 
979
- lines.push("- Data source: `report_input` payload (legacy `audit_state` supported via adapter)")
955
+ lines.push("- Data source: `report_input` payload")
980
956
  lines.push(`- Severity threshold applied: ${threshold}`)
981
- lines.push(`- Findings included in report: ${includedCount}`)
957
+ lines.push(`- Findings included in report: ${reportFindings.length}`)
982
958
 
983
- if (state.findings.length > 0) {
959
+ if (reportFindings.length > 0) {
984
960
  const sourceCounts: Record<string, number> = {}
985
- for (const f of state.findings) {
961
+ for (const f of reportFindings) {
986
962
  sourceCounts[f.source] = (sourceCounts[f.source] ?? 0) + 1
987
963
  }
988
964
  lines.push("")
@@ -1098,10 +1074,51 @@ export async function executeReportGeneration(
1098
1074
  const includeExecutiveSummary = args.include_executive_summary ?? true
1099
1075
  const threshold = args.severity_threshold ?? "low"
1100
1076
  const qualityGatePolicy = args.quality_gate_policy ?? "warn"
1101
- const { reportInput, diagnostics } = parseReportInputPayload(args, context)
1077
+ const toolCoveragePolicy = args.tool_coverage_policy ?? "enforce"
1078
+ const expectedRunId = resolveExpectedRunId(args, context, deps)
1079
+
1080
+ // Ensure report-input.json is materialized before attempting disk lookup.
1081
+ // Scribe may call generate_report without calling read_findings first,
1082
+ // or read_findings may have materialized under a different run_id.
1083
+ if (typeof expectedRunId === "string" && expectedRunId.length > 0) {
1084
+ const projectDir = resolveProjectDir(context)
1085
+ const resolver = createAuditArtifactResolver(expectedRunId, projectDir)
1086
+ if (!existsSync(resolver.paths().reportInputFile)) {
1087
+ try {
1088
+ const { materializeReportInput } = await import(
1089
+ "../features/persistent-state/findings-materializer"
1090
+ )
1091
+ await materializeReportInput(expectedRunId, projectDir, context.sessionID)
1092
+ } catch {
1093
+ /* Best-effort: parseReportInputPayload will produce a clear error if the file is still missing */
1094
+ }
1095
+ }
1096
+ }
1097
+
1098
+ const { reportInput, diagnostics } = parseReportInputPayload(args, context, expectedRunId)
1099
+
1102
1100
  const preflightPolicy = args.preflight_policy ?? "warn"
1103
1101
  let preflightWarningSection: string | null = null
1104
1102
  const warningBullets: string[] = []
1103
+
1104
+ // Hard gate: refuse to generate a report if key audit tools have not been executed
1105
+ if (toolCoveragePolicy !== "skip") {
1106
+ const missingTools = computeMissingKeyTools(
1107
+ reportInput.toolsExecuted,
1108
+ reportInput.unavailableTools,
1109
+ )
1110
+ if (missingTools.length > 0) {
1111
+ const toolList = missingTools.join(", ")
1112
+ if (toolCoveragePolicy === "enforce") {
1113
+ throw new Error(
1114
+ `Tool coverage gate failed: the following key audit tools have not been executed: ${toolList}. ` +
1115
+ 'Run the missing tools before generating a report, or pass tool_coverage_policy: "warn" to override.',
1116
+ )
1117
+ }
1118
+ warningBullets.push(`- Tool coverage incomplete: ${toolList} not executed`)
1119
+ }
1120
+ }
1121
+
1105
1122
  try {
1106
1123
  const readEventsFn = deps.readEvents ?? readEvents
1107
1124
  const events = await readEventsFn(reportInput.run_id, reportInput.projectDir)
@@ -1182,7 +1199,22 @@ export async function executeReportGeneration(
1182
1199
  )
1183
1200
  }
1184
1201
  const counts = calculateCounts(findings)
1185
- const auditDate = new Date().toISOString().slice(0, 10)
1202
+ // Derive audit date from the run's start time for deterministic output.
1203
+ // Falls back to the earliest toolsExecuted timestamp, then current date as last resort.
1204
+ // Exclude UNKNOWN_TIMESTAMP_SENTINEL (patched-in value for missing timestamps).
1205
+ const runStartTime = reportInput.toolsExecuted.reduce(
1206
+ (earliest, exec) =>
1207
+ typeof exec.startTime === "number" &&
1208
+ exec.startTime > UNKNOWN_TIMESTAMP_SENTINEL &&
1209
+ exec.startTime < earliest
1210
+ ? exec.startTime
1211
+ : earliest,
1212
+ Number.MAX_SAFE_INTEGER,
1213
+ )
1214
+ const auditDate =
1215
+ runStartTime < Number.MAX_SAFE_INTEGER
1216
+ ? new Date(runStartTime).toISOString().slice(0, 10)
1217
+ : new Date().toISOString().slice(0, 10)
1186
1218
 
1187
1219
  context.metadata({ title: `Generate audit report: ${args.project_name}` })
1188
1220
 
@@ -1237,10 +1269,13 @@ export async function executeReportGeneration(
1237
1269
  sections.push(preflightWarningSection)
1238
1270
  }
1239
1271
 
1240
- sections.push(buildProvenanceAppendix(state, threshold, findings.length))
1272
+ sections.push(buildProvenanceAppendix(state, threshold, findings))
1241
1273
 
1242
1274
  // Embed report metadata for single-writer policy enforcement
1243
- const runId = reportInput.run_id || state.sessionId || ""
1275
+ const runId = expectedRunId ?? reportInput.run_id
1276
+ if (runId.startsWith("ses_")) {
1277
+ throw new Error("Report generation requires canonical run_id; received OpenCode session id")
1278
+ }
1244
1279
  if (runId) {
1245
1280
  sections.push(buildReportMetadataComment(runId))
1246
1281
  }
@@ -1258,6 +1293,7 @@ export async function executeReportGeneration(
1258
1293
  report: reportMarkdown,
1259
1294
  findingsCount: counts,
1260
1295
  filename: canonicalFilename,
1296
+ run_id: runId,
1261
1297
  contentHash,
1262
1298
  qualityGates,
1263
1299
  contractDiagnostics: diagnostics,
@@ -1267,8 +1303,17 @@ export async function executeReportGeneration(
1267
1303
  const loadConfig = deps.loadConfig ?? loadArgusConfig
1268
1304
  const projectDir = resolveProjectDir(context)
1269
1305
  const config = loadConfig(projectDir)
1270
- const outputDir = config.reporting?.output_dir ?? ".argus/reports/"
1271
- const fullPath = path.join(projectDir, outputDir, canonicalFilename)
1306
+ const rawOutputDir = config.reporting?.output_dir ?? ".argus/reports/"
1307
+ const resolvedOutput = path.resolve(projectDir, rawOutputDir)
1308
+ const projectRoot = projectDir.endsWith(path.sep) ? projectDir : projectDir + path.sep
1309
+ if (resolvedOutput !== projectDir && !resolvedOutput.startsWith(projectRoot)) {
1310
+ result.error = {
1311
+ code: "OUTPUT_DIR_TRAVERSAL",
1312
+ message: `output_dir "${rawOutputDir}" resolves outside the project root. Report not written.`,
1313
+ }
1314
+ return result
1315
+ }
1316
+ const fullPath = path.join(resolvedOutput, canonicalFilename)
1272
1317
 
1273
1318
  // Single-writer policy: check for duplicate writes with same run_id
1274
1319
  if (runId) {
@@ -1285,6 +1330,10 @@ export async function executeReportGeneration(
1285
1330
  const logger = createLogger()
1286
1331
  const message = err instanceof Error ? err.message : String(err)
1287
1332
  logger.warn(`Failed to write report to disk: ${message}`)
1333
+ result.error = {
1334
+ code: "WRITE_FAILED",
1335
+ message,
1336
+ }
1288
1337
  }
1289
1338
 
1290
1339
  return result
@@ -1292,20 +1341,39 @@ export async function executeReportGeneration(
1292
1341
 
1293
1342
  export const reportGeneratorTool = tool({
1294
1343
  description:
1295
- "Generate a professional markdown security audit report from versioned ReportInput payloads with legacy audit_state compatibility.",
1344
+ "Generate a professional markdown security audit report. Pass project_name, scope, and run_id — the tool reads the materialized ReportInput artifact from disk automatically.",
1296
1345
  args: {
1297
1346
  project_name: tool.schema.string(),
1298
1347
  scope: tool.schema.array(tool.schema.string()),
1299
1348
  include_executive_summary: tool.schema.boolean().default(true),
1300
1349
  severity_threshold: tool.schema
1301
1350
  .enum(["critical", "high", "medium", "low", "informational"])
1302
- .default("low"),
1303
- report_input: tool.schema.string().optional(),
1304
- audit_state: tool.schema.string().optional(),
1351
+ .default("informational"),
1305
1352
  preflight_policy: tool.schema.enum(["warn", "strict-fail"]).optional(),
1353
+ tool_coverage_policy: tool.schema
1354
+ .enum(["enforce", "warn", "skip"])
1355
+ .optional()
1356
+ .describe(
1357
+ "Controls whether report generation requires key audit tools to have been executed. " +
1358
+ "Defaults to 'enforce'.",
1359
+ ),
1360
+ run_id: tool.schema
1361
+ .string()
1362
+ .optional()
1363
+ .describe(
1364
+ "The canonical run ID from <argus-context>. The tool reads the materialized report-input.json from disk using this ID.",
1365
+ ),
1306
1366
  },
1307
1367
  async execute(args, context) {
1308
1368
  const result = await executeReportGeneration(args, context)
1309
- return JSON.stringify(result)
1369
+ // Return a slim payload to avoid OpenCode truncating large tool results.
1370
+ // The full markdown is already written to disk at result.filePath.
1371
+ // Truncated JSON breaks tool-tracking-hook parsing, which prevents
1372
+ // reportGenerated from being set and blocks run finalization.
1373
+ const { report, ...slimResult } = result
1374
+ return JSON.stringify({
1375
+ ...slimResult,
1376
+ reportSummary: `Report written to disk (${report.length} bytes, ${report.split("\n").length} lines). See filePath.`,
1377
+ })
1310
1378
  },
1311
1379
  })