solidity-argus 0.3.7 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) 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 +24 -2
  22. package/src/agents/scribe-prompt.ts +34 -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/cli/commands/install.ts +74 -33
  27. package/src/config/loader.ts +29 -5
  28. package/src/config/schema.ts +45 -45
  29. package/src/constants/defaults.ts +1 -0
  30. package/src/create-hooks.ts +806 -173
  31. package/src/create-managers.ts +4 -2
  32. package/src/create-tools.ts +5 -1
  33. package/src/features/audit-enforcer/audit-enforcer.ts +1 -11
  34. package/src/features/background-agent/background-manager.ts +32 -5
  35. package/src/features/error-recovery/tool-error-recovery.ts +1 -0
  36. package/src/features/persistent-state/audit-state-manager.ts +272 -29
  37. package/src/features/persistent-state/event-sink.ts +96 -25
  38. package/src/features/persistent-state/findings-materializer.ts +68 -2
  39. package/src/features/persistent-state/global-run-index.ts +86 -8
  40. package/src/features/persistent-state/index.ts +7 -1
  41. package/src/features/persistent-state/run-finalizer.ts +116 -7
  42. package/src/features/persistent-state/run-pruner.ts +93 -0
  43. package/src/hooks/agent-tracker.ts +14 -2
  44. package/src/hooks/compaction-hook.ts +7 -16
  45. package/src/hooks/config-handler.ts +83 -29
  46. package/src/hooks/context-budget.ts +4 -5
  47. package/src/hooks/event-hook.ts +213 -57
  48. package/src/hooks/knowledge-sync-hook.ts +2 -3
  49. package/src/hooks/safe-create-hook.ts +13 -1
  50. package/src/hooks/system-prompt-hook.ts +20 -39
  51. package/src/hooks/tool-tracking-hook.ts +602 -323
  52. package/src/index.ts +15 -1
  53. package/src/knowledge/scvd-client.ts +2 -4
  54. package/src/knowledge/scvd-errors.ts +25 -2
  55. package/src/knowledge/scvd-index.ts +7 -5
  56. package/src/knowledge/scvd-sync.ts +6 -6
  57. package/src/managers/types.ts +20 -2
  58. package/src/shared/agent-names.ts +23 -0
  59. package/src/shared/audit-artifact-resolver.ts +8 -3
  60. package/src/shared/audit-phases.ts +12 -0
  61. package/src/shared/cache-paths.ts +41 -0
  62. package/src/shared/drop-diagnostics.ts +2 -2
  63. package/src/shared/forge-errors.ts +31 -0
  64. package/src/shared/forge-runner.ts +30 -0
  65. package/src/shared/format-error.ts +3 -0
  66. package/src/shared/index.ts +9 -0
  67. package/src/shared/key-tools.ts +39 -0
  68. package/src/shared/logger.ts +7 -7
  69. package/src/shared/path-containment.ts +25 -0
  70. package/src/shared/path-utils.ts +11 -0
  71. package/src/shared/report-path-resolver.ts +4 -2
  72. package/src/shared/safe-emit.ts +24 -0
  73. package/src/shared/token-utils.ts +5 -0
  74. package/src/shared/type-guards.ts +8 -0
  75. package/src/shared/validation-constants.ts +52 -0
  76. package/src/skills/analysis/cluster.ts +1 -114
  77. package/src/skills/analysis/normalize.ts +2 -114
  78. package/src/skills/analysis/stopwords.ts +109 -0
  79. package/src/skills/argus-skill-resolver.ts +6 -3
  80. package/src/solodit-lifecycle.ts +153 -37
  81. package/src/state/adapters.ts +60 -66
  82. package/src/state/finding-aggregation.ts +6 -8
  83. package/src/state/finding-fingerprint.ts +1 -1
  84. package/src/state/finding-store.ts +31 -9
  85. package/src/state/index.ts +1 -1
  86. package/src/state/projectors.ts +27 -19
  87. package/src/state/schemas.ts +8 -32
  88. package/src/state/types.ts +3 -0
  89. package/src/tools/contract-analyzer-tool.ts +4 -6
  90. package/src/tools/forge-coverage-tool.ts +10 -35
  91. package/src/tools/forge-fuzz-tool.ts +21 -51
  92. package/src/tools/forge-test-tool.ts +25 -47
  93. package/src/tools/gas-analysis-tool.ts +12 -41
  94. package/src/tools/pattern-checker-tool.ts +37 -15
  95. package/src/tools/pattern-loader.ts +18 -4
  96. package/src/tools/persist-deduped-tool.ts +94 -0
  97. package/src/tools/proxy-detection-tool.ts +35 -34
  98. package/src/tools/read-findings-tool.ts +390 -0
  99. package/src/tools/record-finding-tool.ts +130 -25
  100. package/src/tools/report-generator-tool.ts +475 -327
  101. package/src/tools/report-preflight.ts +5 -1
  102. package/src/tools/slither-tool.ts +55 -16
  103. package/src/tools/solodit-search-tool.ts +260 -112
  104. package/src/tools/sync-knowledge-tool.ts +2 -3
  105. package/src/utils/solidity-parser.ts +39 -24
  106. package/src/features/migration/index.ts +0 -14
  107. package/src/features/migration/migration-adapter.ts +0 -151
  108. package/src/features/migration/parity-telemetry.ts +0 -133
@@ -1,14 +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"
15
+ import { isNonEmptyString } from "../shared/type-guards"
16
+ import { SEVERITY_RANK } from "../shared/validation-constants"
12
17
  import { normalizeToCanonicalFinding } from "../state/adapters"
13
18
  import {
14
19
  compareIssueFingerprintSets,
@@ -16,11 +21,13 @@ import {
16
21
  } from "../state/finding-aggregation"
17
22
  import { projectFindings, stableHash } from "../state/projectors"
18
23
  import { type ReportInput, SCHEMA_VERSION, validateReportInput } from "../state/schemas"
19
- import type { AuditState, Finding, FindingSeverity } from "../state/types"
24
+ import type { ArgusAgentName, AuditState, Finding, FindingSeverity } from "../state/types"
20
25
  import { checkReportPreflight } from "./report-preflight"
21
26
 
22
27
  type SeverityThreshold = "critical" | "high" | "medium" | "low" | "informational"
23
28
 
29
+ type ToolCoveragePolicy = "enforce" | "warn" | "skip"
30
+
24
31
  type ReportGeneratorArgs = {
25
32
  project_name: string
26
33
  scope: string[]
@@ -28,8 +35,9 @@ type ReportGeneratorArgs = {
28
35
  severity_threshold?: SeverityThreshold
29
36
  quality_gate_policy?: QualityGatePolicy
30
37
  report_input?: string
31
- audit_state?: string
32
38
  preflight_policy?: PreflightPolicy
39
+ tool_coverage_policy?: ToolCoveragePolicy
40
+ run_id?: string
33
41
  }
34
42
 
35
43
  type FindingsCount = {
@@ -73,6 +81,7 @@ export type ReportGenerationDependencies = {
73
81
  runId: string,
74
82
  projectDir: string,
75
83
  ) => Promise<import("../state/schemas").AuditEvent[]>
84
+ resolveCanonicalRunId?: (sessionId: string, projectDir: string) => string | null | undefined
76
85
  }
77
86
 
78
87
  export const SINGLE_WRITER_POLICY_VERSION = "1.0.0"
@@ -148,13 +157,8 @@ const FINDING_WEIGHT: Record<FindingSeverity, number> = {
148
157
  Informational: 1,
149
158
  }
150
159
 
151
- const SEVERITY_RANK: Record<FindingSeverity, number> = {
152
- Critical: 0,
153
- High: 1,
154
- Medium: 2,
155
- Low: 3,
156
- Informational: 4,
157
- }
160
+ /** Sentinel for missing/unknown tool execution timestamps (schema requires startTime > 0). */
161
+ const UNKNOWN_TIMESTAMP_SENTINEL = 1
158
162
 
159
163
  const MISSING_IMPACT_TEXT = "Impact details were not provided in the finding payload."
160
164
  const MISSING_RECOMMENDATION_TEXT =
@@ -176,19 +180,6 @@ function emptyCounts(): FindingsCount {
176
180
  }
177
181
  }
178
182
 
179
- function emptyAuditState(findings: Finding[] = []): AuditState {
180
- return {
181
- sessionId: "",
182
- projectDir: "",
183
- contractsReviewed: [],
184
- findings,
185
- toolsExecuted: [],
186
- currentPhase: "complete",
187
- scope: [],
188
- startTime: 0,
189
- }
190
- }
191
-
192
183
  /**
193
184
  * Parse a location string like "File.sol:18-22" or "File.sol:18" into { file, lines }.
194
185
  * Returns undefined if the string doesn't match a recognized format.
@@ -237,11 +228,15 @@ export function normalizeRawFinding(raw: Record<string, unknown>): Record<string
237
228
  }
238
229
  }
239
230
 
240
- // file + lines: accept location string as alias
241
- if (typeof result.file !== "string" && typeof result.location === "string") {
231
+ // file + lines: accept location string as alias.
232
+ // Always attempt to extract lines from location, even when file is already set.
233
+ // LLMs commonly provide both file and location (e.g. file="src/Vault.sol", location="Vault.sol:18-23").
234
+ if (typeof result.location === "string") {
242
235
  const parsed = parseLocationString(result.location as string)
243
236
  if (parsed) {
244
- result.file = parsed.file
237
+ if (typeof result.file !== "string" || (result.file as string).length === 0) {
238
+ result.file = parsed.file
239
+ }
245
240
  if (!Array.isArray(result.lines) || (result.lines as unknown[]).length !== 2) {
246
241
  result.lines = parsed.lines
247
242
  }
@@ -298,84 +293,47 @@ export function normalizeRawFinding(raw: Record<string, unknown>): Record<string
298
293
  result.description = result.check
299
294
  }
300
295
 
296
+ if (!Array.isArray(result.lines) || (result.lines as unknown[]).length !== 2) {
297
+ result.lines = [0, 0]
298
+ }
299
+
301
300
  return result
302
301
  }
303
302
 
304
- function hasMinimumFindingFields(
305
- f: unknown,
306
- ): f is { check: string; file: string; lines: [number, number] } {
307
- if (typeof f !== "object" || f === null) return false
308
- const obj = f as Record<string, unknown>
309
- return (
310
- typeof obj.check === "string" &&
311
- obj.check.length > 0 &&
312
- typeof obj.file === "string" &&
313
- Array.isArray(obj.lines) &&
314
- obj.lines.length === 2
315
- )
303
+ type ParseReportInputResult = {
304
+ reportInput: ReportInput
305
+ diagnostics: DropDiagnostic[]
316
306
  }
317
307
 
318
- const VALID_SEVERITIES: ReadonlySet<string> = new Set([
319
- "Critical",
320
- "High",
321
- "Medium",
322
- "Low",
323
- "Informational",
324
- ])
325
- const VALID_SOURCES: ReadonlySet<string> = new Set([
326
- "slither",
327
- "manual",
328
- "pattern",
329
- "scvd",
330
- "solodit",
331
- "fuzz",
308
+ const VALID_AGENT_VALUES = new Set<ArgusAgentName>([
309
+ "argus",
310
+ "sentinel",
311
+ "pythia",
312
+ "scribe",
313
+ "unknown",
332
314
  ])
333
315
 
334
- function normalizeFinding(f: Record<string, unknown>): Finding {
335
- const severity =
336
- typeof f.severity === "string" && VALID_SEVERITIES.has(f.severity)
337
- ? (f.severity as Finding["severity"])
338
- : "Informational"
339
- const confidence =
340
- typeof f.confidence === "string" && ["High", "Medium", "Low"].includes(f.confidence)
341
- ? (f.confidence as Finding["confidence"])
342
- : "Low"
343
- const source =
344
- typeof f.source === "string" && VALID_SOURCES.has(f.source)
345
- ? (f.source as Finding["source"])
346
- : "manual"
347
- const description = typeof f.description === "string" ? f.description : (f.check as string)
348
- const id = typeof f.id === "string" ? f.id : `${f.check}:${f.file}:${(f.lines as number[])[0]}`
349
- return {
350
- id,
351
- check: f.check as string,
352
- severity,
353
- confidence,
354
- description,
355
- file: f.file as string,
356
- lines: f.lines as [number, number],
357
- source,
358
- remediation: typeof f.remediation === "string" ? f.remediation : undefined,
359
- exploitReference: typeof f.exploitReference === "string" ? f.exploitReference : undefined,
360
- ...(typeof f.impact === "string" ? { impact: f.impact } : {}),
361
- ...(typeof f.recommendation === "string" ? { recommendation: f.recommendation } : {}),
362
- ...(typeof f.proofOfConcept === "string" ? { proofOfConcept: f.proofOfConcept } : {}),
363
- ...(typeof f.proof_of_concept === "string" ? { proofOfConcept: f.proof_of_concept } : {}),
364
- } as Finding
365
- }
366
-
367
- export type ParseAuditStateOptions = {
368
- dropPolicy?: DropPolicy
369
- }
370
-
371
- export type ParseAuditStateResult = {
372
- state: AuditState
373
- diagnostics: DropDiagnostic[]
374
- }
375
-
376
- type ParseReportInputResult = {
377
- reportInput: ReportInput
378
- diagnostics: DropDiagnostic[]
316
+ function normalizeDedupedFindings(
317
+ rawFindings: unknown[],
318
+ runId: string,
319
+ projectDir: string,
320
+ dedupedBy: string,
321
+ ): Record<string, unknown>[] {
322
+ const reportedByAgent: ArgusAgentName = VALID_AGENT_VALUES.has(dedupedBy as ArgusAgentName)
323
+ ? (dedupedBy as ArgusAgentName)
324
+ : "scribe"
325
+ return rawFindings.map((raw, index) => {
326
+ const input = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {}
327
+ const normalized = normalizeRawFinding(input)
328
+ const result = normalizeToCanonicalFinding(
329
+ normalized,
330
+ runId,
331
+ index + 1,
332
+ { reportedByAgent },
333
+ projectDir,
334
+ )
335
+ return result.data as unknown as Record<string, unknown>
336
+ })
379
337
  }
380
338
 
381
339
  function diagnosticsSummary(diagnostics: DropDiagnostic[]): string {
@@ -407,80 +365,193 @@ function reportInputToAuditState(reportInput: ReportInput): AuditState {
407
365
  proxyContracts: reportInput.proxyContracts,
408
366
  patternVersion: reportInput.patternVersion,
409
367
  skillsLoaded: reportInput.skillsLoaded,
368
+ unavailableTools: reportInput.unavailableTools,
410
369
  }
411
370
  }
412
371
 
413
- function buildLegacyCompatibleReportInput(
414
- state: AuditState,
415
- context: ToolContext,
372
+ function normalizeToolsExecutedDefaults(
373
+ parsed: unknown,
374
+ expectedRunId: string | undefined,
416
375
  diagnostics: ReturnType<typeof createDropDiagnosticsCollector>,
417
- ): ReportInput {
418
- diagnostics.warn(
419
- "REPORT_INPUT_DEPRECATED_LEGACY_PAYLOAD",
420
- "Legacy audit_state payload is deprecated; pass report_input with canonical ReportInput schema.",
421
- "audit_state",
422
- )
423
-
424
- const runId = state.sessionId || context.sessionID || "legacy-run"
425
- const sessionId = state.sessionId || context.sessionID || runId
376
+ ): void {
377
+ if (!parsed || typeof parsed !== "object") return
378
+ const obj = parsed as Record<string, unknown>
379
+ if (!Array.isArray(obj.toolsExecuted)) return
380
+
381
+ const runId = (typeof obj.run_id === "string" && obj.run_id) || expectedRunId || "unknown"
382
+ let patched = false
383
+
384
+ for (const entry of obj.toolsExecuted) {
385
+ if (!entry || typeof entry !== "object") continue
386
+ const rec = entry as Record<string, unknown>
387
+ if (typeof rec.startTime !== "number" || rec.startTime <= 0) {
388
+ rec.startTime = UNKNOWN_TIMESTAMP_SENTINEL
389
+ patched = true
390
+ }
391
+ if (typeof rec.success !== "boolean") {
392
+ rec.success = true
393
+ patched = true
394
+ }
395
+ if (typeof rec.findingsCount !== "number" || rec.findingsCount < 0) {
396
+ rec.findingsCount = 0
397
+ patched = true
398
+ }
399
+ if (!isNonEmptyString(rec.run_id)) {
400
+ rec.run_id = runId
401
+ patched = true
402
+ }
403
+ if (!isNonEmptyString(rec.schema_version)) {
404
+ rec.schema_version = SCHEMA_VERSION
405
+ patched = true
406
+ }
407
+ }
426
408
 
427
- if (!state.sessionId) {
409
+ if (patched) {
428
410
  diagnostics.warn(
429
- "REPORT_INPUT_SYNTHESIZED_SESSION",
430
- "Legacy payload missing sessionId; synthesized session_id from tool context/run_id.",
431
- "session_id",
411
+ "REPORT_INPUT_TOOLS_EXECUTED_NORMALIZED",
412
+ "toolsExecuted entries were missing canonical fields (startTime, success, findingsCount, run_id, schema_version); defaults applied.",
413
+ "toolsExecuted",
432
414
  )
433
415
  }
434
- if (!state.projectDir) {
435
- diagnostics.warn(
436
- "REPORT_INPUT_SYNTHESIZED_PROJECT_DIR",
437
- "Legacy payload missing projectDir; synthesized projectDir from tool context.",
438
- "projectDir",
439
- )
416
+ }
417
+
418
+ function resolveExpectedRunId(
419
+ args: ReportGeneratorArgs,
420
+ context: ToolContext,
421
+ deps: ReportGenerationDependencies,
422
+ ): string | undefined {
423
+ // 1. Explicit run_id from LLM args (highest priority)
424
+ if (isNonEmptyString(args.run_id)) {
425
+ return args.run_id.trim()
426
+ }
427
+
428
+ // 2. Global run index lookup by session ID
429
+ const sessionId = context.sessionID
430
+ const projectDir = resolveProjectDir(context)
431
+ if (isNonEmptyString(sessionId)) {
432
+ const resolveCanonicalRunId = deps.resolveCanonicalRunId ?? resolveRunIdFromOpencodeSession
433
+ const resolved = resolveCanonicalRunId(sessionId, projectDir)
434
+ if (isNonEmptyString(resolved)) {
435
+ return resolved
436
+ }
440
437
  }
441
438
 
442
- const canonicalFindings = state.findings
443
- .map((finding, index) => {
444
- const normalized = normalizeToCanonicalFinding(finding, runId, index + 1)
445
- for (const diag of normalized.diagnostics) {
446
- diagnostics.warn(
447
- "REPORT_INPUT_LEGACY_FINDING_NORMALIZED",
448
- `[index:${index}] ${diag.message}`,
449
- diag.field,
450
- )
439
+ // When caller provides inline report_input, skip filesystem discovery —
440
+ // the caller already has their data and filesystem state may belong to a different run.
441
+ if (isNonEmptyString(args.report_input)) {
442
+ return undefined
443
+ }
444
+
445
+ // 3. Per-session state files (per-session managers write to sessions/state-{sessionId}.json)
446
+ const STALE_STATE_TTL_MS = 24 * 60 * 60 * 1000
447
+ const sessionsDir = path.join(projectDir, ".argus", "sessions")
448
+ try {
449
+ const entries = readdirSync(sessionsDir)
450
+ const stateFiles = entries.filter((e) => e.startsWith("state-") && e.endsWith(".json"))
451
+ const ranked = stateFiles
452
+ .map((name) => {
453
+ const filePath = path.join(sessionsDir, name)
454
+ try {
455
+ return { name, path: filePath, mtime: statSync(filePath).mtimeMs }
456
+ } catch {
457
+ return null
458
+ }
459
+ })
460
+ .filter((entry): entry is NonNullable<typeof entry> => entry !== null)
461
+ .sort((a, b) => b.mtime - a.mtime)
462
+
463
+ for (const entry of ranked) {
464
+ try {
465
+ const stateRaw = JSON.parse(readFileSync(entry.path, "utf-8")) as Record<string, unknown>
466
+ const stateSessionId = stateRaw.sessionId
467
+ const savedAt = typeof stateRaw.savedAt === "number" ? stateRaw.savedAt : 0
468
+ const isFresh = Date.now() - savedAt < STALE_STATE_TTL_MS
469
+ if (
470
+ typeof stateSessionId === "string" &&
471
+ stateSessionId.trim().length > 0 &&
472
+ !stateSessionId.startsWith("ses_") &&
473
+ isFresh
474
+ ) {
475
+ const resolver = createAuditArtifactResolver(stateSessionId, projectDir)
476
+ const hasArtifacts =
477
+ existsSync(resolver.paths().reportInputFile) || existsSync(resolver.paths().journalFile)
478
+ if (hasArtifacts) {
479
+ return stateSessionId
480
+ }
481
+ }
482
+ } catch {
483
+ /* skip unreadable session file */
451
484
  }
452
- return normalized.data
453
- })
454
- .filter((finding) => finding.check.length > 0 && finding.file.length > 0)
485
+ }
486
+ } catch {
487
+ /* sessions dir doesn't exist */
488
+ }
455
489
 
456
- return {
457
- run_id: runId,
458
- seq: state.toolsExecuted.length + canonicalFindings.length,
459
- session_id: sessionId,
460
- tool_call_id: "legacy-adapter",
461
- source: "report-generator-legacy-adapter",
462
- schema_version: SCHEMA_VERSION,
463
- projectDir: state.projectDir || resolveProjectDir(context),
464
- findings: canonicalFindings,
465
- toolsExecuted: state.toolsExecuted.map((toolExec) => ({
466
- ...toolExec,
467
- run_id: runId,
468
- schema_version: SCHEMA_VERSION,
469
- })),
470
- scope: state.scope,
471
- soloditResults: state.soloditResults,
472
- fuzzCounterexamples: state.fuzzCounterexamples,
473
- coverageReport: state.coverageReport,
474
- gasHotspots: state.gasHotspots,
475
- proxyContracts: state.proxyContracts,
476
- patternVersion: state.patternVersion,
477
- skillsLoaded: state.skillsLoaded,
490
+ // 4. Shared audit state (legacy fallback)
491
+ try {
492
+ const sharedStatePath = path.join(projectDir, ".argus", "argus-state.json")
493
+ if (existsSync(sharedStatePath)) {
494
+ const stateRaw = JSON.parse(readFileSync(sharedStatePath, "utf-8")) as Record<string, unknown>
495
+ const stateSessionId = stateRaw.sessionId
496
+ const savedAt = typeof stateRaw.savedAt === "number" ? stateRaw.savedAt : 0
497
+ const isFresh = Date.now() - savedAt < STALE_STATE_TTL_MS
498
+ if (
499
+ typeof stateSessionId === "string" &&
500
+ stateSessionId.trim().length > 0 &&
501
+ !stateSessionId.startsWith("ses_") &&
502
+ isFresh
503
+ ) {
504
+ const resolver = createAuditArtifactResolver(stateSessionId, projectDir)
505
+ const hasArtifacts =
506
+ existsSync(resolver.paths().reportInputFile) || existsSync(resolver.paths().journalFile)
507
+ if (hasArtifacts) {
508
+ return stateSessionId
509
+ }
510
+ }
511
+ }
512
+ } catch {
513
+ /* fallback path */
478
514
  }
515
+
516
+ return undefined
517
+ }
518
+
519
+ function finalizeReportInputSelection(
520
+ reportInput: ReportInput,
521
+ diagnostics: ReturnType<typeof createDropDiagnosticsCollector>,
522
+ expectedRunId?: string,
523
+ ): ParseReportInputResult {
524
+ if (reportInput.run_id.startsWith("ses_")) {
525
+ diagnostics.error(
526
+ "REPORT_INPUT_RUN_ID_MISMATCH",
527
+ "ReportInput run_id must be a canonical run identifier, not an OpenCode session id (ses_*).",
528
+ "run_id",
529
+ )
530
+ throwContractMismatch(
531
+ "ReportInput contract mismatch: run_id/session_id conflation detected",
532
+ diagnostics.getDiagnostics(),
533
+ )
534
+ }
535
+
536
+ if (expectedRunId && reportInput.run_id !== expectedRunId) {
537
+ diagnostics.error(
538
+ "REPORT_INPUT_CANONICAL_RUN_MISMATCH",
539
+ `ReportInput run_id ${reportInput.run_id} does not match canonical run_id ${expectedRunId}.`,
540
+ "run_id",
541
+ )
542
+ throwContractMismatch(
543
+ "ReportInput contract mismatch: report_input run_id diverges from canonical run_id",
544
+ diagnostics.getDiagnostics(),
545
+ )
546
+ }
547
+
548
+ return { reportInput, diagnostics: diagnostics.getDiagnostics() }
479
549
  }
480
550
 
481
551
  function parseReportInputPayload(
482
552
  args: ReportGeneratorArgs,
483
553
  context: ToolContext,
554
+ expectedRunId: string | undefined,
484
555
  ): ParseReportInputResult {
485
556
  const diagnostics = createDropDiagnosticsCollector(
486
557
  "warn",
@@ -488,7 +559,7 @@ function parseReportInputPayload(
488
559
  "argus_generate_report",
489
560
  )
490
561
 
491
- if (typeof args.report_input === "string" && args.report_input.trim().length > 0) {
562
+ if (isNonEmptyString(args.report_input)) {
492
563
  let parsed: unknown
493
564
  try {
494
565
  parsed = JSON.parse(args.report_input)
@@ -504,44 +575,159 @@ function parseReportInputPayload(
504
575
  )
505
576
  }
506
577
 
578
+ normalizeToolsExecutedDefaults(parsed, expectedRunId, diagnostics)
579
+
507
580
  const validation = validateReportInput(parsed)
508
581
  if (!validation.success) {
509
582
  for (const error of validation.errors) {
510
- diagnostics.error(
511
- "REPORT_INPUT_CONTRACT_MISMATCH",
583
+ diagnostics.warn(
584
+ "REPORT_INPUT_INLINE_VALIDATION_FAILED",
512
585
  `${error.field}: ${error.message}`,
513
586
  error.field,
514
587
  )
515
588
  }
516
- throwContractMismatch(
517
- "ReportInput contract mismatch: report_input failed schema validation",
518
- diagnostics.getDiagnostics(),
519
- )
520
- }
521
-
522
- if (typeof args.audit_state === "string" && args.audit_state.trim().length > 0) {
523
589
  diagnostics.warn(
524
- "REPORT_INPUT_LEGACY_FIELD_IGNORED",
525
- "Both report_input and audit_state were provided; audit_state is ignored.",
526
- "audit_state",
590
+ "REPORT_INPUT_INLINE_FALLTHROUGH",
591
+ `Inline report_input failed validation (${validation.errors.length} errors). Falling back to disk artifact.`,
592
+ "report_input",
527
593
  )
594
+ } else {
595
+ return finalizeReportInputSelection(validation.data, diagnostics, expectedRunId)
528
596
  }
529
-
530
- return { reportInput: validation.data, diagnostics: diagnostics.getDiagnostics() }
531
597
  }
532
598
 
533
- if (typeof args.audit_state === "string" && args.audit_state.trim().length > 0) {
534
- const legacy = parseAuditStateWithDiagnostics(args.audit_state, { dropPolicy: "warn" })
535
- for (const diagnostic of legacy.diagnostics) {
536
- diagnostics.warn(diagnostic.reason.code, diagnostic.reason.message, diagnostic.reason.field)
599
+ const effectiveRunId =
600
+ (isNonEmptyString(args.run_id) ? args.run_id.trim() : undefined) ?? expectedRunId
601
+
602
+ if (isNonEmptyString(effectiveRunId)) {
603
+ const projectDir = resolveProjectDir(context)
604
+ const resolver = createAuditArtifactResolver(effectiveRunId, projectDir)
605
+
606
+ const dedupedFile = resolver.paths().dedupedFindingsFile
607
+ if (existsSync(dedupedFile)) {
608
+ try {
609
+ const dedupedArtifact = JSON.parse(readFileSync(dedupedFile, "utf-8")) as {
610
+ findings?: unknown[]
611
+ deduped_by?: string
612
+ }
613
+ if (Array.isArray(dedupedArtifact.findings) && dedupedArtifact.findings.length > 0) {
614
+ const reportInputFile = resolver.paths().reportInputFile
615
+ let baseInput: Record<string, unknown> = {}
616
+ if (existsSync(reportInputFile)) {
617
+ try {
618
+ baseInput = JSON.parse(readFileSync(reportInputFile, "utf-8")) as Record<
619
+ string,
620
+ unknown
621
+ >
622
+ } catch {
623
+ /* use empty base */
624
+ }
625
+ }
626
+ const normalizedFindings = normalizeDedupedFindings(
627
+ dedupedArtifact.findings,
628
+ effectiveRunId,
629
+ projectDir,
630
+ typeof dedupedArtifact.deduped_by === "string"
631
+ ? dedupedArtifact.deduped_by
632
+ : "scribe",
633
+ )
634
+ const merged: Record<string, unknown> = {
635
+ ...baseInput,
636
+ run_id: effectiveRunId,
637
+ findings: normalizedFindings,
638
+ }
639
+ normalizeToolsExecutedDefaults(merged, effectiveRunId, diagnostics)
640
+ if (typeof merged.seq !== "number" || (merged.seq as number) < 0) {
641
+ merged.seq = 0
642
+ }
643
+ if (typeof merged.session_id !== "string" || (merged.session_id as string).length === 0) {
644
+ merged.session_id = "unknown"
645
+ }
646
+ if (
647
+ typeof merged.tool_call_id !== "string" ||
648
+ (merged.tool_call_id as string).length === 0
649
+ ) {
650
+ merged.tool_call_id = `deduped:${effectiveRunId}`
651
+ }
652
+ if (typeof merged.source !== "string" || (merged.source as string).length === 0) {
653
+ merged.source = "deduped-findings"
654
+ }
655
+ if (
656
+ typeof merged.schema_version !== "string" ||
657
+ merged.schema_version !== SCHEMA_VERSION
658
+ ) {
659
+ merged.schema_version = SCHEMA_VERSION
660
+ }
661
+ if (
662
+ typeof merged.projectDir !== "string" ||
663
+ (merged.projectDir as string).length === 0
664
+ ) {
665
+ merged.projectDir = projectDir
666
+ }
667
+ if (!Array.isArray(merged.scope)) {
668
+ merged.scope = []
669
+ }
670
+ if (!Array.isArray(merged.toolsExecuted)) {
671
+ merged.toolsExecuted = []
672
+ }
673
+ const validation = validateReportInput(merged)
674
+ if (validation.success) {
675
+ return finalizeReportInputSelection(validation.data, diagnostics, expectedRunId)
676
+ }
677
+ for (const error of validation.errors) {
678
+ diagnostics.warn(
679
+ "REPORT_INPUT_DEDUPED_VALIDATION_FAILED",
680
+ `${error.field}: ${error.message}`,
681
+ error.field,
682
+ )
683
+ }
684
+ }
685
+ } catch {
686
+ /* deduped file unreadable — fall through to report-input.json */
687
+ }
537
688
  }
538
- const reportInput = buildLegacyCompatibleReportInput(legacy.state, context, diagnostics)
539
- return { reportInput, diagnostics: diagnostics.getDiagnostics() }
540
- }
541
689
 
690
+ const reportInputFile = resolver.paths().reportInputFile
691
+ if (existsSync(reportInputFile)) {
692
+ diagnostics.warn(
693
+ "REPORT_INPUT_DISK_FALLBACK",
694
+ `No report_input provided; reading materialized report-input.json from disk for run ${effectiveRunId}.`,
695
+ "report_input",
696
+ )
697
+ let parsed: unknown
698
+ try {
699
+ parsed = JSON.parse(readFileSync(reportInputFile, "utf-8"))
700
+ } catch {
701
+ diagnostics.error(
702
+ "REPORT_INPUT_DISK_CORRUPT",
703
+ `Materialized report-input.json for run ${effectiveRunId} is not valid JSON.`,
704
+ "report_input",
705
+ )
706
+ throwContractMismatch(
707
+ "ReportInput contract mismatch: corrupted disk artifact",
708
+ diagnostics.getDiagnostics(),
709
+ )
710
+ }
711
+ const validation = validateReportInput(parsed)
712
+ if (!validation.success) {
713
+ for (const error of validation.errors) {
714
+ diagnostics.error(
715
+ "REPORT_INPUT_DISK_VALIDATION_FAILED",
716
+ `${error.field}: ${error.message}`,
717
+ error.field,
718
+ )
719
+ }
720
+ throwContractMismatch(
721
+ "ReportInput contract mismatch: disk artifact failed schema validation",
722
+ diagnostics.getDiagnostics(),
723
+ )
724
+ }
725
+ return finalizeReportInputSelection(validation.data, diagnostics, expectedRunId)
726
+ }
727
+ }
542
728
  diagnostics.error(
543
729
  "REPORT_INPUT_MISSING",
544
- "Missing report_input payload. Provide report_input (preferred) or legacy audit_state for transition.",
730
+ `Missing report_input payload. args.run_id=${args.run_id ?? "undefined"}, expectedRunId=${expectedRunId ?? "undefined"}. Provide report_input (preferred) or run_id for disk fallback.`,
545
731
  "report_input",
546
732
  )
547
733
  throwContractMismatch(
@@ -550,135 +736,6 @@ function parseReportInputPayload(
550
736
  )
551
737
  }
552
738
 
553
- function emitDropDiagnosticsForFindings(
554
- rawItems: unknown[],
555
- normalized: Record<string, unknown>[],
556
- validFindings: Finding[],
557
- diag: ReturnType<typeof createDropDiagnosticsCollector>,
558
- ): void {
559
- const droppedCount = rawItems.length - validFindings.length
560
- if (droppedCount <= 0) return
561
-
562
- for (const item of normalized) {
563
- if (hasMinimumFindingFields(item)) continue
564
- const missing: string[] = []
565
- if (typeof item.check !== "string" || (item.check as string).length === 0) missing.push("check")
566
- if (typeof item.file !== "string") missing.push("file")
567
- if (!Array.isArray(item.lines) || (item.lines as unknown[]).length !== 2) missing.push("lines")
568
- diag.error(
569
- "MISSING_REQUIRED_FIELD",
570
- `Finding dropped: missing ${missing.join(", ") || "unknown fields"} after normalization`,
571
- missing[0],
572
- )
573
- }
574
- }
575
-
576
- export function parseAuditState(auditState: string, options?: ParseAuditStateOptions): AuditState {
577
- const policy = options?.dropPolicy ?? "warn"
578
- const diag = createDropDiagnosticsCollector(policy, "report-generator")
579
-
580
- let parsed: unknown
581
- try {
582
- parsed = JSON.parse(auditState)
583
- } catch {
584
- diag.error("MALFORMED_JSON", "audit_state is not valid JSON")
585
- diag.throwIfStrict()
586
- throw new Error(
587
- "audit_state is not valid JSON — expected an AuditState object or Finding[] array",
588
- )
589
- }
590
-
591
- if (Array.isArray(parsed)) {
592
- const rawItems = parsed as unknown[]
593
- const normalized = rawItems
594
- .filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
595
- .map((item) => normalizeRawFinding(item))
596
- const validFindings = normalized
597
- .filter(hasMinimumFindingFields)
598
- .map((f) => normalizeFinding(f as Record<string, unknown>))
599
- emitDropDiagnosticsForFindings(rawItems, normalized, validFindings, diag)
600
- diag.throwIfStrict()
601
- return emptyAuditState(validFindings)
602
- }
603
-
604
- if (
605
- typeof parsed === "object" &&
606
- parsed !== null &&
607
- Array.isArray((parsed as AuditState).findings)
608
- ) {
609
- const state = parsed as AuditState
610
- const rawFindings = state.findings as unknown[]
611
- const normalized = rawFindings
612
- .filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
613
- .map((item) => normalizeRawFinding(item))
614
- const validFindings = normalized
615
- .filter(hasMinimumFindingFields)
616
- .map((f) => normalizeFinding(f as Record<string, unknown>))
617
- emitDropDiagnosticsForFindings(rawFindings, normalized, validFindings, diag)
618
- diag.throwIfStrict()
619
- return {
620
- ...emptyAuditState(),
621
- ...state,
622
- findings: validFindings,
623
- }
624
- }
625
-
626
- return emptyAuditState()
627
- }
628
-
629
- export function parseAuditStateWithDiagnostics(
630
- auditState: string,
631
- options?: ParseAuditStateOptions,
632
- ): ParseAuditStateResult {
633
- const policy = options?.dropPolicy ?? "warn"
634
- const diag = createDropDiagnosticsCollector(policy, "report-generator")
635
-
636
- let parsed: unknown
637
- try {
638
- parsed = JSON.parse(auditState)
639
- } catch {
640
- diag.error("MALFORMED_JSON", "audit_state is not valid JSON")
641
- diag.throwIfStrict()
642
- return { state: emptyAuditState(), diagnostics: diag.getDiagnostics() }
643
- }
644
-
645
- if (Array.isArray(parsed)) {
646
- const rawItems = parsed as unknown[]
647
- const normalized = rawItems
648
- .filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
649
- .map((item) => normalizeRawFinding(item))
650
- const validFindings = normalized
651
- .filter(hasMinimumFindingFields)
652
- .map((f) => normalizeFinding(f as Record<string, unknown>))
653
- emitDropDiagnosticsForFindings(rawItems, normalized, validFindings, diag)
654
- diag.throwIfStrict()
655
- return { state: emptyAuditState(validFindings), diagnostics: diag.getDiagnostics() }
656
- }
657
-
658
- if (
659
- typeof parsed === "object" &&
660
- parsed !== null &&
661
- Array.isArray((parsed as AuditState).findings)
662
- ) {
663
- const auditStateObj = parsed as AuditState
664
- const rawFindings = auditStateObj.findings as unknown[]
665
- const normalized = rawFindings
666
- .filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null)
667
- .map((item) => normalizeRawFinding(item))
668
- const validFindings = normalized
669
- .filter(hasMinimumFindingFields)
670
- .map((f) => normalizeFinding(f as Record<string, unknown>))
671
- emitDropDiagnosticsForFindings(rawFindings, normalized, validFindings, diag)
672
- diag.throwIfStrict()
673
- return {
674
- state: { ...emptyAuditState(), ...auditStateObj, findings: validFindings },
675
- diagnostics: diag.getDiagnostics(),
676
- }
677
- }
678
-
679
- return { state: emptyAuditState(), diagnostics: diag.getDiagnostics() }
680
- }
681
-
682
739
  function normalizeTitle(check: string): string {
683
740
  if (!check || typeof check !== "string") return "Unknown Check"
684
741
  return check
@@ -756,7 +813,7 @@ function getExtendedFinding(finding: Finding): Finding & ReportFindingFields {
756
813
 
757
814
  function getFindingImpact(finding: Finding): string {
758
815
  const extended = getExtendedFinding(finding)
759
- if (typeof extended.impact === "string" && extended.impact.trim().length > 0) {
816
+ if (isNonEmptyString(extended.impact)) {
760
817
  return extended.impact.trim()
761
818
  }
762
819
  return MISSING_IMPACT_TEXT
@@ -764,10 +821,10 @@ function getFindingImpact(finding: Finding): string {
764
821
 
765
822
  function getFindingRecommendation(finding: Finding): string {
766
823
  const extended = getExtendedFinding(finding)
767
- if (typeof extended.recommendation === "string" && extended.recommendation.trim().length > 0) {
824
+ if (isNonEmptyString(extended.recommendation)) {
768
825
  return extended.recommendation.trim()
769
826
  }
770
- if (typeof finding.remediation === "string" && finding.remediation.trim().length > 0) {
827
+ if (isNonEmptyString(finding.remediation)) {
771
828
  return finding.remediation.trim()
772
829
  }
773
830
  return MISSING_RECOMMENDATION_TEXT
@@ -775,10 +832,10 @@ function getFindingRecommendation(finding: Finding): string {
775
832
 
776
833
  function getPocEvidence(finding: Finding): string | undefined {
777
834
  const extended = getExtendedFinding(finding)
778
- if (typeof extended.proofOfConcept === "string" && extended.proofOfConcept.trim().length > 0) {
835
+ if (isNonEmptyString(extended.proofOfConcept)) {
779
836
  return extended.proofOfConcept.trim()
780
837
  }
781
- if (typeof finding.exploitReference === "string" && finding.exploitReference.trim().length > 0) {
838
+ if (isNonEmptyString(finding.exploitReference)) {
782
839
  return finding.exploitReference.trim()
783
840
  }
784
841
  return undefined
@@ -973,17 +1030,17 @@ function formatDuration(ms: number): string {
973
1030
  export function buildProvenanceAppendix(
974
1031
  state: AuditState,
975
1032
  threshold: SeverityThreshold,
976
- includedCount: number,
1033
+ reportFindings: Finding[],
977
1034
  ): string {
978
1035
  const lines: string[] = ["## Appendix: Data Provenance"]
979
1036
 
980
- lines.push("- Data source: `report_input` payload (legacy `audit_state` supported via adapter)")
1037
+ lines.push("- Data source: `report_input` payload")
981
1038
  lines.push(`- Severity threshold applied: ${threshold}`)
982
- lines.push(`- Findings included in report: ${includedCount}`)
1039
+ lines.push(`- Findings included in report: ${reportFindings.length}`)
983
1040
 
984
- if (state.findings.length > 0) {
1041
+ if (reportFindings.length > 0) {
985
1042
  const sourceCounts: Record<string, number> = {}
986
- for (const f of state.findings) {
1043
+ for (const f of reportFindings) {
987
1044
  sourceCounts[f.source] = (sourceCounts[f.source] ?? 0) + 1
988
1045
  }
989
1046
  lines.push("")
@@ -1099,10 +1156,51 @@ export async function executeReportGeneration(
1099
1156
  const includeExecutiveSummary = args.include_executive_summary ?? true
1100
1157
  const threshold = args.severity_threshold ?? "low"
1101
1158
  const qualityGatePolicy = args.quality_gate_policy ?? "warn"
1102
- const { reportInput, diagnostics } = parseReportInputPayload(args, context)
1159
+ const toolCoveragePolicy = args.tool_coverage_policy ?? "enforce"
1160
+ const expectedRunId = resolveExpectedRunId(args, context, deps)
1161
+
1162
+ // Ensure report-input.json is materialized before attempting disk lookup.
1163
+ // Scribe may call generate_report without calling read_findings first,
1164
+ // or read_findings may have materialized under a different run_id.
1165
+ if (typeof expectedRunId === "string" && expectedRunId.length > 0) {
1166
+ const projectDir = resolveProjectDir(context)
1167
+ const resolver = createAuditArtifactResolver(expectedRunId, projectDir)
1168
+ if (!existsSync(resolver.paths().reportInputFile)) {
1169
+ try {
1170
+ const { materializeReportInput } = await import(
1171
+ "../features/persistent-state/findings-materializer"
1172
+ )
1173
+ await materializeReportInput(expectedRunId, projectDir, context.sessionID)
1174
+ } catch {
1175
+ /* Best-effort: parseReportInputPayload will produce a clear error if the file is still missing */
1176
+ }
1177
+ }
1178
+ }
1179
+
1180
+ const { reportInput, diagnostics } = parseReportInputPayload(args, context, expectedRunId)
1181
+
1103
1182
  const preflightPolicy = args.preflight_policy ?? "warn"
1104
1183
  let preflightWarningSection: string | null = null
1105
1184
  const warningBullets: string[] = []
1185
+
1186
+ // Hard gate: refuse to generate a report if key audit tools have not been executed
1187
+ if (toolCoveragePolicy !== "skip") {
1188
+ const missingTools = computeMissingKeyTools(
1189
+ reportInput.toolsExecuted,
1190
+ reportInput.unavailableTools,
1191
+ )
1192
+ if (missingTools.length > 0) {
1193
+ const toolList = missingTools.join(", ")
1194
+ if (toolCoveragePolicy === "enforce") {
1195
+ throw new Error(
1196
+ `Tool coverage gate failed: the following key audit tools have not been executed: ${toolList}. ` +
1197
+ 'Run the missing tools before generating a report, or pass tool_coverage_policy: "warn" to override.',
1198
+ )
1199
+ }
1200
+ warningBullets.push(`- Tool coverage incomplete: ${toolList} not executed`)
1201
+ }
1202
+ }
1203
+
1106
1204
  try {
1107
1205
  const readEventsFn = deps.readEvents ?? readEvents
1108
1206
  const events = await readEventsFn(reportInput.run_id, reportInput.projectDir)
@@ -1183,7 +1281,22 @@ export async function executeReportGeneration(
1183
1281
  )
1184
1282
  }
1185
1283
  const counts = calculateCounts(findings)
1186
- const auditDate = new Date().toISOString().slice(0, 10)
1284
+ // Derive audit date from the run's start time for deterministic output.
1285
+ // Falls back to the earliest toolsExecuted timestamp, then current date as last resort.
1286
+ // Exclude UNKNOWN_TIMESTAMP_SENTINEL (patched-in value for missing timestamps).
1287
+ const runStartTime = reportInput.toolsExecuted.reduce(
1288
+ (earliest, exec) =>
1289
+ typeof exec.startTime === "number" &&
1290
+ exec.startTime > UNKNOWN_TIMESTAMP_SENTINEL &&
1291
+ exec.startTime < earliest
1292
+ ? exec.startTime
1293
+ : earliest,
1294
+ Number.MAX_SAFE_INTEGER,
1295
+ )
1296
+ const auditDate =
1297
+ runStartTime < Number.MAX_SAFE_INTEGER
1298
+ ? new Date(runStartTime).toISOString().slice(0, 10)
1299
+ : new Date().toISOString().slice(0, 10)
1187
1300
 
1188
1301
  context.metadata({ title: `Generate audit report: ${args.project_name}` })
1189
1302
 
@@ -1238,10 +1351,13 @@ export async function executeReportGeneration(
1238
1351
  sections.push(preflightWarningSection)
1239
1352
  }
1240
1353
 
1241
- sections.push(buildProvenanceAppendix(state, threshold, findings.length))
1354
+ sections.push(buildProvenanceAppendix(state, threshold, findings))
1242
1355
 
1243
1356
  // Embed report metadata for single-writer policy enforcement
1244
- const runId = reportInput.run_id || state.sessionId || ""
1357
+ const runId = expectedRunId ?? reportInput.run_id
1358
+ if (runId.startsWith("ses_")) {
1359
+ throw new Error("Report generation requires canonical run_id; received OpenCode session id")
1360
+ }
1245
1361
  if (runId) {
1246
1362
  sections.push(buildReportMetadataComment(runId))
1247
1363
  }
@@ -1269,8 +1385,17 @@ export async function executeReportGeneration(
1269
1385
  const loadConfig = deps.loadConfig ?? loadArgusConfig
1270
1386
  const projectDir = resolveProjectDir(context)
1271
1387
  const config = loadConfig(projectDir)
1272
- const outputDir = config.reporting?.output_dir ?? ".argus/reports/"
1273
- const fullPath = path.join(projectDir, outputDir, canonicalFilename)
1388
+ const rawOutputDir = config.reporting?.output_dir ?? ".argus/reports/"
1389
+ const resolvedOutput = path.resolve(projectDir, rawOutputDir)
1390
+ const projectRoot = projectDir.endsWith(path.sep) ? projectDir : projectDir + path.sep
1391
+ if (resolvedOutput !== projectDir && !resolvedOutput.startsWith(projectRoot)) {
1392
+ result.error = {
1393
+ code: "OUTPUT_DIR_TRAVERSAL",
1394
+ message: `output_dir "${rawOutputDir}" resolves outside the project root. Report not written.`,
1395
+ }
1396
+ return result
1397
+ }
1398
+ const fullPath = path.join(resolvedOutput, canonicalFilename)
1274
1399
 
1275
1400
  // Single-writer policy: check for duplicate writes with same run_id
1276
1401
  if (runId) {
@@ -1287,6 +1412,10 @@ export async function executeReportGeneration(
1287
1412
  const logger = createLogger()
1288
1413
  const message = err instanceof Error ? err.message : String(err)
1289
1414
  logger.warn(`Failed to write report to disk: ${message}`)
1415
+ result.error = {
1416
+ code: "WRITE_FAILED",
1417
+ message,
1418
+ }
1290
1419
  }
1291
1420
 
1292
1421
  return result
@@ -1294,20 +1423,39 @@ export async function executeReportGeneration(
1294
1423
 
1295
1424
  export const reportGeneratorTool = tool({
1296
1425
  description:
1297
- "Generate a professional markdown security audit report from versioned ReportInput payloads with legacy audit_state compatibility.",
1426
+ "Generate a professional markdown security audit report. Pass project_name, scope, and run_id — the tool reads the materialized ReportInput artifact from disk automatically.",
1298
1427
  args: {
1299
1428
  project_name: tool.schema.string(),
1300
1429
  scope: tool.schema.array(tool.schema.string()),
1301
1430
  include_executive_summary: tool.schema.boolean().default(true),
1302
1431
  severity_threshold: tool.schema
1303
1432
  .enum(["critical", "high", "medium", "low", "informational"])
1304
- .default("low"),
1305
- report_input: tool.schema.string().optional(),
1306
- audit_state: tool.schema.string().optional(),
1433
+ .default("informational"),
1307
1434
  preflight_policy: tool.schema.enum(["warn", "strict-fail"]).optional(),
1435
+ tool_coverage_policy: tool.schema
1436
+ .enum(["enforce", "warn", "skip"])
1437
+ .optional()
1438
+ .describe(
1439
+ "Controls whether report generation requires key audit tools to have been executed. " +
1440
+ "Defaults to 'enforce'.",
1441
+ ),
1442
+ run_id: tool.schema
1443
+ .string()
1444
+ .optional()
1445
+ .describe(
1446
+ "The canonical run ID from <argus-context>. The tool reads the materialized report-input.json from disk using this ID.",
1447
+ ),
1308
1448
  },
1309
1449
  async execute(args, context) {
1310
1450
  const result = await executeReportGeneration(args, context)
1311
- return JSON.stringify(result)
1451
+ // Return a slim payload to avoid OpenCode truncating large tool results.
1452
+ // The full markdown is already written to disk at result.filePath.
1453
+ // Truncated JSON breaks tool-tracking-hook parsing, which prevents
1454
+ // reportGenerated from being set and blocks run finalization.
1455
+ const { report, ...slimResult } = result
1456
+ return JSON.stringify({
1457
+ ...slimResult,
1458
+ reportSummary: `Report written to disk (${report.length} bytes, ${report.split("\n").length} lines). See filePath.`,
1459
+ })
1312
1460
  },
1313
1461
  })