infraweaver 0.3.11 → 0.3.13

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 (59) hide show
  1. package/README.md +1 -1
  2. package/dist/agents/opencode.d.ts +6 -0
  3. package/dist/agents/quotaTokens.d.ts +6 -0
  4. package/dist/cli.mjs +846 -412
  5. package/dist/index.js +806 -372
  6. package/dist/internal.js +4 -4
  7. package/dist/mcp/git.d.ts +6 -4
  8. package/dist/mcp/localContext.d.ts +7 -0
  9. package/dist/mcp/reviewCommentLimit.d.ts +55 -5
  10. package/dist/mcp/reviewMarkers.d.ts +5 -0
  11. package/dist/mcp/staleFix.d.ts +36 -1
  12. package/dist/mcp/terraform/awsPrices.d.ts +1 -1
  13. package/dist/mcp/terraform/azurePrices.d.ts +1 -1
  14. package/dist/mcp/terraform/concernResult.d.ts +9 -0
  15. package/dist/mcp/terraform/deltaSummary.d.ts +3 -0
  16. package/dist/mcp/terraform/hardcodedSecrets.d.ts +24 -10
  17. package/dist/mcp/terraform/refactorScope.d.ts +14 -2
  18. package/dist/mcp/terraform/ruleContext.d.ts +3 -0
  19. package/dist/mcp/terraform/scanSession.d.ts +2 -0
  20. package/dist/mcp/terraform/tree.d.ts +5 -0
  21. package/dist/mcp/terraform/types.d.ts +2 -1
  22. package/dist/mcp/terraform/verifySites.d.ts +56 -0
  23. package/dist/vendor/@infraweaver-io/models/tokenQuota.d.ts +7 -6
  24. package/package.json +2 -2
  25. package/src/agents/opencode.ts +8 -1
  26. package/src/agents/quotaTokens.ts +7 -1
  27. package/src/mcp/git.ts +64 -12
  28. package/src/mcp/localContext.ts +6 -1
  29. package/src/mcp/moduleExtraction.ts +5 -4
  30. package/src/mcp/review.ts +52 -3
  31. package/src/mcp/reviewCommentLimit.ts +169 -26
  32. package/src/mcp/reviewMarkers.ts +11 -0
  33. package/src/mcp/reviewProvenance.ts +1 -1
  34. package/src/mcp/staleFix.ts +73 -2
  35. package/src/mcp/terraform/awsPrices.ts +1 -1
  36. package/src/mcp/terraform/azurePrices.ts +23 -23
  37. package/src/mcp/terraform/concernResult.ts +18 -9
  38. package/src/mcp/terraform/deltaSummary.ts +4 -0
  39. package/src/mcp/terraform/hardcodedSecrets.ts +148 -27
  40. package/src/mcp/terraform/nativeRuleScanners.ts +2 -1
  41. package/src/mcp/terraform/nativeScan.ts +4 -0
  42. package/src/mcp/terraform/refactor/equivalence.ts +42 -9
  43. package/src/mcp/terraform/refactorScope.ts +90 -21
  44. package/src/mcp/terraform/ruleContext.ts +4 -0
  45. package/src/mcp/terraform/scanDelta.ts +67 -26
  46. package/src/mcp/terraform/scanSession.ts +6 -0
  47. package/src/mcp/terraform/tools/detectPlanDrift.ts +2 -0
  48. package/src/mcp/terraform/tools/ingestExternalFindings.ts +2 -0
  49. package/src/mcp/terraform/tools/normalizationCandidates.ts +5 -5
  50. package/src/mcp/terraform/tools/readFindings.ts +2 -0
  51. package/src/mcp/terraform/tools/scan.ts +3 -0
  52. package/src/mcp/terraform/tools/verifyRemediation.ts +23 -1
  53. package/src/mcp/terraform/tree.ts +8 -0
  54. package/src/mcp/terraform/types.ts +2 -1
  55. package/src/mcp/terraform/verifySites.ts +124 -0
  56. package/src/modes/refactor.ts +1 -1
  57. package/src/modes/remediate-and-refactor.ts +1 -1
  58. package/src/modes/remediate.ts +1 -1
  59. package/src/phases/runAgentWithWatchdogs.ts +3 -2
@@ -12,19 +12,72 @@
12
12
  * people call (terraform-aws-vpc, say). Moving its resources into a submodule is
13
13
  * provably equivalent, and still churn for every consumer: their plans gain a
14
14
  * wall of `moved` addresses for no change in what they get. There, extraction
15
- * and normalisation candidates come back marked `report_only` — named in the
16
- * run's summary, never opened as a PR. Consolidating roots that duplicate each
17
- * other is unaffected: that removes real duplication, not style.
15
+ * and normalisation candidates in the module's own code come back marked
16
+ * `report_only` — named in the run's summary, never opened as a PR. A directory
17
+ * inside it that deploys (`envs/prod/` with its own provider or backend) is a
18
+ * configuration like any other, and its candidates stay actionable.
19
+ * Consolidating roots that duplicate each other is unaffected: that removes real
20
+ * duplication, not style.
18
21
  */
19
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
22
+ import { readdirSync, readFileSync, statSync } from "node:fs";
20
23
  import { join } from "node:path";
24
+ import { BLOCK_LABEL, parseBlocks } from "#app/mcp/terraform/hcl";
21
25
 
22
- /** true for a path inside an `examples/` (or `example/`) directory, at any depth. */
26
+ /**
27
+ * true for a path inside an `examples/` directory at any depth, or an `example/`
28
+ * one at the repository root. The registry's convention is `examples/`; the
29
+ * singular is accepted only where that convention puts it, because a module
30
+ * NAMED `example` (`modules/example/`) is code someone calls.
31
+ */
23
32
  export function isExamplePath(path: string): boolean {
24
- return /(?:^|\/)examples?\//.test(path.replace(/\\/g, "/"));
33
+ return /(?:^|\/)examples\/|^example\//i.test(path.replace(/\\/g, "/"));
34
+ }
35
+
36
+ const PROVIDER_OR_BACKEND = new RegExp(
37
+ String.raw`(?:^|\n)\s*(?:provider|backend)\s+${BLOCK_LABEL}\s*\{`,
38
+ );
39
+ const CLOUD_BLOCK = /(?:^|\n)\s*cloud\s*\{/;
40
+
41
+ /** whether one file's text configures a provider, a backend or HCP Terraform. */
42
+ function configuresDeployment(name: string, text: string): boolean {
43
+ if (name.endsWith(".tf.json")) {
44
+ let doc: unknown;
45
+ try {
46
+ doc = JSON.parse(text);
47
+ } catch {
48
+ return false;
49
+ }
50
+ if (typeof doc !== "object" || doc === null) return false;
51
+ const { provider, terraform } = doc as { provider?: unknown; terraform?: unknown };
52
+ if (provider !== undefined) return true;
53
+ // `terraform` is an object, or an array of them, in the JSON syntax
54
+ return [terraform]
55
+ .flat()
56
+ .some((t) => typeof t === "object" && t !== null && ("backend" in t || "cloud" in t));
57
+ }
58
+ if (PROVIDER_OR_BACKEND.test(text)) return true;
59
+ // `cloud` is a block name other schemas use too; only the settings block's counts
60
+ return parseBlocks(text, ["terraform"]).some((b) => CLOUD_BLOCK.test(b.body));
25
61
  }
26
62
 
27
- const PROVIDER_OR_BACKEND = /^\s*(?:provider\s+"|backend\s+")/m;
63
+ const isTerraformFile = (name: string) => name.endsWith(".tf") || name.endsWith(".tf.json");
64
+
65
+ /** whether the Terraform directly in `dir` is a root someone deploys from. */
66
+ function deploysFrom(dir: string): boolean {
67
+ let names: string[];
68
+ try {
69
+ names = readdirSync(dir).filter(isTerraformFile);
70
+ } catch {
71
+ return false;
72
+ }
73
+ return names.some((name) => {
74
+ try {
75
+ return configuresDeployment(name, readFileSync(join(dir, name), "utf8"));
76
+ } catch {
77
+ return false;
78
+ }
79
+ });
80
+ }
28
81
 
29
82
  /** whether some directory under `dir` (to a small depth) holds a `.tf` file. */
30
83
  function holdsTerraform(dir: string, depth: number): boolean {
@@ -53,23 +106,14 @@ function holdsTerraform(dir: string, depth: number): boolean {
53
106
  * deployment, or null when it does not.
54
107
  */
55
108
  export function libraryRepoReason(cwd: string): string | null {
56
- let rootFiles: string[];
109
+ let rootEntries: string[];
57
110
  try {
58
- rootFiles = readdirSync(cwd).filter((f) => f.endsWith(".tf"));
111
+ rootEntries = readdirSync(cwd);
59
112
  } catch {
60
113
  return null;
61
114
  }
62
- if (rootFiles.length === 0) return null;
63
- for (const f of rootFiles) {
64
- let text: string;
65
- try {
66
- text = readFileSync(join(cwd, f), "utf8");
67
- } catch {
68
- continue;
69
- }
70
- if (PROVIDER_OR_BACKEND.test(text)) return null;
71
- }
72
- const examples = ["examples", "example"].find((d) => existsSync(join(cwd, d)));
115
+ if (!rootEntries.some(isTerraformFile) || deploysFrom(cwd)) return null;
116
+ const examples = rootEntries.find((d) => /^examples?$/i.test(d));
73
117
  if (examples === undefined || !holdsTerraform(join(cwd, examples), 2)) return null;
74
118
  return (
75
119
  `the repository root configures no provider and no backend, and ships \`${examples}/\`: it is a ` +
@@ -77,7 +121,32 @@ export function libraryRepoReason(cwd: string): string | null {
77
121
  );
78
122
  }
79
123
 
80
- /** what a detector tool adds to its result when the repository is a library. */
124
+ /**
125
+ * For a library repository, why a candidate in `file` (repo-relative) is
126
+ * reported rather than restructured — or null when the file belongs to a
127
+ * directory, or sits under one, that deploys on its own. Outside a library,
128
+ * always null.
129
+ */
130
+ export function libraryScope(cwd: string): (file: string) => string | null {
131
+ const reason = libraryRepoReason(cwd);
132
+ if (reason === null) return () => null;
133
+ const deploys = new Map<string, boolean>();
134
+ return (file) => {
135
+ const dirs = file.replace(/\\/g, "/").split("/").slice(0, -1);
136
+ for (let i = dirs.length; i > 0; i--) {
137
+ const dir = dirs.slice(0, i).join("/");
138
+ let d = deploys.get(dir);
139
+ if (d === undefined) {
140
+ d = deploysFrom(join(cwd, dir));
141
+ deploys.set(dir, d);
142
+ }
143
+ if (d) return null;
144
+ }
145
+ return reason;
146
+ };
147
+ }
148
+
149
+ /** what a detector tool adds to a candidate in a library's own code. */
81
150
  export function reportOnly(reason: string | null): {
82
151
  report_only?: true;
83
152
  report_only_reason?: string;
@@ -46,6 +46,8 @@ export interface RuleContext {
46
46
  readonly files: readonly SourceFile[];
47
47
  /** the `.tofu` half. Only the dialect rule needs it; empty on a repo with none. */
48
48
  readonly tofuFiles: readonly SourceFile[];
49
+ /** the `.tfvars` inputs. Only the hardcoded-secret rule reads them; empty on a repo with none. */
50
+ readonly tfvarsFiles: readonly SourceFile[];
49
51
  /** non-null when the walk was bounded before it finished — see [[tree]]. */
50
52
  readonly truncated: TreeTruncation;
51
53
  /** how many times a literal must repeat in one directory before it is reported. */
@@ -80,6 +82,7 @@ export interface RuleContextInput {
80
82
  cwd: string;
81
83
  files: readonly SourceFile[];
82
84
  tofuFiles?: readonly SourceFile[];
85
+ tfvarsFiles?: readonly SourceFile[];
83
86
  truncated?: TreeTruncation;
84
87
  repeatedLiteralThreshold: number;
85
88
  }
@@ -89,6 +92,7 @@ export function createRuleContext(input: RuleContextInput): RuleContext {
89
92
  cwd: input.cwd,
90
93
  files: input.files,
91
94
  tofuFiles: input.tofuFiles ?? [],
95
+ tfvarsFiles: input.tfvarsFiles ?? [],
92
96
  truncated: input.truncated ?? null,
93
97
  repeatedLiteralThreshold: input.repeatedLiteralThreshold,
94
98
  };
@@ -275,38 +275,79 @@ export function computeScanDelta(input: ScanDeltaInput): ScanDelta {
275
275
  }
276
276
 
277
277
  /**
278
- * Pair an introduced finding on a NEW resource with a resolved one that broke
279
- * the same rule and whose resource no longer exists where it was: renamed within
280
- * its module, or moved under the same name to another directory (a resource
281
- * extracted into a module without a `moved` block). Each resolved finding pairs
282
- * at most once.
278
+ * Pair a NEW resource with a vanished one — a resource whose findings resolved
279
+ * because it no longer exists where it was: renamed within its module, or moved
280
+ * under the same name to another directory (a resource extracted into a module
281
+ * without a `moved` block).
282
+ *
283
+ * Resources pair one-to-one, as `moved` blocks do: Terraform rejects two blocks
284
+ * with the same `from` or the same `to`, so a pairing made per finding — one new
285
+ * bucket matched to two old ones because each shared a different rule — would
286
+ * advise blocks that cannot be written. Each candidate pair scores the findings
287
+ * the two resources share (rule by rule, instance by instance), the best pairs
288
+ * are taken first, and ties fall to address order so the result is stable. Only
289
+ * the shared findings of a pair are labelled; a rule the vanished resource never
290
+ * broke is new on the renamed one too.
283
291
  */
284
292
  function markPossibleRenames(
285
293
  introduced: DeltaFinding[],
286
294
  resolved: DeltaFinding[],
287
295
  headAddresses: (dir: string) => ReadonlySet<string>,
288
296
  ): void {
289
- const vanished = resolved.filter((r) => {
290
- if (r.address === null) return false;
291
- return !headAddresses(dirOf(r.concern.location.file)).has(r.address);
292
- });
293
- const used = new Set<DeltaFinding>();
294
- for (const finding of introduced) {
295
- if (finding.address === null) continue;
296
- const dir = dirOf(finding.concern.location.file);
297
- const match = vanished.find(
298
- (r) =>
299
- !used.has(r) &&
300
- ruleKey(r.concern) === ruleKey(finding.concern) &&
301
- // a `moved` block cannot change a resource's type
302
- typeOf(r.address as string) === typeOf(finding.address as string) &&
303
- // renamed in place, or moved elsewhere under its own name
304
- (dirOf(r.concern.location.file) === dir) !== (r.address === finding.address),
305
- );
306
- if (!match) continue;
307
- used.add(match);
308
- finding.possiblyPreexisting = true;
309
- finding.renamedFrom = { address: match.address as string, file: match.concern.location.file };
297
+ const byResource = (findings: DeltaFinding[]) => {
298
+ const out = new Map<string, { dir: string; address: string; findings: DeltaFinding[] }>();
299
+ for (const f of [...findings].sort((a, b) => byLocation(a.concern, b.concern))) {
300
+ if (f.address === null) continue;
301
+ const dir = dirOf(f.concern.location.file);
302
+ const key = `${dir}|${f.address}`;
303
+ const entry = out.get(key);
304
+ if (entry) entry.findings.push(f);
305
+ else out.set(key, { dir, address: f.address, findings: [f] });
306
+ }
307
+ return [...out.values()];
308
+ };
309
+ const vanished = byResource(resolved).filter((r) => !headAddresses(r.dir).has(r.address));
310
+ const appeared = byResource(introduced);
311
+
312
+ // the findings of `to` that match one of `from`'s, each of `from`'s used once
313
+ const shared = (from: (typeof vanished)[number], to: (typeof appeared)[number]) => {
314
+ const left = new Map<string, number>();
315
+ for (const f of from.findings)
316
+ left.set(ruleKey(f.concern), (left.get(ruleKey(f.concern)) ?? 0) + 1);
317
+ return to.findings.filter((f) => {
318
+ const n = left.get(ruleKey(f.concern)) ?? 0;
319
+ if (n === 0) return false;
320
+ left.set(ruleKey(f.concern), n - 1);
321
+ return true;
322
+ });
323
+ };
324
+ const order = (a: { dir: string; address: string }, b: { dir: string; address: string }) =>
325
+ a.dir.localeCompare(b.dir) || a.address.localeCompare(b.address);
326
+ const candidates = vanished.flatMap((from) =>
327
+ appeared
328
+ .filter(
329
+ (to) =>
330
+ // a `moved` block cannot change a resource's type
331
+ typeOf(from.address) === typeOf(to.address) &&
332
+ // renamed in place, or moved elsewhere under its own name
333
+ (from.dir === to.dir) !== (from.address === to.address),
334
+ )
335
+ .map((to) => ({ from, to, findings: shared(from, to) }))
336
+ .filter((p) => p.findings.length > 0),
337
+ );
338
+ candidates.sort(
339
+ (a, b) => b.findings.length - a.findings.length || order(a.from, b.from) || order(a.to, b.to),
340
+ );
341
+ const used = new Set<object>();
342
+ for (const { from, to, findings } of candidates) {
343
+ if (used.has(from) || used.has(to)) continue;
344
+ used.add(from);
345
+ used.add(to);
346
+ const file = from.findings[0]?.concern.location.file as string;
347
+ for (const finding of findings) {
348
+ finding.possiblyPreexisting = true;
349
+ finding.renamedFrom = { address: from.address, file };
350
+ }
310
351
  }
311
352
  }
312
353
 
@@ -77,6 +77,12 @@ export interface ScanSession {
77
77
  // agent took from the FIRST scan (`concernKeyOf`), or it falls back to
78
78
  // matching ids exactly and calls every line-shifted finding resolved.
79
79
  concernsById?: Map<string, Concern>;
80
+ // the resource each reported concern sat in when it was reported (by id), and
81
+ // per (scanner, rule, file) key the resources the rule fired on the first time
82
+ // the run saw that key. terraform_verify_remediation reads both to count a fix
83
+ // on one resource while the rule still fires on another (see verifySites).
84
+ siteById?: Map<string, string | null>;
85
+ firstSitesByKey?: Map<string, Set<string | null>>;
80
86
  // the findings gate's concern set: scanned by the engine itself before the
81
87
  // agent starts, on the tree the run was invoked on, at the CONFIGURED
82
88
  // threshold and scope. read at end-of-run by finalizeSuccessRun for the
@@ -6,6 +6,7 @@ import { PRODUCT_NAME } from "#app/brand";
6
6
  import type { LocalToolContext } from "#app/mcp/localContext";
7
7
  import { walkTfFiles } from "#app/mcp/modules";
8
8
  import { execute, tool, toolOk } from "#app/mcp/shared";
9
+ import { listOpenRemediationPrs } from "#app/mcp/staleFix";
9
10
  import { buildConcernResult } from "#app/mcp/terraform/concernResult";
10
11
  import { indexTfResources } from "#app/mcp/terraform/cspm";
11
12
  import { docUrlsForGroup, ruleDocUrl } from "#app/mcp/terraform/decisions";
@@ -180,6 +181,7 @@ export function DetectPlanDriftTool(ctx: LocalToolContext) {
180
181
  // drift is a narrow concern source — join any prior scan baseline so a
181
182
  // later ✗→✓ verify doesn't read the whole workspace as regressions.
182
183
  baselineMode: "merge",
184
+ openRemediationPrs: await listOpenRemediationPrs(ctx),
183
185
  });
184
186
 
185
187
  log.info(
@@ -7,6 +7,7 @@ import { PRODUCT_NAME } from "#app/brand";
7
7
  import type { LocalToolContext } from "#app/mcp/localContext";
8
8
  import { readTextCapped, walkTfFiles } from "#app/mcp/modules";
9
9
  import { execute, tool, toolOk } from "#app/mcp/shared";
10
+ import { listOpenRemediationPrs } from "#app/mcp/staleFix";
10
11
  import { buildConcernResult } from "#app/mcp/terraform/concernResult";
11
12
  import { cappedSummary, SeedCapParams, seedCapsFrom } from "#app/mcp/terraform/coverage";
12
13
  import {
@@ -220,6 +221,7 @@ export function IngestExternalFindingsTool(ctx: LocalToolContext) {
220
221
  // baseline so a later ✗→✓ verify doesn't read the workspace as regressions.
221
222
  baselineMode: "merge",
222
223
  seedCaps: seedCapsFrom(input),
224
+ openRemediationPrs: await listOpenRemediationPrs(ctx),
223
225
  });
224
226
  const cappedBlock = cappedSummary(capped);
225
227
 
@@ -2,7 +2,7 @@ import { type } from "arktype";
2
2
  import type { LocalToolContext } from "#app/mcp/localContext";
3
3
  import { execute, tool, toolOk } from "#app/mcp/shared";
4
4
  import { findNormalizationCandidates } from "#app/mcp/terraform/normalization";
5
- import { isExamplePath, libraryRepoReason, reportOnly } from "#app/mcp/terraform/refactorScope";
5
+ import { isExamplePath, libraryScope, reportOnly } from "#app/mcp/terraform/refactorScope";
6
6
  import { log } from "#app/utils/cli";
7
7
 
8
8
  // --- the tool ----------------------------------------------------------------
@@ -29,15 +29,15 @@ export function NormalizationCandidatesTool(ctx: LocalToolContext) {
29
29
  parameters: NormalizationCandidatesParams,
30
30
  execute: execute(async () => {
31
31
  const cwd = ctx.payload.cwd ?? process.cwd();
32
- const files = findNormalizationCandidates(cwd).filter(
33
- (f) => ctx.payload.refactorExamples || !isExamplePath(f.file),
34
- );
32
+ const library = libraryScope(cwd);
33
+ const files = findNormalizationCandidates(cwd)
34
+ .filter((f) => ctx.payload.refactorExamples || !isExamplePath(f.file))
35
+ .map((f) => ({ ...reportOnly(library(f.file)), ...f }));
35
36
  const total = files.reduce((n, f) => n + f.count, 0);
36
37
  log.info(
37
38
  `» terraform_normalization_candidates: ${total} site(s) across ${files.length} file(s)`,
38
39
  );
39
40
  return toolOk({
40
- ...reportOnly(libraryRepoReason(cwd)),
41
41
  file_count: files.length,
42
42
  total,
43
43
  files,
@@ -4,6 +4,7 @@ import { type } from "arktype";
4
4
  import { PRODUCT_NAME } from "#app/brand";
5
5
  import type { LocalToolContext } from "#app/mcp/localContext";
6
6
  import { execute, tool, toolOk } from "#app/mcp/shared";
7
+ import { listOpenRemediationPrs } from "#app/mcp/staleFix";
7
8
  import { buildConcernResult } from "#app/mcp/terraform/concernResult";
8
9
  import { cappedSummary, SeedCapParams, seedCapsFrom } from "#app/mcp/terraform/coverage";
9
10
  import { docUrlsForGroup, ruleDocUrl } from "#app/mcp/terraform/decisions";
@@ -102,6 +103,7 @@ export function ReadFindingsTool(ctx: LocalToolContext) {
102
103
  grouping,
103
104
  autonomyThreshold,
104
105
  seedCaps: seedCapsFrom(input),
106
+ openRemediationPrs: await listOpenRemediationPrs(ctx),
105
107
  });
106
108
  const cappedBlock = cappedSummary(capped);
107
109
 
@@ -4,6 +4,7 @@ import { type } from "arktype";
4
4
  import type { LocalToolContext } from "#app/mcp/localContext";
5
5
  import { walkTfFiles } from "#app/mcp/modules";
6
6
  import { execute, tool, toolOk } from "#app/mcp/shared";
7
+ import { listOpenRemediationPrs } from "#app/mcp/staleFix";
7
8
  import { loadBaseline, loadResolvedLedger } from "#app/mcp/terraform/baseline";
8
9
  import { buildConcernResult } from "#app/mcp/terraform/concernResult";
9
10
  import {
@@ -148,6 +149,8 @@ export async function runScan(
148
149
  autonomyThreshold,
149
150
  inScope,
150
151
  priority,
152
+ openRemediationPrs: await listOpenRemediationPrs(ctx),
153
+ linesMatchTree: true,
151
154
  },
152
155
  );
153
156
  const { active, suppressed, groups, batchPlan, bySeverity: by_severity } = result;
@@ -3,6 +3,7 @@ import type { LocalToolContext } from "#app/mcp/localContext";
3
3
  import { execute, tool, toolOk } from "#app/mcp/shared";
4
4
  import { computeConfidence } from "#app/mcp/terraform/decisions";
5
5
  import { captureProvenFix } from "#app/mcp/terraform/fixMemory";
6
+ import { declaredAddresses } from "#app/mcp/terraform/scanDelta";
6
7
  import {
7
8
  computeRegressions,
8
9
  computeRemediationVerdict,
@@ -12,6 +13,7 @@ import {
12
13
  } from "#app/mcp/terraform/scanners";
13
14
  import { hasBaseline, scanSessionOf } from "#app/mcp/terraform/scanSession";
14
15
  import { concernKeyOf, dedupe } from "#app/mcp/terraform/types";
16
+ import { fileReader, resolvedAtSite, siteOf } from "#app/mcp/terraform/verifySites";
15
17
  import { log } from "#app/utils/cli";
16
18
  import { resolveToolSelection } from "#app/utils/prompt/toolSelection";
17
19
  import { resolveModuleCredentialEnv } from "#app/utils/setup/registryAuth";
@@ -82,7 +84,27 @@ export async function runVerifyRemediation(
82
84
  if (key !== undefined) keyed.push({ id, key });
83
85
  else unkeyed.push(id);
84
86
  }
85
- const keyedVerdict = partitionByKey(keyed, currentKeys);
87
+ const byKey = partitionByKey(keyed, currentKeys);
88
+ // a rule still firing in the file may have been fixed on THIS finding's
89
+ // resource; count it only under the conditions verifySites spells out
90
+ const read = fileReader(cwd);
91
+ const remainingKeyed = keyed.filter((k) => byKey.remaining.includes(k.id));
92
+ const atSite = new Set(
93
+ resolvedAtSite({
94
+ requested: remainingKeyed.map((k) => ({
95
+ ...k,
96
+ site: session.siteById?.get(k.id) ?? null,
97
+ file: reported.get(k.id)?.location.file,
98
+ })),
99
+ current: currentConcerns.map((c) => ({ key: concernKeyOf(c), site: siteOf(c, read) })),
100
+ firstSites: session.firstSitesByKey ?? new Map(),
101
+ declared: (file) => new Set(declaredAddresses(read(file) ?? "")),
102
+ }),
103
+ );
104
+ const keyedVerdict = {
105
+ resolved: [...byKey.resolved, ...byKey.remaining.filter((id) => atSite.has(id))],
106
+ remaining: byKey.remaining.filter((id) => !atSite.has(id)),
107
+ };
86
108
  const fallbackVerdict = computeRemediationVerdict(unkeyed, new Set(currentIds));
87
109
  const resolved = [...keyedVerdict.resolved, ...fallbackVerdict.resolved];
88
110
  const remaining = [...keyedVerdict.remaining, ...fallbackVerdict.remaining];
@@ -176,6 +176,14 @@ export function readTofuTree(cwd: string, limits?: TreeReadLimits): SourceTree {
176
176
  return readTree(cwd, ".tofu", limits);
177
177
  }
178
178
 
179
+ /**
180
+ * The `.tfvars` inputs (`.auto.tfvars` included). Not configuration, so no rule
181
+ * reads them as blocks; the hardcoded-secret rule reads their assignments.
182
+ */
183
+ export function readTfvarsTree(cwd: string, limits?: TreeReadLimits): SourceTree {
184
+ return readTree(cwd, ".tfvars", limits);
185
+ }
186
+
179
187
  /** How many oversize paths a gap names before it stops listing them. A gap is a
180
188
  * sentence in a report, not a manifest; past a handful the count carries the
181
189
  * fact and the list only buries it. */
@@ -399,7 +399,8 @@ export interface ConcernGroup {
399
399
  * the file (by-file grouping) or the rule (by-rule grouping). */
400
400
  id: string;
401
401
  /** the branch its fix is pushed to: `remediate/<id>`, plus `--<base>` when the
402
- * PR targets a branch other than the repository default. */
402
+ * PR targets a branch other than the repository default — or the head of the
403
+ * remediation PR already open for this group into the same base. */
403
404
  branch?: string;
404
405
  /** the group's primary file (by-file) or a human label like "3 files"
405
406
  * (by-rule); `files` carries the full list for by-rule groups. */
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Which findings a fix resolved where its rule still fires elsewhere in the file.
3
+ *
4
+ * terraform_verify_remediation keys a finding on (scanner, rule, file), so a
5
+ * rule fixed on one resource but still firing on another in the same file read
6
+ * as unfixed for both: AWSGoat's open-egress fix on two of four security groups
7
+ * verified as 0 of 5. Counting by resource instead risks the opposite error —
8
+ * a fix that moves the defect onto a new resource would look resolved — so a
9
+ * finding counts as resolved at its own site only when ALL of these hold:
10
+ *
11
+ * - it was placed on a resource when it was reported, by a scan of the tree
12
+ * as it then stood, and no later scan placed the same id elsewhere;
13
+ * - that resource is still declared in the file (a renamed or deleted one
14
+ * proves nothing);
15
+ * - the rule no longer fires on it;
16
+ * - every place the rule still fires in the file is a resource it already
17
+ * fired on before the fix (no new site, and no finding outside a resource).
18
+ *
19
+ * Anything else keeps the file-level answer, which under-claims and never
20
+ * over-claims.
21
+ */
22
+
23
+ import { readFileSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import type { ScanSession } from "#app/mcp/terraform/scanSession";
26
+ import { resourceAddressAt } from "#app/mcp/terraform/suppressions";
27
+ import { type Concern, concernKeyOf } from "#app/mcp/terraform/types";
28
+
29
+ /** A reader that returns each file under `cwd` once, or null when unreadable. */
30
+ export function fileReader(cwd: string): (file: string) => string | null {
31
+ const cache = new Map<string, string | null>();
32
+ return (file) => {
33
+ if (!cache.has(file)) {
34
+ let text: string | null;
35
+ try {
36
+ text = readFileSync(join(cwd, file), "utf8");
37
+ } catch {
38
+ text = null;
39
+ }
40
+ cache.set(file, text);
41
+ }
42
+ return cache.get(file) ?? null;
43
+ };
44
+ }
45
+
46
+ /** The resource a concern sits in, read from the file as it is now. */
47
+ export function siteOf(c: Concern, read: (file: string) => string | null): string | null {
48
+ const text = read(c.location.file);
49
+ return text === null ? null : resourceAddressAt(text, c.location.line);
50
+ }
51
+
52
+ /**
53
+ * Remember where each reported concern sat, and the first sites per key.
54
+ *
55
+ * `linesMatchTree` says the concerns were scanned off the tree in `cwd` as it
56
+ * is now. Without it (a findings file or external report, which may describe
57
+ * another commit) their lines are not trusted to name today's resources: their
58
+ * ids get no site and their keys no first sites.
59
+ */
60
+ export function recordConcernSites(
61
+ session: ScanSession,
62
+ concerns: Concern[],
63
+ cwd: string,
64
+ linesMatchTree: boolean,
65
+ ): void {
66
+ const read = fileReader(cwd);
67
+ session.siteById ??= new Map();
68
+ session.firstSitesByKey ??= new Map();
69
+ const first = session.firstSitesByKey;
70
+ const fresh = new Map<string, Set<string | null>>();
71
+ for (const c of concerns) {
72
+ const site = linesMatchTree ? siteOf(c, read) : null;
73
+ // an id is line-pinned, so after an edit a re-scan can report a different
74
+ // finding under it. It keeps the site it was first reported at; placed
75
+ // anywhere else later, it is ambiguous and gets none.
76
+ const prior = session.siteById.get(c.id);
77
+ session.siteById.set(c.id, prior === undefined || prior === site ? site : null);
78
+ if (!linesMatchTree) continue;
79
+ const key = concernKeyOf(c);
80
+ if (first.has(key)) continue;
81
+ const sites = fresh.get(key) ?? new Set();
82
+ sites.add(site);
83
+ fresh.set(key, sites);
84
+ }
85
+ for (const [key, sites] of fresh) first.set(key, sites);
86
+ }
87
+
88
+ export interface SiteRequest {
89
+ id: string;
90
+ /** the (scanner, rule, file) key */
91
+ key: string;
92
+ /** the resource address the finding sat in when reported, or null */
93
+ site: string | null;
94
+ file: string | undefined;
95
+ }
96
+
97
+ export function resolvedAtSite(params: {
98
+ requested: readonly SiteRequest[];
99
+ /** every finding the re-scan reports, with the resource it sits in */
100
+ current: readonly { key: string; site: string | null }[];
101
+ /** per key, the resources the rule fired on in the first scan of the run */
102
+ firstSites: ReadonlyMap<string, ReadonlySet<string | null>>;
103
+ /** the resource addresses a file declares now */
104
+ declared: (file: string) => ReadonlySet<string>;
105
+ }): string[] {
106
+ const firingByKey = new Map<string, (string | null)[]>();
107
+ for (const c of params.current) {
108
+ const list = firingByKey.get(c.key) ?? [];
109
+ list.push(c.site);
110
+ firingByKey.set(c.key, list);
111
+ }
112
+ const out: string[] = [];
113
+ for (const r of params.requested) {
114
+ if (r.site === null || r.file === undefined) continue;
115
+ const firing = firingByKey.get(r.key) ?? [];
116
+ const known = params.firstSites.get(r.key);
117
+ if (known === undefined) continue;
118
+ if (firing.some((s) => s === null || !known.has(s))) continue;
119
+ if (firing.includes(r.site)) continue;
120
+ if (!params.declared(r.file).has(r.site)) continue;
121
+ out.push(r.id);
122
+ }
123
+ return out;
124
+ }
@@ -18,7 +18,7 @@ This mode standardises STRUCTURE while preserving BEHAVIOUR — the equivalence
18
18
  - \`${t("terraform_normalization_candidates")}\` → in-place idiomatic cleanups that change SYNTAX, not behaviour (redundant whole-string \`"\${expr}"\` interpolation; legacy HCL0.11 map-argument block syntax like \`vars {\` on a \`template_file\`). These relocate NOTHING — zero \`moved {}\` blocks — and are proven by the same equivalence check.
19
19
  - \`${t("terraform_consolidation_candidates")}\` → the SAME resource shape declared in several root directories (\`env/dev\` + \`env/staging\` + \`env/prod\`), with the single parameterised module they could all call already derived: which attributes are identical (module content) and which differ (per-environment inputs). A member root of a group will ALSO show up as a zero-candidate cluster in \`${t("module_extraction_candidates")}\` — prefer the consolidation, which does that work once for every environment instead of once per environment. Its \`near_groups\` are the opposite case: roots recognisably the same stack that DIFFER (one carries a resource the other does not), where folding them together is a design decision this mode has no basis for. Do NOT extract a near-group root's shared resources into a module on its own either — a module one twin calls and the other does not widens exactly the drift the near-group reports. Leave those roots alone and name the near-group and its \`differences\` in your report.
20
20
 
21
- **A result marked \`report_only\` is an observation, not work.** The repository is a module other configurations call (\`report_only_reason\` says why), and restructuring it is churn for every caller: name those candidates in your \`${t("report_progress")}\` summary and open no PR for them. \`examples/\` roots are left out of every candidate list unless the operator set \`refactor_examples\`.
21
+ **A candidate marked \`report_only\` is an observation, not work.** It sits in the code of a module other configurations call (\`report_only_reason\` says why), and restructuring that is churn for every caller: name those candidates in your \`${t("report_progress")}\` summary and open no PR for them. \`examples/\` roots are left out of every candidate list unless the operator set \`refactor_examples\`.
22
22
 
23
23
  **Before picking, check the interface.** \`${t("module_extraction_candidates")}\` reports \`unmapped_attributes\` per candidate and a top-level \`partial_interfaces\` list: arguments the raw resources SET that the target module does not. Adopting such a module DROPS them silently — and the equivalence check cannot catch it, because once the argument is gone it is absent from both sides. Two of these are unrecoverable rather than merely wrong (\`object_lock_enabled\` cannot be set after creation; \`force_destroy\` changes deletion semantics). So: **decline the candidate, or extend the module to expose the argument and say so in the PR body.** Deleting the argument to make the shapes match is never the fix. A candidate whose \`unmapped_attributes\` is \`null\` was NOT checked — the module is external and its code is not in this repo — which is not the same as clean.
24
24
 
@@ -20,7 +20,7 @@ This mode composes the **Remediate** and **Refactor** verbs into a single run. I
20
20
  - **commit the fix** (\`git add\` only the changed \`*.tf\`/\`*.tfvars\`, a \`fix(tf): …\` message). Do NOT push yet. This committed state is the refactor's equivalence BASELINE.
21
21
 
22
22
  3. **REFACTOR (phase 2) — modularise on top of the committed fix**: follow the **Refactor** mode's flow:
23
- - \`${t("module_extraction_candidates")}\` / \`${t("terraform_normalization_candidates")}\` → pick one behaviour-preserving refactor; a result marked \`report_only\` (the repository is a module others call) is named in the summary, never proposed. Respect a pinned \`refactor_source\` if one was supplied (it constrains which module source you may use; the behaviour-altering b.4 path is only available when the operator pinned \`third-party\`).
23
+ - \`${t("module_extraction_candidates")}\` / \`${t("terraform_normalization_candidates")}\` → pick one behaviour-preserving refactor; a candidate marked \`report_only\` (it sits in the code of a module others call) is named in the summary, never proposed. Respect a pinned \`refactor_source\` if one was supplied (it constrains which module source you may use; the behaviour-altering b.4 path is only available when the operator pinned \`third-party\`).
24
24
  - resolve the module source, wire the \`module\` call against its real interface (\`${t("terraform_module_interface")}\` for a local module dir, \`${t("terraform_module_lookup")}\` for a registry-sourced one), and emit a \`moved {}\` block for EVERY relocated address (\`${t("terraform_generate_moved")}\`).
25
25
  - \`terraform fmt\` + \`${t("terraform_validate")}\`, then — **with the refactor edits still UNCOMMITTED** — call \`${t("terraform_equivalence_check")}\`. In this mode it diffs the working tree against the COMMITTED fix (not the run-start commit), so it measures only the refactor. It must return \`equivalent: true\` (zero uncovered moves, resource set + arguments preserved, validate + fmt clean) before you proceed; \`${t("push_branch")}\` hard-blocks an unproven one — and, once this run has added a \`moved {}\` block, it also hard-blocks a push that never ran the check at all, so abandoning the refactor means REVERTING its edits rather than leaving them in with the fix. If it can't be proven equivalent, abandon the refactor and ship the fix alone.
26
26
  - **document the module (only when the \`docs\` input is enabled)**: after the equivalence check passes, call \`${t("terraform_module_docs")}\` for the module you created/adopted (pass the relocated addresses as \`moves\`). Do not hand-write docs — the tool builds from the interface, moved blocks, and equivalence verdict.
@@ -34,7 +34,7 @@ export function remediateMode(t: ToolRef): Mode {
34
34
 
35
35
  4. **for the chosen group**:
36
36
  - **base branch**: this run's base branch is resolved deterministically — \`${t("create_pull_request")}\` targets the \`base_branch\` input if set, else the branch the run started on, else the repository's default branch (\`main\`, or \`master\`). You do not choose it; just **omit** the \`base\` argument when opening the PR (below) and it is filled in.
37
- - **idempotency**: the remediation branch is the group's \`branch\` (\`remediate/<group-id>\`, with a \`--<base>\` suffix when the PR targets a non-default branch) — use it exactly, never a name of your own. Before doing anything, check whether that branch or an open PR for it already exists (\`${t("git")}\` / \`${t("get_pull_request")}\`). If one exists, update it rather than opening a duplicate.
37
+ - **idempotency**: the remediation branch is the group's \`branch\` (\`remediate/<group-id>\`, with a \`--<base>\` suffix when the PR targets a non-default branch, or the branch of the PR already open for this group) — use it exactly, never a name of your own. Before doing anything, check whether that branch or an open PR for it already exists (\`${t("git")}\` / \`${t("get_pull_request")}\`). If one exists, update it rather than opening a duplicate.
38
38
  - **branch**: create the group's \`branch\` from the **current HEAD** (the checkout that was just scanned) via \`${t("git")}\` (\`git({ command: "checkout", args: ["-b", <branch>] })\`). Do NOT switch to a different base first — branching from the scanned checkout keeps the PR diff to exactly your fix.
39
39
  - **honest refusal (decide BEFORE fixing)**: if the group's concerns appear in the scan's \`refusal_candidates\` (the fix needs a human decision — narrowing an IAM wildcard, a KMS key policy, a real ingress CIDR), do **not** guess a fix that could break the stack. Instead open a structured issue (\`${t("create_issue")}\`) describing the concern, why it isn't auto-fixed, and what a human should do, and skip the PR for that group. A proven fix or an honest refusal — never a guessed, unverifiable PR.
40
40
  - **propose, then let me steer (when there's no single right fix)**: distinct from honest refusal (which refuses a fix a human must *decide*), this is for a finding with **2–3 genuinely distinct, defensible fixes** that differ in trade-offs, not correctness (e.g. encrypt with an AWS-managed key **vs** a customer-managed KMS key; a narrow security-group rule **vs** a prefix list **vs** a VPC endpoint). When such a fork exists **and the triggering comment did not already select a strategy**, do **not** silently pick for the reviewer: via \`${t("create_issue_comment")}\` post one short comment listing the options as **A / B / C** — each a single line (what it does + its trade-off) — and ask the reviewer to reply \`${COMMENT_COMMAND} fix #<concern-id> with strategy <A|B|C>\`. Then **skip the PR for this group** this run and note it in your final report (it resumes when the reviewer replies). When the comment **did** select one (\`fix #<id> with strategy B\`, or a bare \`strategy B\` reply on the proposal thread), apply **exactly** that strategy — don't second-guess it. Reserve this for real forks in the road; a fix with one obvious correct answer just gets made.
@@ -105,10 +105,11 @@ export async function runAgentWithWatchdogs(params: RunAgentParams): Promise<Age
105
105
  const costQuota = reserved ? quotas.cost : createCostQuota(quotas.costLimit.full);
106
106
  log.info(
107
107
  quotas.tokenLimit.full > 0
108
- ? `» run token quota: ${quotas.tokenLimit.full.toLocaleString("en-GB")} billable tokens` +
108
+ ? `» run token quota: ${quotas.tokenLimit.full.toLocaleString("en-GB")} tokens` +
109
109
  (reserved
110
110
  ? ` (${tokenQuota.limit.toLocaleString("en-GB")} for the run, the rest reserved to deliver findings if it runs out)`
111
- : "")
111
+ : "") +
112
+ "; a token read from the prompt cache counts as a tenth"
112
113
  : `» run token quota: disabled (${RUN_TOKEN_QUOTA_INPUT}: 0)`,
113
114
  );
114
115
  log.info(