infraweaver 0.3.7 → 0.3.8

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 (175) hide show
  1. package/README.md +2 -2
  2. package/dist/agents/claude.d.ts +1 -1
  3. package/dist/agents/claudePretoolGate.d.ts +0 -36
  4. package/dist/agents/toolPlaneWatch.d.ts +31 -0
  5. package/dist/cli.mjs +31903 -18736
  6. package/dist/external.d.ts +2 -1
  7. package/dist/index.js +31987 -18749
  8. package/dist/internal.js +132 -52
  9. package/dist/mcp/baseBranch.d.ts +41 -0
  10. package/dist/mcp/changeSummary.d.ts +1 -1
  11. package/dist/mcp/findingAudit.d.ts +2 -0
  12. package/dist/mcp/guardrails.d.ts +38 -0
  13. package/dist/mcp/localContext.d.ts +1 -1
  14. package/dist/mcp/moduleExtraction.d.ts +5 -0
  15. package/dist/mcp/pr.d.ts +0 -31
  16. package/dist/mcp/reproveSuggestions.d.ts +27 -0
  17. package/dist/mcp/review.d.ts +126 -2
  18. package/dist/mcp/reviewCommentLimit.d.ts +17 -0
  19. package/dist/mcp/reviewComments.d.ts +9 -5
  20. package/dist/mcp/reviewDedup.d.ts +1 -0
  21. package/dist/mcp/reviewMarkers.d.ts +10 -0
  22. package/dist/mcp/reviewProvenance.d.ts +20 -6
  23. package/dist/mcp/shared.d.ts +15 -0
  24. package/dist/mcp/suggestionProver.d.ts +3 -2
  25. package/dist/mcp/terraform/checkovSeverities.d.ts +7 -0
  26. package/dist/mcp/terraform/consolidation.d.ts +26 -0
  27. package/dist/mcp/terraform/cost.d.ts +2 -2
  28. package/dist/mcp/terraform/currency.d.ts +11 -0
  29. package/dist/mcp/terraform/deltaSummary.d.ts +11 -1
  30. package/dist/mcp/terraform/emittedArtefacts.d.ts +11 -0
  31. package/dist/mcp/terraform/evidence.d.ts +10 -0
  32. package/dist/mcp/terraform/gateScan.d.ts +2 -16
  33. package/dist/mcp/terraform/hcl.d.ts +15 -0
  34. package/dist/mcp/terraform/initArtefacts.d.ts +21 -0
  35. package/dist/mcp/terraform/localModules.d.ts +29 -0
  36. package/dist/mcp/terraform/moduleFindings.d.ts +38 -0
  37. package/dist/mcp/terraform/refactor/attribution.d.ts +1 -1
  38. package/dist/mcp/terraform/refactor/equivalence.d.ts +7 -0
  39. package/dist/mcp/terraform/refactor/expressions.d.ts +62 -0
  40. package/dist/mcp/terraform/refactor/references.d.ts +15 -0
  41. package/dist/mcp/terraform/refactor/resources.d.ts +11 -0
  42. package/dist/mcp/terraform/refactor/substitution.d.ts +55 -37
  43. package/dist/mcp/terraform/scanDelta.d.ts +28 -0
  44. package/dist/mcp/terraform/scanSession.d.ts +13 -0
  45. package/dist/mcp/terraform/scanners.d.ts +67 -18
  46. package/dist/mcp/terraform/secretScan.d.ts +19 -0
  47. package/dist/mcp/terraform/standardsReport.d.ts +1 -1
  48. package/dist/mcp/terraform/tools/plan.d.ts +1 -1
  49. package/dist/mcp/terraform/tools/scan.d.ts +7 -7
  50. package/dist/mcp/terraform/tools/validate.d.ts +7 -2
  51. package/dist/mcp/terraform/tools/verifyRemediation.d.ts +1 -1
  52. package/dist/mcp/terraform/treeLayout.d.ts +57 -0
  53. package/dist/mcp/terraform/types.d.ts +1 -1
  54. package/dist/phases/applyPlan.d.ts +0 -2
  55. package/dist/toolState.d.ts +3 -0
  56. package/dist/utils/agent/codexOAuth.d.ts +1 -1
  57. package/dist/utils/cli.d.ts +1 -7
  58. package/dist/utils/cloud/cloudReport.d.ts +1 -1
  59. package/dist/utils/config/baseRefConfig.d.ts +1 -1
  60. package/dist/utils/config/infraweaverConfig.d.ts +1 -1
  61. package/dist/utils/config/payload.d.ts +5 -0
  62. package/dist/utils/markdownTable.d.ts +6 -0
  63. package/dist/utils/setup/toolLicensing.d.ts +1 -1
  64. package/dist/utils/unifiedDiff.d.ts +69 -0
  65. package/package.json +34 -28
  66. package/src/agents/claude.ts +48 -13
  67. package/src/agents/claudePretoolGate.ts +9 -17
  68. package/src/agents/sessionLabeler.ts +3 -3
  69. package/src/agents/toolPlaneWatch.ts +71 -0
  70. package/src/external.ts +3 -1
  71. package/src/mcp/assess.ts +8 -8
  72. package/src/mcp/baseBranch.ts +96 -0
  73. package/src/mcp/changeSummary.ts +10 -11
  74. package/src/mcp/checkSuite.ts +7 -10
  75. package/src/mcp/checkout.ts +13 -40
  76. package/src/mcp/crosswalk.ts +5 -0
  77. package/src/mcp/findingAudit.ts +1 -1
  78. package/src/mcp/git.ts +44 -2
  79. package/src/mcp/guardrails.ts +188 -36
  80. package/src/mcp/localContext.ts +2 -0
  81. package/src/mcp/localServer.ts +1 -0
  82. package/src/mcp/moduleExtraction.ts +17 -5
  83. package/src/mcp/moduleTests.ts +9 -2
  84. package/src/mcp/modules.ts +10 -7
  85. package/src/mcp/pr.ts +17 -65
  86. package/src/mcp/reproveSuggestions.ts +86 -0
  87. package/src/mcp/review.ts +249 -75
  88. package/src/mcp/reviewAnchor.ts +11 -23
  89. package/src/mcp/reviewCommentLimit.ts +79 -0
  90. package/src/mcp/reviewComments.ts +34 -81
  91. package/src/mcp/reviewDedup.ts +1 -1
  92. package/src/mcp/reviewMarkers.ts +25 -2
  93. package/src/mcp/reviewProvenance.ts +56 -21
  94. package/src/mcp/roots.ts +3 -2
  95. package/src/mcp/server.ts +2 -1
  96. package/src/mcp/shared.ts +40 -1
  97. package/src/mcp/shell.ts +27 -28
  98. package/src/mcp/suggestionProver.ts +8 -4
  99. package/src/mcp/terraform/checkovSeverities.ts +1156 -0
  100. package/src/mcp/terraform/concernResult.ts +2 -1
  101. package/src/mcp/terraform/consolidation.ts +232 -19
  102. package/src/mcp/terraform/cost.ts +16 -7
  103. package/src/mcp/terraform/cspm.ts +8 -4
  104. package/src/mcp/terraform/currency.ts +28 -12
  105. package/src/mcp/terraform/decisions.ts +1 -1
  106. package/src/mcp/terraform/deltaSummary.ts +50 -1
  107. package/src/mcp/terraform/emittedArtefacts.ts +63 -0
  108. package/src/mcp/terraform/evidence.ts +23 -3
  109. package/src/mcp/terraform/gateScan.ts +191 -128
  110. package/src/mcp/terraform/hcl.ts +27 -2
  111. package/src/mcp/terraform/idleFloor.ts +2 -1
  112. package/src/mcp/terraform/initArtefacts.ts +76 -0
  113. package/src/mcp/terraform/localModules.ts +112 -0
  114. package/src/mcp/terraform/moduleDocs.ts +2 -5
  115. package/src/mcp/terraform/moduleFindings.ts +102 -0
  116. package/src/mcp/terraform/moduleVersionConstraints.ts +1 -1
  117. package/src/mcp/terraform/planPairs.ts +2 -20
  118. package/src/mcp/terraform/policyGate.ts +0 -15
  119. package/src/mcp/terraform/refactor/attribution.ts +39 -5
  120. package/src/mcp/terraform/refactor/equivalence.ts +49 -10
  121. package/src/mcp/terraform/refactor/expressions.ts +323 -0
  122. package/src/mcp/terraform/refactor/references.ts +101 -0
  123. package/src/mcp/terraform/refactor/resources.ts +79 -7
  124. package/src/mcp/terraform/refactor/substitution.ts +852 -256
  125. package/src/mcp/terraform/scanDelta.ts +34 -0
  126. package/src/mcp/terraform/scanSession.ts +30 -0
  127. package/src/mcp/terraform/scannerCache.ts +4 -0
  128. package/src/mcp/terraform/scanners.ts +553 -233
  129. package/src/mcp/terraform/secretScan.ts +131 -0
  130. package/src/mcp/terraform/standardsReport.ts +14 -13
  131. package/src/mcp/terraform/tools/consolidationCandidates.ts +3 -0
  132. package/src/mcp/terraform/tools/emitEvidence.ts +13 -5
  133. package/src/mcp/terraform/tools/emitOscal.ts +3 -2
  134. package/src/mcp/terraform/tools/emitVex.ts +10 -4
  135. package/src/mcp/terraform/tools/equivalenceCheck.ts +139 -21
  136. package/src/mcp/terraform/tools/infracostDiff.ts +2 -2
  137. package/src/mcp/terraform/tools/plan.ts +30 -17
  138. package/src/mcp/terraform/tools/reviewScanDelta.ts +3 -0
  139. package/src/mcp/terraform/tools/scan.ts +5 -3
  140. package/src/mcp/terraform/tools/standardsReport.ts +1 -1
  141. package/src/mcp/terraform/tools/validate.ts +20 -5
  142. package/src/mcp/terraform/tools/verifyRemediation.ts +14 -7
  143. package/src/mcp/terraform/tools/versionCurrency.ts +3 -1
  144. package/src/mcp/terraform/treeLayout.ts +378 -0
  145. package/src/mcp/terraform/types.ts +4 -1
  146. package/src/mcp/terraform/versionRequirements.ts +11 -2
  147. package/src/modes/__snapshots__/assemblePrompt.test.ts.snap +2 -0
  148. package/src/modes/modernize-deprecated.ts +2 -2
  149. package/src/modes/refactor.ts +9 -9
  150. package/src/modes/remediate.ts +1 -1
  151. package/src/modes/terraform-code-review.ts +6 -3
  152. package/src/modes/update-dependencies.ts +6 -6
  153. package/src/phases/applyPlan.ts +33 -14
  154. package/src/phases/applySuggestions.ts +2 -2
  155. package/src/phases/runAgentWithWatchdogs.ts +2 -1
  156. package/src/toolState.ts +7 -0
  157. package/src/utils/agent/codexOAuth.ts +7 -7
  158. package/src/utils/agent/openCodeModels.ts +4 -6
  159. package/src/utils/cli.ts +1 -31
  160. package/src/utils/cloud/runContext.ts +1 -8
  161. package/src/utils/config/baseRefConfig.ts +6 -0
  162. package/src/utils/config/infraweaverConfig.ts +2 -0
  163. package/src/utils/config/payload.ts +41 -0
  164. package/src/utils/github/assets.ts +1 -6
  165. package/src/utils/github/github.ts +42 -150
  166. package/src/utils/github/token.ts +6 -11
  167. package/src/utils/log.ts +4 -5
  168. package/src/utils/markdownTable.ts +11 -0
  169. package/src/utils/prompt/changeImpact.ts +18 -26
  170. package/src/utils/prompt/instructions.ts +1 -1
  171. package/src/utils/prompt/promptDirectives.ts +1 -1
  172. package/src/utils/prompt/toolSelection.ts +11 -2
  173. package/src/utils/setup/toolLicensing.ts +7 -0
  174. package/src/utils/telemetry.ts +2 -1
  175. package/src/utils/unifiedDiff.ts +120 -0
@@ -8,7 +8,13 @@
8
8
  */
9
9
  import { packControlsForConcern, requiredControlViolations } from "#app/mcp/crosswalk";
10
10
  import { evaluateGate } from "#app/mcp/terraform/baseline";
11
- import type { DeltaFinding, GateDelta } from "#app/mcp/terraform/scanDelta";
11
+ import { count } from "#app/mcp/terraform/prose";
12
+ import {
13
+ type DeltaFinding,
14
+ type GateDelta,
15
+ type UndeclaredRename,
16
+ undeclaredRenames,
17
+ } from "#app/mcp/terraform/scanDelta";
12
18
  import { SEVERITY_RANK } from "#app/mcp/terraform/types";
13
19
  import { neutralizeMarkup } from "#app/mcp/untrustedContent";
14
20
 
@@ -50,6 +56,47 @@ export function deltaVerdict(delta: GateDelta, requiredControls: string[]): Delt
50
56
  };
51
57
  }
52
58
 
59
+ /** Whether one introduced finding fails the gate on its own — the same two
60
+ * halves `deltaVerdict` applies to the whole delta. */
61
+ export function failsGate(finding: DeltaFinding, requiredControls: string[]): boolean {
62
+ return (
63
+ !evaluateGate([finding.concern], null).pass ||
64
+ requiredControlViolations([finding.concern], requiredControls).length > 0
65
+ );
66
+ }
67
+
68
+ /** Renames a paragraph names before summarising the rest as a count. */
69
+ const MAX_RENAMES_NAMED = 5;
70
+
71
+ /**
72
+ * The undeclared renames in a delta as one paragraph — what happened, what it
73
+ * costs at apply, and the one thing to do about it — or "" when there are none.
74
+ * `fmt` code-spans a value the way the caller's surface does: every address and
75
+ * directory came out of the PR's own files.
76
+ */
77
+ export function renderUndeclaredRenames(
78
+ renames: readonly UndeclaredRename[],
79
+ fmt: (text: string) => string,
80
+ ): string {
81
+ if (renames.length === 0) return "";
82
+ const one = renames.length === 1;
83
+ const named = renames.slice(0, MAX_RENAMES_NAMED).map((r) => {
84
+ const what =
85
+ r.from.address === r.to.address
86
+ ? `${fmt(r.to.address)} moved from ${fmt(r.from.dir || ".")} to ${fmt(r.to.dir || ".")}`
87
+ : `${fmt(r.from.address)} → ${fmt(r.to.address)}`;
88
+ return `${what} (${count(r.findings, "finding")})`;
89
+ });
90
+ const rest = renames.length - named.length;
91
+ return (
92
+ `**${count(renames.length, "resource")} ${one ? "looks" : "look"} renamed without a \`moved\` block:** ` +
93
+ `${named.join(", ")}${rest > 0 ? `, and ${rest} more` : ""}. ` +
94
+ `Without ${one ? "one" : "them"}, \`terraform apply\` destroys the old resource and creates the new one, ` +
95
+ `and every finding on it counts as new. Add a \`moved { from = … to = … }\` block for ${one ? "it" : "each"} ` +
96
+ "and those findings match their base instead."
97
+ );
98
+ }
99
+
53
100
  /** The compliance controls the introduced findings touch, sorted and unique. */
54
101
  export function controlsTouched(findings: readonly DeltaFinding[]): string[] {
55
102
  return [...new Set(findings.flatMap((f) => packControlsForConcern(f.concern)))].sort();
@@ -88,6 +135,8 @@ export function renderDeltaSummary(input: {
88
135
  ].filter(Boolean);
89
136
  lines.push("", `The gate fails on ${why.join(" and ")}.`);
90
137
  }
138
+ const renames = renderUndeclaredRenames(undeclaredRenames(delta.introduced), cell);
139
+ if (renames) lines.push("", renames);
91
140
  const controls = controlsTouched(delta.introduced);
92
141
  if (controls.length > 0) lines.push("", `Controls touched: ${controls.map(cell).join(", ")}`);
93
142
  if (delta.introduced.length > 0) {
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The compliance artefacts this run's emit tools wrote, and whether a push
3
+ * carries them exactly as written.
4
+ *
5
+ * `terraform_emit_evidence` (and the OSCAL and VEX emitters) write into the
6
+ * workspace — `compliance/…` by default — because the artefact is meant to be
7
+ * committed beside the change it describes: a Refactor PR names its evidence
8
+ * bundle so a reviewer can run `verify-evidence` on it. A guarded mode's push is
9
+ * Terraform-only, which refused that file and sent the agent into untracking it.
10
+ *
11
+ * Opening `compliance/**` would let the agent commit anything there. What is
12
+ * admitted instead is narrower: a path one of these tools wrote this run, with
13
+ * the bytes it wrote. An edited artefact, or a file that only looks like one,
14
+ * is still refused.
15
+ */
16
+ import { createHash } from "node:crypto";
17
+ import { writeFileSync } from "node:fs";
18
+ import { resolve } from "node:path";
19
+ import { scanSessionOf } from "#app/mcp/terraform/scanSession";
20
+
21
+ /** the directory every emit tool writes to by default, repo-relative. */
22
+ const ARTEFACT_DIR = "compliance/";
23
+
24
+ /** The id git gives a blob with these contents — what `git rev-parse HEAD:<path>` prints. */
25
+ export function gitBlobId(bytes: string): string {
26
+ const content = Buffer.from(bytes, "utf8");
27
+ return createHash("sha1").update(`blob ${content.length}\0`).update(content).digest("hex");
28
+ }
29
+
30
+ /** Write an emit tool's artefact to `path` (absolute) and remember the bytes written. */
31
+ export function writeEmittedArtefact(
32
+ toolState: Parameters<typeof scanSessionOf>[0],
33
+ path: string,
34
+ bytes: string,
35
+ ): void {
36
+ writeFileSync(path, bytes);
37
+ const session = scanSessionOf(toolState);
38
+ session.emittedArtefacts ??= {};
39
+ const written = session.emittedArtefacts;
40
+ const key = resolve(path);
41
+ // every version: an emit re-run after the first was committed leaves the
42
+ // commit holding the earlier bytes, which the tool wrote just the same
43
+ written[key] = [...new Set([...(written[key] ?? []), gitBlobId(bytes)])];
44
+ }
45
+
46
+ /**
47
+ * True when `file` — repo-relative, as `git diff` names it — is an artefact an
48
+ * emit tool wrote this run and `committedBlob` (its blob id at the pushed
49
+ * commit, null when absent) is one of the versions it wrote.
50
+ */
51
+ export function isUnchangedEmittedArtefact(
52
+ toolState: Parameters<typeof scanSessionOf>[0],
53
+ repoTop: string,
54
+ file: string,
55
+ committedBlob: string | null,
56
+ ): boolean {
57
+ // only where the emit tools write by default: an `output_path` aimed at
58
+ // `.github/CODEOWNERS` or `package.json` would otherwise replace that file
59
+ // with an artefact and pass as "committed as emitted"
60
+ if (!file.startsWith(ARTEFACT_DIR)) return false;
61
+ const written = scanSessionOf(toolState).emittedArtefacts?.[resolve(repoTop, file)];
62
+ return committedBlob !== null && (written?.includes(committedBlob) ?? false);
63
+ }
@@ -311,6 +311,23 @@ export interface RefactorEquivalenceInput {
311
311
  values_verified?: number;
312
312
  }
313
313
 
314
+ /**
315
+ * What a proposed-unproven change IS, named from the mode that made it. The
316
+ * agent's flag says only that the change is unproven; a signed statement that
317
+ * called a version bump a module adoption would misdescribe the artefact it
318
+ * signs.
319
+ */
320
+ export function proposedChangeOf(mode: string | undefined): string {
321
+ switch (mode) {
322
+ case "UpdateDependencies":
323
+ return "dependency version change";
324
+ case "ModernizeDeprecated":
325
+ return "deprecated-pattern migration";
326
+ default:
327
+ return "third-party-module adoption";
328
+ }
329
+ }
330
+
314
331
  /**
315
332
  * Build the refactor evidence statement (pure). Returns undefined when nothing
316
333
  * refactor-related happened (no equivalence proof, not a proposed-unproven b.4,
@@ -330,8 +347,12 @@ export function buildRefactorStatement(args: {
330
347
  | undefined;
331
348
  /** the agent recorded the reviewed-plan outcome (b.4 with credentials). */
332
349
  reviewedPlan?: boolean;
350
+ /** what the unproven change is (`proposedChangeOf`); a module adoption when
351
+ * absent. */
352
+ change?: string;
333
353
  }): EvidenceRefactorStatement | undefined {
334
354
  const { equivalence, provenance, proposedUnproven, planTotals, reviewedPlan } = args;
355
+ const change = args.change ?? proposedChangeOf(undefined);
335
356
  if (!equivalence && !proposedUnproven && !reviewedPlan && provenance.length === 0)
336
357
  return undefined;
337
358
 
@@ -371,14 +392,13 @@ export function buildRefactorStatement(args: {
371
392
  if (planTotals) {
372
393
  asserts =
373
394
  "nothing about runtime behaviour beyond what the recorded terraform plan shows — this " +
374
- "module adoption is behaviour-ALTERING and ships for human review of that plan.";
395
+ `${change} is behaviour-ALTERING and ships for human review of that plan.`;
375
396
  doesNotAssert =
376
397
  "behaviour-equivalence (it alters behaviour and is not claimed equivalent). A terraform " +
377
398
  `plan WAS recorded (+${planTotals.to_add} ~${planTotals.to_change} -${planTotals.to_destroy}); ` +
378
399
  "the claim rests on a human reviewing that delta, which this bundle cannot assert.";
379
400
  } else {
380
- asserts =
381
- "nothing about runtime behaviour — this third-party-module adoption is PROPOSED only and was not verified against a plan.";
401
+ asserts = `nothing about runtime behaviour — this ${change} is PROPOSED only and was not verified against a plan.`;
382
402
  doesNotAssert =
383
403
  "behaviour-equivalence (it alters behaviour and is not claimed equivalent); no terraform plan was run.";
384
404
  }
@@ -4,6 +4,7 @@ import type { LocalToolContext } from "#app/mcp/localContext";
4
4
  import { walkTfFiles } from "#app/mcp/modules";
5
5
  import { loadBaselineForRun, loadResolvedLedger } from "#app/mcp/terraform/baseline";
6
6
  import { annotateConcerns } from "#app/mcp/terraform/concernResult";
7
+ import { initArtefacts, removeNewInitArtefacts } from "#app/mcp/terraform/initArtefacts";
7
8
  import { lineageResolver, repoFileReader } from "#app/mcp/terraform/lineage";
8
9
  import {
9
10
  computeScanDelta,
@@ -14,10 +15,12 @@ import {
14
15
  parseMovedBlocks,
15
16
  } from "#app/mcp/terraform/scanDelta";
16
17
  import { runScannersCached } from "#app/mcp/terraform/scannerCache";
17
- import { changedTerraformFiles, resetTerraformInitCache } from "#app/mcp/terraform/scanners";
18
+ import { changedTerraformFiles } from "#app/mcp/terraform/scanners";
18
19
  import { scanSessionOf } from "#app/mcp/terraform/scanSession";
20
+ import { scanAddedSecrets } from "#app/mcp/terraform/secretScan";
19
21
  import { run } from "#app/mcp/terraform/subprocess";
20
22
  import { inChangedFiles, type ScanScanners } from "#app/mcp/terraform/tools/scan";
23
+ import { scopeFor, treeLayout } from "#app/mcp/terraform/treeLayout";
21
24
  import {
22
25
  type Concern,
23
26
  isReviewedTerraformFile,
@@ -53,11 +56,10 @@ const realScanners: ScanScanners = { runScannersCached, changedTerraformFiles };
53
56
  export async function runGateScan(
54
57
  ctx: LocalToolContext,
55
58
  scanners: ScanScanners = realScanners,
56
- isTerraform?: (c: Concern) => boolean,
57
59
  ): Promise<Concern[] | undefined> {
58
60
  const cwd = ctx.payload.cwd ?? process.cwd();
59
61
  if (walkTfFiles(cwd).length === 0) return undefined;
60
- return scanTree(ctx, cwd, diffScope(ctx, cwd, scanners), scanners, isTerraform);
62
+ return scanTree(ctx, cwd, diffScope(ctx, cwd, scanners), scanners);
61
63
  }
62
64
 
63
65
  /** What a pull-request review judges: its own file scope, JSON syntax included. */
@@ -68,13 +70,17 @@ function diffScope(ctx: LocalToolContext, cwd: string, scanners: ScanScanners):
68
70
  return (ctx.payload.scanScope ?? "full") === "diff" ? scanners.changedTerraformFiles(cwd) : null;
69
71
  }
70
72
 
71
- /** One gate-configured scan of the tree at `cwd`, kept to `changed` when set. */
73
+ /**
74
+ * One gate-configured scan of the tree at `cwd`, kept to `changed` when set.
75
+ * `scope` limits it to those directories (`scopeFor`).
76
+ */
72
77
  async function scanTree(
73
78
  ctx: LocalToolContext,
74
79
  cwd: string,
75
80
  changed: Set<string> | null,
76
81
  scanners: ScanScanners,
77
82
  isTerraform?: (c: Concern) => boolean,
83
+ scope?: string[],
78
84
  ): Promise<Concern[]> {
79
85
  const outcomes = await scanners.runScannersCached(cwd, {
80
86
  selection: resolveToolSelection(ctx.payload),
@@ -82,6 +88,8 @@ async function scanTree(
82
88
  downloadExternalModules: undefined,
83
89
  toolsNetwork: ctx.payload.toolsNetwork,
84
90
  repeatedLiteralThreshold: ctx.payload.repeatedLiteralThreshold,
91
+ varFiles: ctx.payload.varFiles,
92
+ ...(scope ? { scope } : {}),
85
93
  });
86
94
 
87
95
  const threshold = (ctx.payload.severityThreshold as Severity | undefined) ?? "low";
@@ -91,25 +99,27 @@ async function scanTree(
91
99
  );
92
100
  }
93
101
 
102
+ /** The PR's base, checked out beside the head. */
103
+ interface BaseCheckout {
104
+ mergeBase: string;
105
+ cwd: string;
106
+ close: () => void;
107
+ }
108
+
94
109
  /**
95
- * The scan delta of a pull request: the base scanned with the same scanners and
96
- * configuration as the head, in a detached worktree so the checkout the agent
97
- * works in is never touched, and the two concern sets matched (`scanDelta.ts`).
98
- *
99
- * The base is the merge base of the PR's base commit and HEAD, which is the base
100
- * commit itself on GitHub's `refs/pull/N/merge` checkout and the fork point on a
101
- * head checkout — "what this PR changed" either way.
102
- *
103
- * Undefined when the base commit is not in the clone (a shallow checkout) or the
104
- * worktree cannot be made. The run then gates as it always has, and says why.
110
+ * Check out the base of a pull request in a detached worktree, so the checkout
111
+ * the agent works in is never touched. The base is the merge base of the PR's
112
+ * base commit and HEAD, which is the base commit itself on GitHub's
113
+ * `refs/pull/N/merge` checkout and the fork point on a head checkout — "what
114
+ * this PR changed" either way. Undefined when the base commit is not in the
115
+ * clone (a shallow checkout) or the worktree cannot be made: the run then
116
+ * gates as it always has, and says why.
105
117
  */
106
- export async function runScanDelta(
107
- ctx: LocalToolContext,
108
- pr: Pick<PullRequestRunContext, "baseSha" | "headSha">,
109
- headConcerns: Concern[],
110
- scanners: ScanScanners = realScanners,
111
- ): Promise<GateDelta | undefined> {
112
- const cwd = ctx.payload.cwd ?? process.cwd();
118
+ function checkOutBase(
119
+ cwd: string,
120
+ pr: Pick<PullRequestRunContext, "baseSha">,
121
+ tmp: string,
122
+ ): BaseCheckout | undefined {
113
123
  if (run("git", ["cat-file", "-e", `${pr.baseSha}^{commit}`], cwd).status !== 0) {
114
124
  log.warning(
115
125
  `» scan delta: base commit ${pr.baseSha.slice(0, 7)} is not in this clone — ` +
@@ -119,59 +129,169 @@ export async function runScanDelta(
119
129
  }
120
130
  const mergeBase = run("git", ["merge-base", pr.baseSha, "HEAD"], cwd).stdout.trim() || pr.baseSha;
121
131
  const prefix = run("git", ["rev-parse", "--show-prefix"], cwd).stdout.trim();
122
-
123
132
  // a fresh parent from mkdtemp, the worktree in a not-yet-existing child: git
124
133
  // refuses to add a worktree at a path that exists, and a predictable name is
125
134
  // one a local actor could pre-create.
126
- const parent = mkdtempSync(join(ctx.tmpdir, "scan-delta-base-"));
135
+ const parent = mkdtempSync(join(tmp, "scan-delta-base-"));
127
136
  const worktree = join(parent, "wt");
128
137
  if (run("git", ["worktree", "add", "--detach", worktree, mergeBase], cwd).status !== 0) {
129
138
  rmSync(parent, { recursive: true, force: true });
130
139
  log.warning("» scan delta: could not check out the base — every finding is treated as new.");
131
140
  return undefined;
132
141
  }
133
- const started = Date.now();
142
+ return {
143
+ mergeBase,
144
+ cwd: prefix ? join(worktree, prefix) : worktree,
145
+ close: () => {
146
+ run("git", ["worktree", "remove", "--force", worktree], cwd);
147
+ rmSync(parent, { recursive: true, force: true });
148
+ },
149
+ };
150
+ }
151
+
152
+ /** Every file the PR changed, relative to `cwd`; null when git cannot say. */
153
+ function changedSince(cwd: string, mergeBase: string): string[] | null {
154
+ const diff = run("git", ["diff", "--name-only", "--no-renames", mergeBase, "HEAD"], cwd);
155
+ if (diff.status !== 0) return null;
156
+ const prefix = run("git", ["rev-parse", "--show-prefix"], cwd).stdout.trim();
157
+ return diff.stdout
158
+ .split("\n")
159
+ .map((f) => f.trim())
160
+ .filter((f) => f !== "" && f.startsWith(prefix))
161
+ .map((f) => f.slice(prefix.length));
162
+ }
163
+
164
+ /**
165
+ * The directories both scans of a review cover: the root modules the PR's
166
+ * changes reach, on either side, with their local modules. A root the PR cannot
167
+ * reach has the same findings on both sides, so scanning it changes no count
168
+ * the review reports or the gate judges — only how long it takes. Undefined
169
+ * (the whole tree) when the change cannot be attributed.
170
+ */
171
+ function reviewScope(ctx: LocalToolContext, cwd: string, base: BaseCheckout): string[] | undefined {
172
+ const changed = changedSince(cwd, base.mergeBase);
173
+ if (!changed) return undefined;
174
+ const head = treeLayout(cwd, ctx.payload.varFiles);
175
+ const scope = scopeFor([head, treeLayout(base.cwd, ctx.payload.varFiles)], changed);
176
+ if (!scope) {
177
+ log.info("» scan delta: a scanner setting changed — scanning every root module");
178
+ return undefined;
179
+ }
180
+ const reached = head.roots.filter((r) => scope.includes(r.dir)).length;
181
+ log.info(
182
+ `» scan delta: scanning the ${reached} of ${head.roots.length} root module(s) this change reaches`,
183
+ );
184
+ return scope;
185
+ }
186
+
187
+ /**
188
+ * The head and base scans of a pull request, run together and over the same
189
+ * scope, and the delta between them (`scanDelta.ts`). Undefined when the
190
+ * checkout has no Terraform. No delta when the base could not be checked out
191
+ * or scanned: the run then gates every finding, as a run without one always has.
192
+ */
193
+ async function reviewScans(
194
+ ctx: LocalToolContext,
195
+ pr: Pick<PullRequestRunContext, "baseSha" | "headSha">,
196
+ scanners: ScanScanners,
197
+ ): Promise<{ head: Concern[]; delta?: GateDelta } | undefined> {
198
+ const cwd = ctx.payload.cwd ?? process.cwd();
199
+ if (walkTfFiles(cwd).length === 0) return undefined;
200
+ const base = checkOutBase(cwd, pr, ctx.tmpdir);
134
201
  try {
135
- const baseCwd = prefix ? join(worktree, prefix) : worktree;
136
202
  const changed = diffScope(ctx, cwd, scanners);
137
- const baseConcerns = existsSync(baseCwd)
138
- ? await scanTree(ctx, baseCwd, changed, scanners, reviewedConcern)
139
- : [];
140
- const moved = new Map<string, MovedBlock[]>();
141
- const modules = new Map<string, Map<string, string>>();
142
- const addresses = new Map<string, Set<string>>();
143
- const readHead = repoFileReader(cwd);
144
- for (const rel of walkTfFiles(cwd)) {
145
- const src = readHead(rel);
146
- if (src === null) continue;
147
- const dir = posix.dirname(rel.replace(/\\/g, "/"));
148
- const key = dir === "." ? "" : dir;
149
- moved.set(key, [...(moved.get(key) ?? []), ...parseMovedBlocks(src)]);
150
- modules.set(key, new Map([...(modules.get(key) ?? []), ...localModuleSources(src)]));
151
- const set = addresses.get(key) ?? new Set<string>();
152
- for (const a of declaredAddresses(src)) set.add(a);
153
- addresses.set(key, set);
203
+ const scope = base ? reviewScope(ctx, cwd, base) : undefined;
204
+ const started = Date.now();
205
+ const scanBase = async (b: BaseCheckout) =>
206
+ existsSync(b.cwd) ? scanTree(ctx, b.cwd, changed, scanners, reviewedConcern, scope) : [];
207
+ // settled, not `all`: a failed head scan must not leave the base scanning
208
+ // a worktree the `finally` below removes
209
+ const [head, baseScan, secrets] = await Promise.allSettled([
210
+ scanTree(ctx, cwd, changed, scanners, reviewedConcern, scope),
211
+ base ? scanBase(base) : Promise.resolve([]),
212
+ secretsOf(ctx, cwd, base),
213
+ ]);
214
+ if (head.status === "rejected") throw head.reason;
215
+ if (!base) return { head: head.value };
216
+ const added = secrets.status === "fulfilled" ? secrets.value : [];
217
+ if (baseScan.status === "rejected") {
218
+ log.warning(
219
+ `» scan delta: base scan failed, every finding is treated as new — ${baseScan.reason}`,
220
+ );
221
+ return { head: head.value };
154
222
  }
155
- const delta = computeScanDelta({
156
- base: baseConcerns.filter((c) => !c.suppression),
157
- head: headConcerns.filter((c) => !c.suppression),
158
- readBase: repoFileReader(baseCwd),
159
- readHead,
160
- moved,
161
- moduleSources: (dir) => modules.get(dir) ?? new Map(),
162
- headAddresses: (dir) => addresses.get(dir) ?? new Set(),
163
- });
223
+ const delta = deltaOf(cwd, base, head.value, baseScan.value);
224
+ // every secret the PR's commits add is introduced by the PR
225
+ delta.introduced.push(...added.map((concern) => ({ concern, address: null })));
164
226
  log.info(
165
227
  `» scan delta: ${delta.introduced.length} introduced, ${delta.resolved.length} resolved, ` +
166
- `${delta.unchanged.length} unchanged vs ${mergeBase.slice(0, 7)} (base scan ${Date.now() - started}ms)`,
228
+ `${delta.unchanged.length} unchanged vs ${base.mergeBase.slice(0, 7)} ` +
229
+ `(base and head scanned in ${Date.now() - started}ms)`,
167
230
  );
168
- return { ...delta, baseSha: mergeBase, headSha: pr.headSha };
231
+ return {
232
+ head: [...head.value, ...added],
233
+ delta: { ...delta, baseSha: base.mergeBase, headSha: pr.headSha },
234
+ };
169
235
  } finally {
170
- run("git", ["worktree", "remove", "--force", worktree], cwd);
171
- rmSync(parent, { recursive: true, force: true });
236
+ base?.close();
172
237
  }
173
238
  }
174
239
 
240
+ /**
241
+ * The secrets the PR's commits add, when `tools_enabled` names betterleaks
242
+ * (`secretScan.ts`): in every file the PR changes, not only Terraform, with the
243
+ * gate's threshold and the repository's suppressions applied.
244
+ */
245
+ async function secretsOf(
246
+ ctx: LocalToolContext,
247
+ cwd: string,
248
+ base: BaseCheckout | undefined,
249
+ ): Promise<Concern[]> {
250
+ if (!resolveToolSelection(ctx.payload).enabled("betterleaks")) return [];
251
+ if (!base) {
252
+ log.info("» betterleaks: no base commit to scan from — secrets were not scanned");
253
+ return [];
254
+ }
255
+ const scan = await scanAddedSecrets(cwd, base.mergeBase, ctx.tmpdir);
256
+ // a scan that did not finish still reports what it found
257
+ if (!scan.ran) log.warning(`» betterleaks did not run fully — ${scan.reason}`);
258
+ log.info(`» betterleaks: ${scan.concerns.length} secret(s) added by this pull request`);
259
+ const threshold = (ctx.payload.severityThreshold as Severity | undefined) ?? "low";
260
+ return annotateConcerns(scan.concerns, {
261
+ cwd,
262
+ minRank: SEVERITY_RANK[threshold],
263
+ isTerraform: () => true,
264
+ });
265
+ }
266
+
267
+ /** Match the two scans (`scanDelta.ts`), with what the head's configuration says moved. */
268
+ function deltaOf(cwd: string, base: BaseCheckout, head: Concern[], baseConcerns: Concern[]) {
269
+ const moved = new Map<string, MovedBlock[]>();
270
+ const modules = new Map<string, Map<string, string>>();
271
+ const addresses = new Map<string, Set<string>>();
272
+ const readHead = repoFileReader(cwd);
273
+ for (const rel of walkTfFiles(cwd)) {
274
+ const src = readHead(rel);
275
+ if (src === null) continue;
276
+ const dir = posix.dirname(rel.replace(/\\/g, "/"));
277
+ const key = dir === "." ? "" : dir;
278
+ moved.set(key, [...(moved.get(key) ?? []), ...parseMovedBlocks(src)]);
279
+ modules.set(key, new Map([...(modules.get(key) ?? []), ...localModuleSources(src)]));
280
+ const set = addresses.get(key) ?? new Set<string>();
281
+ for (const a of declaredAddresses(src)) set.add(a);
282
+ addresses.set(key, set);
283
+ }
284
+ return computeScanDelta({
285
+ base: baseConcerns.filter((c) => !c.suppression),
286
+ head: head.filter((c) => !c.suppression),
287
+ readBase: repoFileReader(base.cwd),
288
+ readHead,
289
+ moved,
290
+ moduleSources: (dir) => modules.get(dir) ?? new Map(),
291
+ headAddresses: (dir) => addresses.get(dir) ?? new Set(),
292
+ });
293
+ }
294
+
175
295
  /**
176
296
  * Record the gate scan on the session. A failure leaves `gateConcerns` unset —
177
297
  * the outputs then read "not scanned", never a pass — and must not stop the run.
@@ -189,87 +309,24 @@ export async function captureGateScan(
189
309
  try {
190
310
  await captureGateScanInner(ctx, scanners, review);
191
311
  } finally {
192
- removeNewInitArtefacts(checkout, before);
312
+ removeNewInitArtefacts(checkout, before, "findings gate");
193
313
  }
194
314
  }
195
315
 
196
- const INIT_ARTEFACTS = new Set([".terraform", ".terraform.lock.hcl"]);
197
-
198
- interface InitArtefacts {
199
- /** init output git neither tracks nor ignores. */
200
- untracked: Set<string>;
201
- /** committed lock files with uncommitted changes. */
202
- modifiedLocks: Set<string>;
203
- }
204
-
205
- /**
206
- * The `terraform init` output in the checkout, as repo-relative paths; null
207
- * outside a git checkout. A committed lock file counts once it is modified:
208
- * init adds the runner's platform hash to one made on another platform (a
209
- * lock file from a Mac lacks the linux hash).
210
- */
211
- function initArtefacts(cwd: string): InitArtefacts | null {
212
- const r = run("git", ["status", "--porcelain=v1", "-z", "--untracked-files=normal"], cwd);
213
- if (r.status !== 0) return null;
214
- const entries = r.stdout.split("\0");
215
- const isArtefact = (p: string) => INIT_ARTEFACTS.has(posix.basename(p.replace(/\/$/, "")));
216
- return {
217
- untracked: new Set(
218
- entries
219
- .filter((e) => e.startsWith("?? "))
220
- .map((e) => e.slice(3))
221
- .filter(isArtefact),
222
- ),
223
- modifiedLocks: new Set(
224
- entries
225
- .filter((e) => e[1] === "M" && e[2] === " ")
226
- .map((e) => e.slice(3))
227
- .filter((p) => posix.basename(p) === ".terraform.lock.hcl"),
228
- ),
229
- };
230
- }
231
-
232
- /**
233
- * Undo what the gate scan's init did to the checkout: remove the artefacts it
234
- * created and restore the committed lock files it changed. Validate inits every
235
- * root in place, and a checkout that is dirty before the agent starts makes
236
- * `checkout_pr` refuse — or, on a committing run, ships the change in the fix.
237
- * What was there before the scan is left alone.
238
- */
239
- function removeNewInitArtefacts(cwd: string, before: InitArtefacts | null): void {
240
- if (!before) return;
241
- const top = run("git", ["rev-parse", "--show-toplevel"], cwd).stdout.trim();
242
- const after = initArtefacts(cwd);
243
- const created = [...(after?.untracked ?? [])].filter((p) => !before.untracked.has(p));
244
- const changed = [...(after?.modifiedLocks ?? [])].filter((p) => !before.modifiedLocks.has(p));
245
- if (!top || created.length + changed.length === 0) return;
246
- for (const p of created) rmSync(join(top, p), { recursive: true, force: true });
247
- if (changed.length > 0) run("git", ["checkout", "--", ...changed], top);
248
- resetTerraformInitCache();
249
- log.info(
250
- `» findings gate: removed ${created.length} terraform init artefact(s) from the checkout` +
251
- (changed.length ? `, restored ${changed.length} committed lock file(s)` : ""),
252
- );
253
- }
254
-
255
316
  async function captureGateScanInner(
256
317
  ctx: LocalToolContext,
257
318
  scanners: ScanScanners,
258
319
  review: Pick<PullRequestRunContext, "baseSha" | "headSha"> | undefined,
259
320
  ): Promise<void> {
260
321
  try {
261
- const concerns = await runGateScan(ctx, scanners, review ? reviewedConcern : undefined);
262
- if (!concerns) return;
263
- if (review) {
264
- // a failed base scan must not cost the run its gate: it falls back to
265
- // judging every finding, as a run without a delta always has.
266
- try {
267
- const delta = await runScanDelta(ctx, review, concerns, scanners);
268
- if (delta) scanSessionOf(ctx.toolState).gateDelta = delta;
269
- } catch (error) {
270
- log.warning(`» scan delta: base scan failed, every finding is treated as new — ${error}`);
271
- }
272
- }
322
+ // a review scans its base too, and the gate then judges only what the PR
323
+ // introduced
324
+ const scanned: { head: Concern[]; delta?: GateDelta } | undefined = review
325
+ ? await reviewScans(ctx, review, scanners)
326
+ : await runGateScan(ctx, scanners).then((head) => head && { head });
327
+ if (!scanned) return;
328
+ const concerns = scanned.head;
329
+ if (scanned.delta) scanSessionOf(ctx.toolState).gateDelta = scanned.delta;
273
330
  const session = scanSessionOf(ctx.toolState);
274
331
  // the baseline is part of the verdict, so it is resolved NOW too — from the
275
332
  // tree and git state as invoked, before the agent can change either.
@@ -297,7 +354,13 @@ async function captureGateScanInner(
297
354
  export async function scanForProof(
298
355
  ctx: LocalToolContext,
299
356
  cwd: string,
357
+ files?: string[],
300
358
  scanners: ScanScanners = realScanners,
301
359
  ): Promise<Concern[]> {
302
- return (await scanTree(ctx, cwd, null, scanners, reviewedConcern)).filter((c) => !c.suppression);
360
+ const scope = files
361
+ ? (scopeFor([treeLayout(cwd, ctx.payload.varFiles)], files) ?? undefined)
362
+ : undefined;
363
+ return (await scanTree(ctx, cwd, null, scanners, reviewedConcern, scope)).filter(
364
+ (c) => !c.suppression,
365
+ );
303
366
  }
@@ -482,9 +482,34 @@ export function parseBlocks(hcl: string, keywords: readonly string[]): HclBlock[
482
482
  return out;
483
483
  }
484
484
 
485
+ /**
486
+ * One block label: a quoted string or, as the HCL grammar also allows, a bare
487
+ * identifier — `resource aws_vpc main {` is the same block as
488
+ * `resource "aws_vpc" "main" {`, and pre-0.12 code is full of it. A reader that
489
+ * knew only the quoted form saw none of those resources, so a proof read a
490
+ * `terraform fmt` that quoted them as resources being ADDED.
491
+ */
492
+ export const BLOCK_LABEL = String.raw`(?:"[^"\n]*"|[A-Za-z_][A-Za-z0-9_-]*)`;
493
+
494
+ /** a label as written, without its quotes. */
495
+ export function unquoteLabel(label: string): string {
496
+ return label.startsWith('"') ? label.slice(1, -1) : label;
497
+ }
498
+
499
+ /** a `resource <type> <name> {` header in either label style; groups 1 and 2
500
+ * are the labels as written ({@link unquoteLabel}). */
501
+ export const RESOURCE_HEADER = String.raw`(?:^|\n)\s*resource\s+(${BLOCK_LABEL})\s+(${BLOCK_LABEL})\s*\{`;
502
+
503
+ /** a `module <name> {` header in either label style; group 1 is the label as written. */
504
+ export const MODULE_HEADER = String.raw`(?:^|\n)\s*module\s+(${BLOCK_LABEL})\s*\{`;
505
+
485
506
  function parseBlocksUncached(hcl: string, alternation: string): HclBlock[] {
486
507
  const out: HclBlock[] = [];
487
- const re = new RegExp(`(?:^|\\n)[ \\t]*(${alternation})((?:[ \\t]+"[^"\\n]*")*)[ \\t]*\\{`, "g");
508
+ const re = new RegExp(
509
+ `(?:^|\\n)[ \\t]*(${alternation})((?:[ \\t]+${BLOCK_LABEL})*)[ \\t]*\\{`,
510
+ "g",
511
+ );
512
+ const labelRe = new RegExp(BLOCK_LABEL, "g");
488
513
 
489
514
  // The shared index rather than a forward counter of this pass's own: five
490
515
  // passes over one file used to walk its newlines five times over, and the
@@ -495,7 +520,7 @@ function parseBlocksUncached(hcl: string, alternation: string): HclBlock[] {
495
520
  // biome-ignore lint/suspicious/noAssignInExpressions: idiomatic regex-exec iteration
496
521
  while ((m = re.exec(hcl)) !== null) {
497
522
  const keyword = m[1] ?? "";
498
- const labels = [...(m[2] ?? "").matchAll(/"([^"\n]*)"/g)].map((l) => l[1] ?? "");
523
+ const labels = [...(m[2] ?? "").matchAll(labelRe)].map((l) => unquoteLabel(l[0]));
499
524
  const bodyStart = m.index + m[0].length - 1; // the `{` the header ends on
500
525
  const body = matchBraceBody(hcl, bodyStart);
501
526
  if (!body) break;