infraweaver 0.3.10 → 0.3.12

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 (51) hide show
  1. package/README.md +1 -1
  2. package/dist/agents/quotaTokens.d.ts +8 -0
  3. package/dist/cli.mjs +1929 -1608
  4. package/dist/index.js +568 -250
  5. package/dist/internal.js +6 -4
  6. package/dist/mcp/crosswalk-digest.d.ts +1 -1
  7. package/dist/mcp/git.d.ts +19 -3
  8. package/dist/mcp/localContext.d.ts +9 -1
  9. package/dist/mcp/shell.d.ts +3 -3
  10. package/dist/mcp/staleFix.d.ts +13 -1
  11. package/dist/mcp/terraform/awsPrices.d.ts +1 -1
  12. package/dist/mcp/terraform/azurePrices.d.ts +1 -1
  13. package/dist/mcp/terraform/hardcodedSecrets.d.ts +49 -0
  14. package/dist/mcp/terraform/nativeRules.d.ts +1 -0
  15. package/dist/mcp/terraform/refactorScope.d.ts +12 -0
  16. package/dist/mcp/terraform/scanSession.d.ts +2 -0
  17. package/dist/mcp/terraform/tools/scan.d.ts +1 -0
  18. package/dist/mcp/terraform/types.d.ts +3 -0
  19. package/dist/mcp/terraform/verifySites.d.ts +48 -0
  20. package/dist/utils/config/payload.d.ts +2 -0
  21. package/package.json +1 -1
  22. package/src/agents/claude.ts +2 -1
  23. package/src/agents/opencode.ts +2 -1
  24. package/src/agents/quotaTokens.ts +25 -0
  25. package/src/mcp/crosswalk-digest.ts +1 -1
  26. package/src/mcp/crosswalk.ts +2 -0
  27. package/src/mcp/git.ts +44 -3
  28. package/src/mcp/localContext.ts +8 -0
  29. package/src/mcp/localServer.ts +3 -0
  30. package/src/mcp/moduleExtraction.ts +4 -1
  31. package/src/mcp/shell.ts +2 -2
  32. package/src/mcp/staleFix.ts +21 -2
  33. package/src/mcp/terraform/awsPrices.ts +1 -1
  34. package/src/mcp/terraform/azurePrices.ts +23 -23
  35. package/src/mcp/terraform/concernResult.ts +26 -4
  36. package/src/mcp/terraform/hardcodedSecrets.ts +202 -0
  37. package/src/mcp/terraform/nativeRuleScanners.ts +8 -0
  38. package/src/mcp/terraform/nativeRules.ts +14 -0
  39. package/src/mcp/terraform/refactorScope.ts +86 -0
  40. package/src/mcp/terraform/scanSession.ts +6 -0
  41. package/src/mcp/terraform/standardsReport.ts +2 -0
  42. package/src/mcp/terraform/tools/consolidationCandidates.ts +5 -1
  43. package/src/mcp/terraform/tools/normalizationCandidates.ts +5 -1
  44. package/src/mcp/terraform/tools/verifyRemediation.ts +23 -1
  45. package/src/mcp/terraform/types.ts +3 -0
  46. package/src/mcp/terraform/verifySites.ts +106 -0
  47. package/src/modes/refactor.ts +2 -0
  48. package/src/modes/refresh-remediation.ts +1 -1
  49. package/src/modes/remediate-and-refactor.ts +1 -1
  50. package/src/modes/remediate.ts +2 -2
  51. package/src/utils/config/payload.ts +4 -0
@@ -9,30 +9,30 @@ export const AZURE_PRICE_REGION = "westeurope";
9
9
  /** Currency of every price below. */
10
10
  export const AZURE_PRICE_CURRENCY = "EUR";
11
11
  /** ISO date the snapshot was captured — surfaced in cost.md so staleness is visible. */
12
- export const AZURE_PRICE_FETCHED_AT = "2026-09-03";
12
+ export const AZURE_PRICE_FETCHED_AT = "2026-10-03";
13
13
 
14
14
  /** meter key → list price per HOUR. */
15
15
  export const AZURE_HOURLY_PRICES: Record<string, number> = {
16
- "app-service-p0v3": 0.0764,
17
- "app-service-p1v3": 0.1528,
18
- "app-service-p2v3": 0.3057,
19
- "app-service-p3v3": 0.6114,
20
- "bastion-basic": 0.1631,
21
- "bastion-premium": 0.3864,
22
- "bastion-standard": 0.249,
23
- "firewall-basic": 0.3392,
24
- "firewall-premium": 1.5027,
25
- "firewall-standard": 1.0733,
26
- "redis-c0": 0.0236,
27
- "redis-c1": 0.0592,
28
- "redis-c2": 0.0962,
29
- "redis-c3": 0.1932,
30
- "redis-p1": 0.2378,
31
- "redis-p2": 0.4766,
32
- "redis-p3": 0.9523,
33
- "vpn-gateway-vpngw1": 0.1631,
34
- "vpn-gateway-vpngw2": 0.4207,
35
- "vpn-gateway-vpngw3": 1.0733,
36
- "vpn-gateway-vpngw4": 1.8032,
37
- "vpn-gateway-vpngw5": 3.1341,
16
+ "app-service-p0v3": 0.0783,
17
+ "app-service-p1v3": 0.1566,
18
+ "app-service-p2v3": 0.3133,
19
+ "app-service-p3v3": 0.6265,
20
+ "bastion-basic": 0.1672,
21
+ "bastion-premium": 0.396,
22
+ "bastion-standard": 0.2552,
23
+ "firewall-basic": 0.3476,
24
+ "firewall-premium": 1.54,
25
+ "firewall-standard": 1.1,
26
+ "redis-c0": 0.0242,
27
+ "redis-c1": 0.0607,
28
+ "redis-c2": 0.0986,
29
+ "redis-c3": 0.198,
30
+ "redis-p1": 0.2438,
31
+ "redis-p2": 0.4884,
32
+ "redis-p3": 0.9759,
33
+ "vpn-gateway-vpngw1": 0.1672,
34
+ "vpn-gateway-vpngw2": 0.4312,
35
+ "vpn-gateway-vpngw3": 1.1,
36
+ "vpn-gateway-vpngw4": 1.8479,
37
+ "vpn-gateway-vpngw5": 3.2119,
38
38
  };
@@ -1,4 +1,5 @@
1
1
  import type { LocalToolContext } from "#app/mcp/localContext";
2
+ import { remediationBranchSuffix } from "#app/mcp/staleFix";
2
3
  import {
3
4
  capSeededConcerns,
4
5
  type PrioritySignals,
@@ -24,6 +25,7 @@ import {
24
25
  type Severity,
25
26
  sortConcerns,
26
27
  } from "#app/mcp/terraform/types";
28
+ import { recordConcernSites } from "#app/mcp/terraform/verifySites";
27
29
 
28
30
  export interface ConcernResult {
29
31
  /** non-suppressed concerns ≥ threshold, sorted */
@@ -65,6 +67,18 @@ export function annotateConcerns(
65
67
  );
66
68
  }
67
69
 
70
+ /** The branch this run's PRs target, as `create_pull_request` resolves it: the
71
+ * `base_branch` input, else the branch the run started on, else the default. */
72
+ function remediationBaseOf(ctx: LocalToolContext): string | undefined {
73
+ const head = ctx.toolState.initialHead;
74
+ return (
75
+ ctx.payload.baseBranch?.trim() ||
76
+ (head?.kind === "branch" ? head.name : undefined) ||
77
+ ctx.repo?.data.default_branch?.trim() ||
78
+ undefined
79
+ );
80
+ }
81
+
68
82
  /**
69
83
  * The shared concerns→result pipeline behind `terraform_scan` and `read_findings`:
70
84
  * capture the line-pinned + line-independent baseline (so verify's regression diff
@@ -115,6 +129,7 @@ export function buildConcernResult(
115
129
  fullBaseline.map((c) => concernKeyOf(c)),
116
130
  );
117
131
  recordConcernKeys(session, fullBaseline);
132
+ recordConcernSites(session, fullBaseline, opts.cwd);
118
133
  // the files already unformatted at this (pre-fix) scan. terraform_validate
119
134
  // subtracts these so PRE-EXISTING `terraform fmt` drift can't force a
120
135
  // whole-file reformat into a scoped fix. Captured from the raw set (fmt
@@ -141,10 +156,17 @@ export function buildConcernResult(
141
156
  active,
142
157
  opts.autonomyThreshold,
143
158
  );
144
- const groups = opts.priority
145
- ? prioritizeGroups(annotatedGroups, active, opts.priority)
146
- : annotatedGroups;
147
- const batchPlan = planBatches(groups);
159
+ const suffix = remediationBranchSuffix(
160
+ remediationBaseOf(ctx),
161
+ ctx.repo?.data.default_branch?.trim() || undefined,
162
+ );
163
+ const groups = (
164
+ opts.priority ? prioritizeGroups(annotatedGroups, active, opts.priority) : annotatedGroups
165
+ ).map((g) => ({ ...g, branch: `remediate/${g.id}${suffix}` }));
166
+ const planned = planBatches(groups);
167
+ const batchPlan = planned.batch_branch
168
+ ? { ...planned, batch_branch: `${planned.batch_branch}${suffix}` }
169
+ : planned;
148
170
 
149
171
  const bySeverity: Record<string, number> = {};
150
172
  for (const c of active) bySeverity[c.severity] = (bySeverity[c.severity] ?? 0) + 1;
@@ -0,0 +1,202 @@
1
+ /**
2
+ * A credential written into the configuration as a string literal, by the
3
+ * name of the argument that holds it.
4
+ *
5
+ * WHY BY NAME. The secret scanners look at the VALUE — a known key format, or a
6
+ * string with enough entropy — so a hand-made password (`"cg-flagshop-Pr0d!…"`)
7
+ * slips past all of them. Terraform says more than a text scanner can see: the
8
+ * value sits in an argument, a map key or a variable whose name says what it
9
+ * holds. A literal in `password`, `client_secret` or a Lambda environment
10
+ * variable called `auth` is in the repository whatever it looks like.
11
+ *
12
+ * WHAT IS READ. Every `name = "…"` line inside a `resource`, `data`,
13
+ * `provider`, `module` or `locals` block, at any depth, so
14
+ * `environment { variables = { auth = "…" } }` and `jsonencode({ … })` objects
15
+ * are reached; and the `default` of a `variable` whose name is a secret's. Not
16
+ * read: comments, heredoc bodies, a name/value pair split across two keys
17
+ * (`[{ name = "PASSWORD", value = "…" }]`), and a value that starts on a later
18
+ * line than its name.
19
+ *
20
+ * WHAT IS NOT A FINDING. A reference, a function call, an interpolation, an
21
+ * empty string, or a placeholder made only of `*` / `x` / `<…>`. Neither is an
22
+ * argument whose name only mentions a secret (`password_length`, `secret_name`,
23
+ * `token_validity_units`, `creation_token`): the last word of the name decides.
24
+ *
25
+ * Arguments the provider can take write-only (`aws_db_instance.password`) may
26
+ * also be reported by `secret-persisted-to-state`, which says something else:
27
+ * that the value reaches state. This one says it is in git.
28
+ */
29
+
30
+ import { isStringLiteral, stripTrailingComment } from "#app/mcp/terraform/hcl";
31
+ import { HARDCODED_SECRET_ARGUMENT, NATIVE_SOURCE } from "#app/mcp/terraform/nativeRules";
32
+ import type { SourceFile } from "#app/mcp/terraform/tree";
33
+ import { buildConcern, type Concern } from "#app/mcp/terraform/types";
34
+
35
+ /** a name's last word, alone, says it holds a secret. */
36
+ const SECRET_WORDS = new Set([
37
+ "password",
38
+ "passwd",
39
+ "pwd",
40
+ "passphrase",
41
+ "secret",
42
+ "token",
43
+ "auth",
44
+ "apikey",
45
+ "credential",
46
+ "credentials",
47
+ ]);
48
+ /** `<word>_key` names that hold a secret (`api_key`, `secret_key`, …). */
49
+ const SECRET_KEY_PREFIXES = new Set(["api", "secret", "private", "signing"]);
50
+ /** `<word>_token` names that are identifiers, not credentials. */
51
+ const NOT_SECRET_TOKENS = new Set(["creation", "idempotency", "client", "request", "continuation"]);
52
+
53
+ function words(name: string): string[] {
54
+ return name
55
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
56
+ .toLowerCase()
57
+ .split(/[_-]+/)
58
+ .filter(Boolean);
59
+ }
60
+
61
+ /** True when an argument, map key or variable called `name` holds a secret. */
62
+ export function isSecretName(name: string): boolean {
63
+ const w = words(name);
64
+ const last = w.at(-1);
65
+ const prev = w.at(-2);
66
+ if (last === undefined) return false;
67
+ if (last === "key") return prev !== undefined && SECRET_KEY_PREFIXES.has(prev);
68
+ if (last === "token" && prev !== undefined && NOT_SECRET_TOKENS.has(prev)) return false;
69
+ return SECRET_WORDS.has(last);
70
+ }
71
+
72
+ /** A literal that says it is not a real value: empty, masked, or `<…>`. */
73
+ function isPlaceholder(inner: string): boolean {
74
+ return (
75
+ inner.trim() === "" ||
76
+ /^[*xX.]+$/.test(inner) ||
77
+ /^<[^>]*>$/.test(inner) ||
78
+ // the dummy credentials offline plans and LocalStack are given (`secret_key = "test"`)
79
+ /^(?:test|mock|fake|dummy|fixture|example|placeholder|sample)(?:[_-].*)?$/i.test(inner) ||
80
+ // a token's lifetime unit: `token_validity_units { access_token = "hours" }`
81
+ /^(?:seconds|minutes|hours|days)$/.test(inner)
82
+ );
83
+ }
84
+
85
+ export interface HardcodedSecret {
86
+ /** the argument, map key or variable name. */
87
+ name: string;
88
+ /** what holds it: `resource "aws_lambda_function" "x"`, `variable "db_password"`, … */
89
+ holder: string;
90
+ line: number;
91
+ }
92
+
93
+ /** a top-level block header: `resource "aws_db_instance" "db" {`, `locals {` */
94
+ const HEADER = /^(resource|data|provider|module|locals|variable|output|terraform)\b([^{]*)\{\s*$/;
95
+ /** `name = value`, the name bare or quoted */
96
+ const ASSIGNMENT = /^\s*(?:"([A-Za-z_][\w-]*)"|([A-Za-z_][\w-]*))\s*=(?!=)\s*(.*)$/;
97
+ const HEREDOC = /<<-?([A-Za-z_]\w*)\s*$/;
98
+ /** a `key = "string"` pair inside a one-line object */
99
+ const INLINE_PAIR = /(?:"([A-Za-z_][\w-]*)"|\b([A-Za-z_][\w-]*))\s*=(?!=)\s*("(?:[^"\\]|\\.)*")/g;
100
+
101
+ function literalValue(raw: string): boolean {
102
+ const value = stripTrailingComment(raw).replace(/,$/, "").trim();
103
+ return isStringLiteral(value) && !isPlaceholder(value.slice(1, -1));
104
+ }
105
+
106
+ /**
107
+ * Every secret-named literal in one file. Pure.
108
+ *
109
+ * One pass over the lines rather than a block parse: every assignment line is
110
+ * the same question whatever block it sits in, and the native pack's block
111
+ * parser is shared by every scanner and budgeted (see nativeScan.perf.test).
112
+ */
113
+ export function findHardcodedSecrets(hcl: string): HardcodedSecret[] {
114
+ const out: HardcodedSecret[] = [];
115
+ let holder: string | null = null;
116
+ let heredoc: string | null = null;
117
+ let inComment = false;
118
+ const lines = hcl.split("\n");
119
+ for (let i = 0; i < lines.length; i++) {
120
+ const line = (lines[i] as string).replace(/\r$/, "");
121
+ if (heredoc !== null) {
122
+ if (line.trim() === heredoc) heredoc = null;
123
+ continue;
124
+ }
125
+ if (inComment) {
126
+ if (line.includes("*/")) inComment = false;
127
+ continue;
128
+ }
129
+ const trimmed = line.trimStart();
130
+ if (trimmed.startsWith("/*")) {
131
+ inComment = !trimmed.includes("*/");
132
+ continue;
133
+ }
134
+ if (trimmed.startsWith("#") || trimmed.startsWith("//")) continue;
135
+ const header = HEADER.exec(line);
136
+ if (header) {
137
+ holder = `${header[1]}${(header[2] as string).trimEnd() ? ` ${(header[2] as string).trim()}` : ""}`;
138
+ continue;
139
+ }
140
+ if (line.startsWith("}")) {
141
+ holder = null;
142
+ continue;
143
+ }
144
+ const opens = HEREDOC.exec(line);
145
+ if (opens) heredoc = opens[1] as string;
146
+ if (holder === null || opens) continue;
147
+ const m = ASSIGNMENT.exec(line);
148
+ if (!m) continue;
149
+ const name = (m[1] ?? m[2]) as string;
150
+ const value = m[3] as string;
151
+ if (holder.startsWith("variable ")) {
152
+ // a variable's literal is its default, and the variable's name says what it holds
153
+ const variable = /^variable\s+"?([\w-]+)"?/.exec(holder)?.[1] ?? "";
154
+ if (name === "default" && isSecretName(variable) && literalValue(value)) {
155
+ out.push({ name: variable, holder, line: i + 1 });
156
+ }
157
+ continue;
158
+ }
159
+ if (holder.startsWith("output ") || holder.startsWith("terraform")) continue;
160
+ if (isSecretName(name) && literalValue(value)) {
161
+ out.push({ name, holder, line: i + 1 });
162
+ continue;
163
+ }
164
+ // an object written on one line holds its own pairs: `{ "DB_PASSWORD" = "…" }`
165
+ for (const pair of value.matchAll(INLINE_PAIR)) {
166
+ const key = (pair[1] ?? pair[2]) as string;
167
+ if (isSecretName(key) && literalValue(pair[3] as string)) {
168
+ out.push({ name: key, holder, line: i + 1 });
169
+ }
170
+ }
171
+ }
172
+ return out;
173
+ }
174
+
175
+ /** Scan an already-read tree into native concerns. Pure; no I/O of its own. */
176
+ export function scanHardcodedSecrets(files: readonly SourceFile[]): Concern[] {
177
+ const concerns: Concern[] = [];
178
+ for (const { path, hcl } of files) {
179
+ for (const hit of findHardcodedSecrets(hcl)) {
180
+ concerns.push(
181
+ buildConcern({
182
+ source: NATIVE_SOURCE,
183
+ rule: HARDCODED_SECRET_ARGUMENT,
184
+ file: path,
185
+ line: hit.line,
186
+ // a credential in the repository is readable by everyone who can read
187
+ // it, and stays in the history after it is edited out
188
+ severity: "high",
189
+ category: "security",
190
+ evidence:
191
+ `\`${hit.name}\` in \`${hit.holder}\` is a string literal, so the credential it holds is ` +
192
+ "committed to the repository (value not quoted here)",
193
+ remediation_hint:
194
+ "Rotate the credential first — it stays in the branch history after the line is changed. " +
195
+ "Then pass it in at run time: a `sensitive` variable with no default, or a read from a " +
196
+ "secret store (AWS Secrets Manager, Azure Key Vault, GCP Secret Manager).",
197
+ }),
198
+ );
199
+ }
200
+ }
201
+ return concerns;
202
+ }
@@ -26,6 +26,7 @@
26
26
 
27
27
  import { scanDialect } from "#app/mcp/terraform/dialect";
28
28
  import { scanGovernanceFile } from "#app/mcp/terraform/governance";
29
+ import { scanHardcodedSecrets } from "#app/mcp/terraform/hardcodedSecrets";
29
30
  import { scanInterfaceHygiene } from "#app/mcp/terraform/moduleInterfaceRules";
30
31
  import { scanUnpinnedModules } from "#app/mcp/terraform/moduleVersionConstraints";
31
32
  import {
@@ -34,6 +35,7 @@ import {
34
35
  DEPRECATED_S3_BUCKET_ARGUMENT,
35
36
  DEPRECATED_STATE_LOCKING,
36
37
  DYNAMIC_DESTROY_PROTECTION,
38
+ HARDCODED_SECRET_ARGUMENT,
37
39
  IMPERATIVE_ACTION_TRIGGER,
38
40
  LEGACY_BLOCK_ASSIGNMENT,
39
41
  LEGACY_INTERPOLATION,
@@ -125,6 +127,12 @@ export const NATIVE_RULE_SCANNERS: readonly NativeRuleScanner[] = [
125
127
  // one. See [[stateFiles]].
126
128
  scan: (ctx) => scanCommittedStateFiles(ctx.cwd),
127
129
  },
130
+ {
131
+ name: "hardcoded-secrets",
132
+ rules: [HARDCODED_SECRET_ARGUMENT],
133
+ // Per file: the argument's name and its literal are both on the line itself.
134
+ scan: (ctx) => ({ concerns: scanHardcodedSecrets(ctx.files) }),
135
+ },
128
136
  {
129
137
  name: "write-only",
130
138
  rules: [SECRET_PERSISTED_TO_STATE],
@@ -59,6 +59,7 @@ export interface NativeRule {
59
59
 
60
60
  export const SECRET_PERSISTED_TO_STATE = "secret-persisted-to-state";
61
61
  export const COMMITTED_STATE_FILE = "committed-state-file";
62
+ export const HARDCODED_SECRET_ARGUMENT = "hardcoded-secret-argument";
62
63
  export const IMPERATIVE_ACTION_TRIGGER = "imperative-action-trigger";
63
64
  export const DYNAMIC_DESTROY_PROTECTION = "dynamic-destroy-protection";
64
65
  export const DEPRECATED_STATE_LOCKING = "deprecated-state-locking";
@@ -103,6 +104,19 @@ export const NATIVE_RULES: readonly NativeRule[] = [
103
104
  "that the state contains a live credential, or that anyone has read it — the file is never opened; " +
104
105
  "the finding is that state is under version control, and what it recorded is for the owner to say",
105
106
  },
107
+ {
108
+ id: HARDCODED_SECRET_ARGUMENT,
109
+ title: "Credential written into the configuration as a literal",
110
+ band: "verified",
111
+ category: "security",
112
+ asserts:
113
+ "an argument, map key or variable whose name says it holds a credential (`password`, `token`, " +
114
+ "`client_secret`, `api_key`, …) is assigned a string literal, so the value is in the repository " +
115
+ "and in its history",
116
+ doesNotAssert:
117
+ "that the value is a live credential — it may be a test value; the finding is that a credential " +
118
+ "slot is filled from the code rather than at run time",
119
+ },
106
120
  {
107
121
  id: IMPERATIVE_ACTION_TRIGGER,
108
122
  title: "Imperative side-effect invoked from declarative IaC (action / action_trigger)",
@@ -0,0 +1,86 @@
1
+ /**
2
+ * What a Refactor run may propose, beyond what the detectors find.
3
+ *
4
+ * EXAMPLES ARE NOT REFACTORED BY DEFAULT. An `examples/` root is documentation
5
+ * that happens to plan: its resources exist to show a caller how to use the
6
+ * module, so they repeat on purpose, and a PR that consolidates or extracts them
7
+ * rewrites the docs to save nobody anything. The `refactor_examples` input opts
8
+ * back in.
9
+ *
10
+ * A REUSABLE MODULE IS REPORTED, NOT RESTRUCTURED. A repository whose root
11
+ * configures no provider and no backend, and ships `examples/`, is a module other
12
+ * people call (terraform-aws-vpc, say). Moving its resources into a submodule is
13
+ * provably equivalent, and still churn for every consumer: their plans gain a
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.
18
+ */
19
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
20
+ import { join } from "node:path";
21
+
22
+ /** true for a path inside an `examples/` (or `example/`) directory, at any depth. */
23
+ export function isExamplePath(path: string): boolean {
24
+ return /(?:^|\/)examples?\//.test(path.replace(/\\/g, "/"));
25
+ }
26
+
27
+ const PROVIDER_OR_BACKEND = /^\s*(?:provider\s+"|backend\s+")/m;
28
+
29
+ /** whether some directory under `dir` (to a small depth) holds a `.tf` file. */
30
+ function holdsTerraform(dir: string, depth: number): boolean {
31
+ let entries: string[];
32
+ try {
33
+ entries = readdirSync(dir);
34
+ } catch {
35
+ return false;
36
+ }
37
+ for (const name of entries) {
38
+ const path = join(dir, name);
39
+ if (name.endsWith(".tf")) return true;
40
+ if (
41
+ depth > 0 &&
42
+ !name.startsWith(".") &&
43
+ statSync(path, { throwIfNoEntry: false })?.isDirectory()
44
+ ) {
45
+ if (holdsTerraform(path, depth - 1)) return true;
46
+ }
47
+ }
48
+ return false;
49
+ }
50
+
51
+ /**
52
+ * Why the repository at `cwd` reads as a reusable module rather than a
53
+ * deployment, or null when it does not.
54
+ */
55
+ export function libraryRepoReason(cwd: string): string | null {
56
+ let rootFiles: string[];
57
+ try {
58
+ rootFiles = readdirSync(cwd).filter((f) => f.endsWith(".tf"));
59
+ } catch {
60
+ return null;
61
+ }
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)));
73
+ if (examples === undefined || !holdsTerraform(join(cwd, examples), 2)) return null;
74
+ return (
75
+ `the repository root configures no provider and no backend, and ships \`${examples}/\`: it is a ` +
76
+ "module other configurations call, so restructuring it is churn for every caller"
77
+ );
78
+ }
79
+
80
+ /** what a detector tool adds to its result when the repository is a library. */
81
+ export function reportOnly(reason: string | null): {
82
+ report_only?: true;
83
+ report_only_reason?: string;
84
+ } {
85
+ return reason === null ? {} : { report_only: true, report_only_reason: reason };
86
+ }
@@ -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
@@ -364,6 +364,8 @@ const SUBJECT: Record<string, RegExp> = {
364
364
  // rule's silence a real statement. Whether git could answer is handled one
365
365
  // level up by the blind set: a run that could not ask is never conforming.
366
366
  "committed-state-file": ANY_BLOCK,
367
+ // any block may hold a credential argument, a map of them, or a variable default
368
+ "hardcoded-secret-argument": ANY_BLOCK,
367
369
  "deprecated-s3-bucket-argument": /(?:^|\n)\s*resource\s+"?aws_s3_bucket(?:"|\s)/,
368
370
  "superseded-resource-type":
369
371
  /(?:^|\n)\s*(?:resource\s+"?(?:aws_launch_configuration|aws_elb)(?:"|\s)|data\s+"?template_file(?:"|\s))/,
@@ -3,6 +3,7 @@ import type { LocalToolContext } from "#app/mcp/localContext";
3
3
  import { execute, tool, toolOk } from "#app/mcp/shared";
4
4
  import { findConsolidationGroups } from "#app/mcp/terraform/consolidation";
5
5
  import { readWorkingTree } from "#app/mcp/terraform/refactor/workspace";
6
+ import { isExamplePath } from "#app/mcp/terraform/refactorScope";
6
7
  import { log } from "#app/utils/cli";
7
8
 
8
9
  const ConsolidationCandidatesParams = type({});
@@ -37,7 +38,10 @@ export function ConsolidationCandidatesTool(ctx: LocalToolContext) {
37
38
  parameters: ConsolidationCandidatesParams,
38
39
  execute: execute(async () => {
39
40
  const cwd = ctx.payload.cwd ?? process.cwd();
40
- const result = findConsolidationGroups(readWorkingTree(cwd));
41
+ const tree = readWorkingTree(cwd);
42
+ const result = findConsolidationGroups(
43
+ ctx.payload.refactorExamples ? tree : tree.filter((f) => !isExamplePath(f.path)),
44
+ );
41
45
  log.info(
42
46
  `» terraform_consolidation_candidates: ${result.groups.length} group(s), ` +
43
47
  `${result.near_groups.length} near-group(s), ${result.declined_groups.length} declined`,
@@ -2,6 +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
6
  import { log } from "#app/utils/cli";
6
7
 
7
8
  // --- the tool ----------------------------------------------------------------
@@ -28,12 +29,15 @@ export function NormalizationCandidatesTool(ctx: LocalToolContext) {
28
29
  parameters: NormalizationCandidatesParams,
29
30
  execute: execute(async () => {
30
31
  const cwd = ctx.payload.cwd ?? process.cwd();
31
- const files = findNormalizationCandidates(cwd);
32
+ const files = findNormalizationCandidates(cwd).filter(
33
+ (f) => ctx.payload.refactorExamples || !isExamplePath(f.file),
34
+ );
32
35
  const total = files.reduce((n, f) => n + f.count, 0);
33
36
  log.info(
34
37
  `» terraform_normalization_candidates: ${total} site(s) across ${files.length} file(s)`,
35
38
  );
36
39
  return toolOk({
40
+ ...reportOnly(libraryRepoReason(cwd)),
37
41
  file_count: files.length,
38
42
  total,
39
43
  files,
@@ -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];
@@ -398,6 +398,9 @@ export interface ConcernGroup {
398
398
  /** stable id — the remediation branch/PR key (`remediate/<id>`). Derived from
399
399
  * the file (by-file grouping) or the rule (by-rule grouping). */
400
400
  id: string;
401
+ /** the branch its fix is pushed to: `remediate/<id>`, plus `--<base>` when the
402
+ * PR targets a branch other than the repository default. */
403
+ branch?: string;
401
404
  /** the group's primary file (by-file) or a human label like "3 files"
402
405
  * (by-rule); `files` carries the full list for by-rule groups. */
403
406
  file: string;