terramend 0.2.0 → 0.2.1

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 (60) hide show
  1. package/dist/agents/claudePretoolGate.d.ts +2 -2
  2. package/dist/cli.mjs +16554 -8100
  3. package/dist/index.js +13484 -5037
  4. package/dist/internal.js +75 -11
  5. package/dist/mcp/assess.d.ts +86 -0
  6. package/dist/mcp/changeSummary.d.ts +50 -0
  7. package/dist/mcp/crosswalk.d.ts +5 -0
  8. package/dist/mcp/localContext.d.ts +1 -1
  9. package/dist/mcp/terraform/evidence.d.ts +99 -0
  10. package/dist/mcp/terraform/scanners.d.ts +38 -3
  11. package/dist/mcp/terraform/types.d.ts +16 -0
  12. package/dist/mcp/terraform/verification.d.ts +74 -0
  13. package/dist/mcp/terraform.d.ts +4 -0
  14. package/dist/modes.d.ts +1 -1
  15. package/dist/toolState.d.ts +1 -0
  16. package/dist/utils/moduleFetch.d.ts +42 -0
  17. package/dist/utils/payload.d.ts +4 -0
  18. package/dist/utils/remediationCommand.d.ts +3 -0
  19. package/dist/utils/terraformMcp.d.ts +2 -2
  20. package/dist/utils/terramendConfig.d.ts +51 -0
  21. package/dist/utils/toolLicensing.d.ts +56 -0
  22. package/dist/utils/toolSelection.d.ts +72 -0
  23. package/package.json +9 -8
  24. package/src/agents/claudePretoolGate.ts +3 -3
  25. package/src/mcp/assess.test.ts +135 -0
  26. package/src/mcp/assess.ts +341 -0
  27. package/src/mcp/changeSummary.test.ts +94 -0
  28. package/src/mcp/changeSummary.ts +145 -0
  29. package/src/mcp/crosswalk.ts +15 -1
  30. package/src/mcp/guardrails.ts +11 -6
  31. package/src/mcp/localContext.ts +7 -0
  32. package/src/mcp/localServer.test.ts +2 -0
  33. package/src/mcp/localServer.ts +14 -0
  34. package/src/mcp/server.ts +6 -0
  35. package/src/mcp/terraform/evidence.test.ts +72 -0
  36. package/src/mcp/terraform/evidence.ts +187 -0
  37. package/src/mcp/terraform/scanners.ts +86 -9
  38. package/src/mcp/terraform/tools.test.ts +96 -1
  39. package/src/mcp/terraform/tools.ts +115 -32
  40. package/src/mcp/terraform/types.ts +24 -0
  41. package/src/mcp/terraform/verification.test.ts +85 -0
  42. package/src/mcp/terraform/verification.ts +133 -0
  43. package/src/mcp/terraform.test.ts +108 -0
  44. package/src/mcp/terraform.ts +4 -0
  45. package/src/modes.test.ts +9 -1
  46. package/src/modes.ts +81 -11
  47. package/src/toolState.ts +6 -0
  48. package/src/utils/moduleFetch.test.ts +68 -0
  49. package/src/utils/moduleFetch.ts +86 -0
  50. package/src/utils/payload.test.ts +66 -1
  51. package/src/utils/payload.ts +39 -11
  52. package/src/utils/remediationCommand.test.ts +32 -0
  53. package/src/utils/remediationCommand.ts +11 -0
  54. package/src/utils/terraformMcp.ts +6 -5
  55. package/src/utils/terramendConfig.test.ts +98 -0
  56. package/src/utils/terramendConfig.ts +143 -0
  57. package/src/utils/toolLicensing.test.ts +54 -0
  58. package/src/utils/toolLicensing.ts +103 -0
  59. package/src/utils/toolSelection.test.ts +140 -0
  60. package/src/utils/toolSelection.ts +231 -0
@@ -0,0 +1,341 @@
1
+ import { type } from "arktype";
2
+ import { buildCrosswalkReport, type CrosswalkReport } from "#app/mcp/crosswalk";
3
+ import type { LocalToolContext } from "#app/mcp/localContext";
4
+ import { execute, tool, toolOk } from "#app/mcp/shared";
5
+ import { runScanners } from "#app/mcp/terraform/scanners";
6
+ import {
7
+ type Concern,
8
+ dedupe,
9
+ isTerraformConcern,
10
+ type ScannerOutcome,
11
+ SEVERITY_RANK,
12
+ type Severity,
13
+ sortConcerns,
14
+ } from "#app/mcp/terraform/types";
15
+ import {
16
+ buildVerificationSummary,
17
+ type VerificationSummary,
18
+ } from "#app/mcp/terraform/verification";
19
+ import { log } from "#app/utils/cli";
20
+ import { resolveModuleFetchEnv } from "#app/utils/moduleFetch";
21
+ import { type ResolvedToolSelection, resolveToolSelection } from "#app/utils/toolSelection";
22
+
23
+ /**
24
+ * Assess pillar — the read-only product (roadmap pillar 3). Terramend's scanner
25
+ * engine has two modes off ONE codebase: Remediate = engine + fix loop + verify;
26
+ * **Assess = engine, read-only**. This surfaces that read-only half as a
27
+ * first-class deliverable: run the deterministic scanners, normalise into the
28
+ * findings schema, map to the compliance crosswalk (§23), and produce a
29
+ * **scorecard** + an auditor-facing markdown report — WITHOUT touching the
30
+ * Terraform or opening a PR. No cloud credentials, no writes.
31
+ *
32
+ * The scorecard is deterministic (computed from tool results, never the model's
33
+ * word) so a CI gate can branch on `posture` and an assessor gets a reproducible,
34
+ * framework-mapped report.
35
+ */
36
+
37
+ export type AssessPosture = "clean" | "advisory" | "action-required";
38
+
39
+ export interface AssessTopRisk {
40
+ rule_id: string;
41
+ severity: Severity;
42
+ file: string;
43
+ line: number | null;
44
+ evidence: string;
45
+ }
46
+
47
+ export interface AssessmentScorecard {
48
+ /** clean (0 concerns) · advisory (only medium/low/info) · action-required (≥1 critical/high). */
49
+ posture: AssessPosture;
50
+ total: number;
51
+ by_severity: Record<Severity, number>;
52
+ /** highest-severity concerns first, capped — the "what to look at first" list. */
53
+ top_risks: AssessTopRisk[];
54
+ compliance: {
55
+ /** frameworks this scan touched (from the crosswalk's by_framework index). */
56
+ frameworks: string[];
57
+ /** distinct controls touched across all frameworks. */
58
+ controls_touched: number;
59
+ /** concerns that mapped to ≥1 control vs none (honest coverage signal). */
60
+ mapped: number;
61
+ unmapped: number;
62
+ version: string;
63
+ reviewed: string;
64
+ };
65
+ /** five-status verification taxonomy: per-concern fail / not-code-verifiable +
66
+ * the scanner coverage (verified vs inconclusive). Keeps a "clean" posture
67
+ * honest — e.g. "clean, but tflint inconclusive (not run)". */
68
+ verification: VerificationSummary;
69
+ }
70
+
71
+ /** posture from the severity distribution: any critical/high ⇒ action-required;
72
+ * any lower-severity concern ⇒ advisory; nothing ⇒ clean. */
73
+ export function assessPosture(bySeverity: Record<Severity, number>): AssessPosture {
74
+ if (bySeverity.critical > 0 || bySeverity.high > 0) return "action-required";
75
+ if (bySeverity.medium > 0 || bySeverity.low > 0 || bySeverity.info > 0) return "advisory";
76
+ return "clean";
77
+ }
78
+
79
+ const TOP_RISK_CAP = 10;
80
+
81
+ /**
82
+ * Build the deterministic assessment scorecard from a scan's concerns + their
83
+ * crosswalk report. Pure. `concerns` should be the severity-sorted, deduped,
84
+ * Terraform-only set; `crosswalk` is `buildCrosswalkReport(concerns)`.
85
+ */
86
+ export function buildAssessment(
87
+ concerns: Concern[],
88
+ crosswalk: CrosswalkReport,
89
+ verification: VerificationSummary,
90
+ ): AssessmentScorecard {
91
+ const by_severity: Record<Severity, number> = {
92
+ critical: 0,
93
+ high: 0,
94
+ medium: 0,
95
+ low: 0,
96
+ info: 0,
97
+ };
98
+ for (const c of concerns) by_severity[c.severity]++;
99
+
100
+ const top_risks = [...concerns]
101
+ .sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity])
102
+ .slice(0, TOP_RISK_CAP)
103
+ .map((c) => ({
104
+ rule_id: c.rule_id,
105
+ severity: c.severity,
106
+ file: c.location.file,
107
+ line: c.location.line,
108
+ evidence: c.evidence,
109
+ }));
110
+
111
+ const controls_touched = Object.values(crosswalk.by_framework).reduce(
112
+ (n, controls) => n + controls.length,
113
+ 0,
114
+ );
115
+
116
+ return {
117
+ posture: assessPosture(by_severity),
118
+ total: concerns.length,
119
+ by_severity,
120
+ top_risks,
121
+ compliance: {
122
+ frameworks: Object.keys(crosswalk.by_framework),
123
+ controls_touched,
124
+ mapped: crosswalk.entries.length,
125
+ unmapped: crosswalk.unmapped_concern_ids.length,
126
+ version: crosswalk.version,
127
+ reviewed: crosswalk.reviewed,
128
+ },
129
+ verification,
130
+ };
131
+ }
132
+
133
+ const POSTURE_BANNER: Record<AssessPosture, string> = {
134
+ clean: "> [!NOTE]\n> ✅ **Clean** — no best-practice concerns found at the configured threshold.",
135
+ advisory:
136
+ "> [!WARNING]\n> ⚠️ **Advisory** — concerns found, but none critical/high. Plan to address them.",
137
+ "action-required":
138
+ "> [!CAUTION]\n> 🚨 **Action required** — critical/high-severity concerns found. Review before relying on this Terraform.",
139
+ };
140
+
141
+ /**
142
+ * Render the scorecard as a deterministic, auditor-facing markdown report (the
143
+ * Assess deliverable). Built entirely from the scorecard so it's reproducible and
144
+ * model-independent. Pure.
145
+ */
146
+ export function renderAssessmentMarkdown(s: AssessmentScorecard): string {
147
+ const lines: string[] = [];
148
+ lines.push(POSTURE_BANNER[s.posture]);
149
+ lines.push("");
150
+ lines.push("## Terraform best-practice assessment");
151
+ lines.push("");
152
+ const sevOrder: Severity[] = ["critical", "high", "medium", "low", "info"];
153
+ const sevCells = sevOrder
154
+ .filter((sev) => s.by_severity[sev] > 0)
155
+ .map((sev) => `\`${sev}: ${s.by_severity[sev]}\``);
156
+ lines.push(
157
+ `**${s.total} concern${s.total === 1 ? "" : "s"}** — ${
158
+ sevCells.length ? sevCells.join(" · ") : "none"
159
+ }`,
160
+ );
161
+ lines.push("");
162
+
163
+ if (s.top_risks.length > 0) {
164
+ lines.push("### Top risks");
165
+ lines.push("");
166
+ for (const r of s.top_risks) {
167
+ const loc = r.line !== null ? `\`${r.file}:${r.line}\`` : `\`${r.file}\``;
168
+ lines.push(`- ${severityEmoji(r.severity)} \`${r.rule_id}\` — ${loc} — ${r.evidence}`);
169
+ }
170
+ lines.push("");
171
+ }
172
+
173
+ lines.push("### Compliance crosswalk");
174
+ lines.push("");
175
+ if (s.compliance.frameworks.length > 0) {
176
+ lines.push(
177
+ `Indicative alignment (crosswalk v${s.compliance.version}, reviewed ${s.compliance.reviewed}) — ` +
178
+ `**not an audit verdict**. Touches ${s.compliance.controls_touched} control(s) across ` +
179
+ `${s.compliance.frameworks.length} framework(s): ${s.compliance.frameworks.join(", ")}.`,
180
+ );
181
+ if (s.compliance.unmapped > 0) {
182
+ lines.push("");
183
+ lines.push(
184
+ `> ${s.compliance.unmapped} concern(s) did not map to a control in the starter rule-pack (honest coverage gap).`,
185
+ );
186
+ }
187
+ } else {
188
+ lines.push("No concerns mapped to a compliance control in the starter rule-pack.");
189
+ }
190
+ lines.push("");
191
+
192
+ // five-status verification taxonomy — keep the posture honest about coverage.
193
+ const v = s.verification;
194
+ lines.push("### Verification coverage");
195
+ lines.push("");
196
+ lines.push(
197
+ `Code-verified by: ${v.coverage.verified.length ? v.coverage.verified.join(", ") : "none"}.`,
198
+ );
199
+ if (v.coverage.inconclusive.length > 0) {
200
+ lines.push("");
201
+ lines.push(
202
+ `> [!WARNING]\n> **Inconclusive** (a coverage gap, not a pass) — these checks did not run:`,
203
+ );
204
+ for (const t of v.coverage.inconclusive) lines.push(`> - \`${t.source}\` — ${t.reason}`);
205
+ }
206
+ if (v.counts.not_code_verifiable > 0) {
207
+ lines.push("");
208
+ lines.push(
209
+ `> ${v.counts.not_code_verifiable} concern(s) are **not code-verifiable** — they need a human/process decision (e.g. IAM least-privilege, a KMS key policy) the engine can flag but not prove.`,
210
+ );
211
+ }
212
+ lines.push("");
213
+ lines.push(`_${v.note}_`);
214
+
215
+ lines.push("");
216
+ lines.push(
217
+ "_Read-only assessment — no Terraform was modified and no PR was opened. " +
218
+ "Run Terramend in `remediate` mode to fix and prove these concerns._",
219
+ );
220
+ return lines.join("\n");
221
+ }
222
+
223
+ function severityEmoji(sev: Severity): string {
224
+ switch (sev) {
225
+ case "critical":
226
+ return "🚨";
227
+ case "high":
228
+ return "⚠️";
229
+ case "medium":
230
+ return "🔶";
231
+ case "low":
232
+ return "ℹ️";
233
+ default:
234
+ return "·";
235
+ }
236
+ }
237
+
238
+ export const TerraformAssessParams = type({
239
+ "severity_threshold?": type("'critical' | 'high' | 'medium' | 'low' | 'info'").describe(
240
+ "minimum severity to include (default: the run's configured threshold, else low).",
241
+ ),
242
+ });
243
+
244
+ /** the full read-only assessment pipeline: scan (honouring the §1.5 licence gate
245
+ * + module-fetch credential) → crosswalk → verification taxonomy → scorecard.
246
+ * Shared by `terraform_assess` and the evidence-bundle emitter so both report the
247
+ * identical posture from the identical toolchain. Pure-ish (only the scanners do
248
+ * I/O); no writes. */
249
+ export function runAssessmentPipeline(
250
+ ctx: LocalToolContext,
251
+ threshold: Severity,
252
+ ): {
253
+ cwd: string;
254
+ selection: ResolvedToolSelection;
255
+ outcomes: ScannerOutcome[];
256
+ concerns: Concern[];
257
+ crosswalk: CrosswalkReport;
258
+ verification: VerificationSummary;
259
+ scorecard: AssessmentScorecard;
260
+ } {
261
+ const cwd = ctx.payload.cwd ?? process.cwd();
262
+ const minRank = SEVERITY_RANK[threshold];
263
+ // §1.5 — same licence gate + module-fetch credential as terraform_scan, so a
264
+ // gated tool (e.g. tflint) shows up as INCONCLUSIVE coverage, not a silent pass.
265
+ // The resolved selection is returned so the read-only/auditor surfaces can be
266
+ // as transparent about the toolchain (gated / disabled / mistyped tokens) as
267
+ // terraform_scan is — silence on the assess side would read as "fully covered".
268
+ const selection = resolveToolSelection(ctx.payload);
269
+ const outcomes = runScanners(cwd, {
270
+ selection,
271
+ terraformEnv: resolveModuleFetchEnv(ctx.payload),
272
+ });
273
+ const concerns = sortConcerns(dedupe(outcomes.flatMap((o) => o.concerns)))
274
+ .filter(isTerraformConcern)
275
+ .filter((c) => SEVERITY_RANK[c.severity] >= minRank);
276
+ const crosswalk = buildCrosswalkReport(
277
+ concerns.map((c) => ({
278
+ id: c.id,
279
+ rule_id: c.rule_id,
280
+ evidence: c.evidence,
281
+ category: c.category,
282
+ severity: c.severity,
283
+ location: c.location,
284
+ })),
285
+ );
286
+ const verification = buildVerificationSummary(concerns, outcomes);
287
+ const scorecard = buildAssessment(concerns, crosswalk, verification);
288
+ return { cwd, selection, outcomes, concerns, crosswalk, verification, scorecard };
289
+ }
290
+
291
+ export function TerraformAssessTool(ctx: LocalToolContext) {
292
+ return tool({
293
+ name: "terraform_assess",
294
+ description:
295
+ "Read-only Terraform best-practice ASSESSMENT (the Assess pillar — the scanner engine surfaced as a " +
296
+ "product). Runs the deterministic scanners, then returns a deterministic `scorecard` (overall " +
297
+ "`posture` — clean / advisory / action-required, `by_severity` counts, `top_risks`, an indicative " +
298
+ "compliance-crosswalk summary, and a five-status `verification` coverage block) plus a ready-to-post " +
299
+ "`markdown` report. It NEVER modifies Terraform or opens a PR — use it to report posture (e.g. in a job " +
300
+ "summary or a CI gate on `posture`). Pairs with `terraform_emit_sarif` (Security tab), " +
301
+ "`terraform_emit_evidence` (auditor bundle), and `infracost_diff` / `terraform_version_currency` for the " +
302
+ "cost and currency lenses. For the fix loop, use `terraform_scan` + the Remediate mode instead.",
303
+ parameters: TerraformAssessParams,
304
+ execute: execute(async ({ severity_threshold }) => {
305
+ const configured = ctx.payload.severityThreshold as Severity | undefined;
306
+ const threshold: Severity = severity_threshold ?? configured ?? "low";
307
+
308
+ const { cwd, selection, outcomes, scorecard } = runAssessmentPipeline(ctx, threshold);
309
+ const markdown = renderAssessmentMarkdown(scorecard);
310
+
311
+ const ran = outcomes.filter((o) => o.ran).map((o) => o.source);
312
+ // §1.5 — a mistyped tools_enabled token is otherwise silently ignored; in a
313
+ // read-only assessment that's a footgun (the operator thinks they configured
314
+ // something they didn't). Surface it the same way terraform_scan does.
315
+ if (selection.unknownTokens.length > 0) {
316
+ log.warning(
317
+ `» tools_enabled: ignoring unrecognised tool(s) [${selection.unknownTokens.join(", ")}]`,
318
+ );
319
+ }
320
+ log.info(
321
+ `» terraform_assess: ${scorecard.posture} — ${scorecard.total} concern(s) ` +
322
+ `(${scorecard.compliance.controls_touched} control(s) across ${scorecard.compliance.frameworks.length} framework(s)) ` +
323
+ `from [${ran.join(", ")}]`,
324
+ );
325
+ return toolOk({
326
+ scanned_dir: cwd,
327
+ scanners_ran: ran,
328
+ // parity with terraform_scan: which tools were licence-gated off, which
329
+ // the operator disabled, and which tokens were unrecognised — so the
330
+ // assessment is honest about its own coverage, not silently partial.
331
+ tool_selection: {
332
+ licence_gated: selection.gated,
333
+ disabled: selection.disabled,
334
+ unknown_tokens: selection.unknownTokens,
335
+ },
336
+ scorecard,
337
+ markdown,
338
+ });
339
+ }),
340
+ });
341
+ }
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseBlockAddress, summarizeTerraformResourceDiff } from "#app/mcp/changeSummary";
3
+
4
+ describe("parseBlockAddress", () => {
5
+ it("parses two-label resource/data blocks into addresses", () => {
6
+ expect(parseBlockAddress('resource "aws_s3_bucket" "logs" {')).toBe("aws_s3_bucket.logs");
7
+ expect(parseBlockAddress(' data "aws_ami" "ubuntu" {')).toBe("data.aws_ami.ubuntu");
8
+ });
9
+
10
+ it("parses single-label module/variable/output/provider blocks", () => {
11
+ expect(parseBlockAddress('module "vpc" {')).toBe("module.vpc");
12
+ expect(parseBlockAddress('variable "region" {')).toBe("var.region");
13
+ expect(parseBlockAddress('output "url" {')).toBe("output.url");
14
+ expect(parseBlockAddress('provider "aws" {')).toBe("provider.aws");
15
+ });
16
+
17
+ it("returns null for non-block lines", () => {
18
+ expect(parseBlockAddress(' cidr_blocks = ["10.0.0.0/16"]')).toBeNull();
19
+ expect(parseBlockAddress("}")).toBeNull();
20
+ expect(parseBlockAddress('resource "aws_s3_bucket" {')).toBeNull(); // missing name label
21
+ });
22
+ });
23
+
24
+ describe("summarizeTerraformResourceDiff", () => {
25
+ it("collects added/removed block addresses and touched files, ignoring non-tf files", () => {
26
+ const diff = `diff --git a/main.tf b/main.tf
27
+ --- a/main.tf
28
+ +++ b/main.tf
29
+ @@ -1,3 +1,8 @@
30
+ +resource "aws_s3_bucket" "logs" {
31
+ + bucket = "x"
32
+ +}
33
+ -resource "aws_launch_configuration" "web" {
34
+ - image_id = "ami-1"
35
+ -}
36
+ cidr_blocks = ["10.0.0.0/16"]
37
+ diff --git a/README.md b/README.md
38
+ --- a/README.md
39
+ +++ b/README.md
40
+ @@ -1 +1 @@
41
+ +resource "aws_s3_bucket" "not_terraform" {
42
+ `;
43
+ const s = summarizeTerraformResourceDiff(diff);
44
+ expect(s.added).toEqual(["aws_s3_bucket.logs"]);
45
+ expect(s.removed).toEqual(["aws_launch_configuration.web"]);
46
+ // the README "resource" line must NOT be counted (not a .tf file)
47
+ expect(s.added).not.toContain("aws_s3_bucket.not_terraform");
48
+ expect(s.files).toEqual(["main.tf"]);
49
+ expect(s.counts).toEqual({ added: 1, removed: 1, files: 1 });
50
+ });
51
+
52
+ it("surfaces an in-place block edit as a touched file (not an added/removed address)", () => {
53
+ const diff = `diff --git a/vpc.tf b/vpc.tf
54
+ --- a/vpc.tf
55
+ +++ b/vpc.tf
56
+ @@ -2,3 +2,3 @@ resource "aws_security_group" "db" {
57
+ - description = "old"
58
+ + description = "new"
59
+ `;
60
+ const s = summarizeTerraformResourceDiff(diff);
61
+ expect(s.added).toEqual([]);
62
+ expect(s.removed).toEqual([]);
63
+ expect(s.files).toEqual(["vpc.tf"]);
64
+ });
65
+
66
+ it("counts a new module addition across a new .tf file", () => {
67
+ const diff = `diff --git a/modules.tf b/modules.tf
68
+ --- /dev/null
69
+ +++ b/modules.tf
70
+ @@ -0,0 +1,4 @@
71
+ +module "vpc" {
72
+ + source = "./modules/vpc"
73
+ +}
74
+ `;
75
+ const s = summarizeTerraformResourceDiff(diff);
76
+ expect(s.added).toEqual(["module.vpc"]);
77
+ expect(s.files).toEqual(["modules.tf"]);
78
+ });
79
+
80
+ it("is empty for a diff that touches no Terraform files", () => {
81
+ const diff = `diff --git a/app.py b/app.py
82
+ --- a/app.py
83
+ +++ b/app.py
84
+ @@ -1 +1 @@
85
+ +print("hi")
86
+ `;
87
+ expect(summarizeTerraformResourceDiff(diff)).toEqual({
88
+ added: [],
89
+ removed: [],
90
+ files: [],
91
+ counts: { added: 0, removed: 0, files: 0 },
92
+ });
93
+ });
94
+ });
@@ -0,0 +1,145 @@
1
+ import { type } from "arktype";
2
+ import { resolveBaseBranch } from "#app/mcp/pr";
3
+ import type { ToolContext } from "#app/mcp/server";
4
+ import { execute, tool, toolOk } from "#app/mcp/shared";
5
+ import { skipResult } from "#app/mcp/terraform/types";
6
+ import { log } from "#app/utils/cli";
7
+ import { $ } from "#app/utils/shell";
8
+
9
+ /**
10
+ * §36 AI PR summaries — the deterministic Terraform-change anchor. A PR summary
11
+ * written purely by the model drifts (miscounts resources, invents changes). This
12
+ * parses the PR's unified diff for Terraform BLOCK changes (which resource /
13
+ * module / data / variable / output addresses were added or removed, which files
14
+ * were touched) so the human-readable summary is anchored to facts, not prose.
15
+ *
16
+ * Pure parser (`summarizeTerraformResourceDiff`) + a tool that runs the
17
+ * merge-base diff and feeds it in. Block ADDED/REMOVED is precise (a block header
18
+ * on a +/- line); in-place edits to an existing block surface as a touched FILE
19
+ * (attributing a sub-block edit to a specific address needs full-file parsing —
20
+ * we stay honest and report the file rather than guess).
21
+ */
22
+
23
+ export interface TerraformChangeSummary {
24
+ /** addresses of blocks added in this diff (e.g. `aws_s3_bucket.logs`, `module.vpc`). */
25
+ added: string[];
26
+ /** addresses of blocks removed in this diff. */
27
+ removed: string[];
28
+ /** Terraform files touched (a superset signal — includes in-place edits). */
29
+ files: string[];
30
+ counts: { added: number; removed: number; files: number };
31
+ }
32
+
33
+ const isTfFile = (file: string): boolean => /\.tf$|\.tfvars$/.test(file);
34
+
35
+ /**
36
+ * Parse a Terraform block header into its address, or null when the line is not a
37
+ * top-level block header. Handles two-label blocks (`resource`/`data`) and
38
+ * single-label blocks (`module`/`variable`/`output`/`provider`). The line is the
39
+ * raw HCL (diff +/- prefix already stripped). Pure.
40
+ */
41
+ export function parseBlockAddress(line: string): string | null {
42
+ const s = line.trim();
43
+ const two = s.match(/^(resource|data)\s+"([^"]+)"\s+"([^"]+)"\s*\{/);
44
+ if (two) {
45
+ const [, kind, t, name] = two;
46
+ return kind === "data" ? `data.${t}.${name}` : `${t}.${name}`;
47
+ }
48
+ const one = s.match(/^(module|variable|output|provider)\s+"([^"]+)"\s*\{/);
49
+ if (one) {
50
+ const [, kind, name] = one;
51
+ const prefix = kind === "variable" ? "var" : kind;
52
+ return `${prefix}.${name}`;
53
+ }
54
+ return null;
55
+ }
56
+
57
+ /**
58
+ * Summarise a unified `git diff` into added/removed Terraform block addresses +
59
+ * the touched Terraform files. Tracks the current file from `+++ b/<path>`
60
+ * headers and only considers `.tf`/`.tfvars` files. Pure; deterministic ordering
61
+ * (sorted, de-duplicated). A block counted as both added and removed (moved) is
62
+ * left in both lists — the prose can describe the move.
63
+ */
64
+ export function summarizeTerraformResourceDiff(diff: string): TerraformChangeSummary {
65
+ const added = new Set<string>();
66
+ const removed = new Set<string>();
67
+ const files = new Set<string>();
68
+ let file = "";
69
+ let inTf = false;
70
+ for (const raw of diff.split("\n")) {
71
+ if (raw.startsWith("+++ ")) {
72
+ const path = raw.slice(4).trim().replace(/^b\//, "");
73
+ file = path === "/dev/null" ? "" : path;
74
+ inTf = !!file && isTfFile(file);
75
+ continue;
76
+ }
77
+ if (raw.startsWith("--- ") || raw.startsWith("diff --git") || raw.startsWith("@@")) continue;
78
+ if (!inTf) continue;
79
+ if (raw.startsWith("+") || raw.startsWith("-")) {
80
+ files.add(file);
81
+ const addr = parseBlockAddress(raw.slice(1));
82
+ if (addr) (raw.startsWith("+") ? added : removed).add(addr);
83
+ }
84
+ }
85
+ const sort = (s: Set<string>): string[] => [...s].sort();
86
+ return {
87
+ added: sort(added),
88
+ removed: sort(removed),
89
+ files: sort(files),
90
+ counts: { added: added.size, removed: removed.size, files: files.size },
91
+ };
92
+ }
93
+
94
+ export const TerraformChangeSummaryParams = type({
95
+ "base?": type.string.describe(
96
+ "base branch to diff against (default: the run's resolved base branch — main/master or the base_branch input).",
97
+ ),
98
+ });
99
+
100
+ export function TerraformChangeSummaryTool(ctx: ToolContext) {
101
+ return tool({
102
+ name: "terraform_change_summary",
103
+ description:
104
+ "§36 — a DETERMINISTIC anchor for a PR summary: the Terraform `resource`/`module`/`data`/`variable`/" +
105
+ "`output` addresses ADDED and REMOVED on this branch vs its base, plus the Terraform files touched. " +
106
+ "Runs a merge-base `git diff` of `*.tf`/`*.tfvars` and parses the block headers, so the human-readable " +
107
+ "summary you write is grounded in real counts instead of guessed ones. Degrades green (returns " +
108
+ "`ok: false`) when git can't resolve the base (fetch it first) or nothing Terraform changed — then " +
109
+ "summarise from the diff yourself. In-place edits to an existing block surface as a touched `file` " +
110
+ "(not an added/removed address). Use it in SummarizePr (or any PR summary) before writing the prose.",
111
+ parameters: TerraformChangeSummaryParams,
112
+ execute: execute(async ({ base }) => {
113
+ const baseBranch = base ?? resolveBaseBranch(ctx);
114
+ const pathspec = ["--", "*.tf", "*.tfvars"];
115
+ let diff: string | null = null;
116
+ // prefer the remote-tracking base (origin/<base>), fall back to a local ref.
117
+ for (const ref of [`origin/${baseBranch}`, baseBranch]) {
118
+ try {
119
+ diff = $("git", ["diff", "--merge-base", ref, ...pathspec], { log: false });
120
+ break;
121
+ } catch {
122
+ diff = null;
123
+ }
124
+ }
125
+ if (diff === null) {
126
+ return skipResult(
127
+ "base_unresolved",
128
+ `could not diff against '${baseBranch}' (fetch the base first, e.g. git_fetch({ ref: "${baseBranch}" })) — summarise from the diff yourself`,
129
+ );
130
+ }
131
+ const summary = summarizeTerraformResourceDiff(diff);
132
+ if (summary.counts.files === 0) {
133
+ return skipResult(
134
+ "no_terraform_changes",
135
+ "no Terraform files changed vs the base — this PR's summary is not Terraform-specific; summarise from the diff yourself",
136
+ );
137
+ }
138
+ log.info(
139
+ `» terraform_change_summary: +${summary.counts.added} -${summary.counts.removed} block(s) ` +
140
+ `across ${summary.counts.files} file(s) vs ${baseBranch}`,
141
+ );
142
+ return toolOk({ base: baseBranch, ...summary });
143
+ }),
144
+ });
145
+ }
@@ -1,6 +1,10 @@
1
1
  import { type } from "arktype";
2
2
  import type { ToolContext } from "#app/mcp/server";
3
3
  import { execute, tool, toolOk } from "#app/mcp/shared";
4
+ import {
5
+ type ConcernVerificationStatus,
6
+ concernVerificationStatus,
7
+ } from "#app/mcp/terraform/verification";
4
8
  import { log } from "#app/utils/cli";
5
9
 
6
10
  /**
@@ -248,6 +252,10 @@ export interface CrosswalkEntry {
248
252
  rule_id: string;
249
253
  themes: string[];
250
254
  controls: ControlRef[];
255
+ /** the five-status verdict for this control statement: `fail` (code-verified
256
+ * violation) or `not-code-verifiable` (a human-decision control the engine can
257
+ * flag but not prove). Lets an assessor read the crosswalk honestly. */
258
+ status: ConcernVerificationStatus;
251
259
  }
252
260
 
253
261
  export interface CrosswalkReport {
@@ -277,7 +285,13 @@ export function buildCrosswalkReport(concerns: ConcernForCrosswalk[]): Crosswalk
277
285
  unmapped.push(c.id);
278
286
  continue;
279
287
  }
280
- entries.push({ concern_id: c.id, rule_id: c.rule_id, themes, controls });
288
+ entries.push({
289
+ concern_id: c.id,
290
+ rule_id: c.rule_id,
291
+ themes,
292
+ controls,
293
+ status: concernVerificationStatus(c).status,
294
+ });
281
295
  for (const ctl of controls) {
282
296
  const map = byFramework.get(ctl.framework) ?? new Map<string, string>();
283
297
  if (!map.has(ctl.control)) map.set(ctl.control, ctl.title);
@@ -5,6 +5,7 @@ import type { ToolContext } from "#app/mcp/server";
5
5
  import { log } from "#app/utils/cli";
6
6
  import { resolveEnv } from "#app/utils/secrets";
7
7
  import { $ } from "#app/utils/shell";
8
+ import { resolveToolSelection } from "#app/utils/toolSelection";
8
9
 
9
10
  /**
10
11
  * Terraform-write guardrails — hard, code-level limits that back the prompt
@@ -49,9 +50,10 @@ function isGuardedMode(ctx: ToolContext): boolean {
49
50
  export function resolveAllowedPaths(ctx: ToolContext): string[] {
50
51
  const configured = ctx.payload.allowedPaths;
51
52
  const base = configured && configured.length > 0 ? [...configured] : [...DEFAULT_ALLOWED_PATHS];
52
- // §28 — when Terratest scaffolding is opted in, also permit the Go test +
53
- // example-fixture paths the scaffold writes (they're outside the .tf default).
54
- if (ctx.payload.terratest) base.push(...TERRATEST_ALLOWED_PATHS);
53
+ // §28 — when Terratest scaffolding is opted in (the `terratest` input OR the
54
+ // unified tools_enabled list, §1.5), also permit the Go test + example-fixture
55
+ // paths the scaffold writes (they're outside the .tf default).
56
+ if (resolveToolSelection(ctx.payload).enabled("terratest")) base.push(...TERRATEST_ALLOWED_PATHS);
55
57
  return base;
56
58
  }
57
59
 
@@ -374,8 +376,11 @@ export function assertNoSecretsInDiff(ctx: ToolContext): void {
374
376
  const diff = $("git", ["diff", base, "HEAD"], { log: false });
375
377
  const hits = scanDiffForSecrets(diff);
376
378
 
377
- // optional deeper engine — merged on top of the built-in baseline.
378
- if (ctx.payload.gitleaks) {
379
+ // optional deeper engine — merged on top of the built-in baseline. Opted in
380
+ // via the `gitleaks` input OR by naming "gitleaks" in the unified tools_enabled
381
+ // list (§1.5); an explicit `-gitleaks` there turns it back off.
382
+ const gitleaksEnabled = resolveToolSelection(ctx.payload).enabled("gitleaks");
383
+ if (gitleaksEnabled) {
379
384
  const gitleaksHits = scanWithGitleaks(ctx, base);
380
385
  if (gitleaksHits) hits.push(...gitleaksHits);
381
386
  }
@@ -388,7 +393,7 @@ export function assertNoSecretsInDiff(ctx: ToolContext): void {
388
393
  );
389
394
  }
390
395
  log.info(
391
- `» secret-scan guardrail ok (no inlined secrets in the diff${ctx.payload.gitleaks ? ", built-in + gitleaks" : ""})`,
396
+ `» secret-scan guardrail ok (no inlined secrets in the diff${gitleaksEnabled ? ", built-in + gitleaks" : ""})`,
392
397
  );
393
398
  }
394
399
 
@@ -22,6 +22,13 @@ export interface LocalToolContext {
22
22
  | "autonomyThreshold"
23
23
  | "costIncreaseBlockUsd"
24
24
  | "moduleCatalogue"
25
+ // §1.5 — the unified tool selection + module-fetch credential are honoured on
26
+ // the local stdio MCP server too (read-only scans + private-module init).
27
+ | "toolsEnabled"
28
+ | "gitleaks"
29
+ | "terratest"
30
+ | "terraformMcp"
31
+ | "moduleFetchToken"
25
32
  >;
26
33
  toolState: ToolState;
27
34
  tmpdir: string;
@@ -21,6 +21,8 @@ describe("buildLocalTools", () => {
21
21
  "list_modules",
22
22
  "module_extraction_candidates",
23
23
  "read_findings",
24
+ "terraform_assess",
25
+ "terraform_emit_evidence",
24
26
  "terraform_emit_sarif",
25
27
  "terraform_module_graph",
26
28
  "terraform_module_interface",