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.
- package/dist/agents/claudePretoolGate.d.ts +2 -2
- package/dist/cli.mjs +16554 -8100
- package/dist/index.js +13484 -5037
- package/dist/internal.js +75 -11
- package/dist/mcp/assess.d.ts +86 -0
- package/dist/mcp/changeSummary.d.ts +50 -0
- package/dist/mcp/crosswalk.d.ts +5 -0
- package/dist/mcp/localContext.d.ts +1 -1
- package/dist/mcp/terraform/evidence.d.ts +99 -0
- package/dist/mcp/terraform/scanners.d.ts +38 -3
- package/dist/mcp/terraform/types.d.ts +16 -0
- package/dist/mcp/terraform/verification.d.ts +74 -0
- package/dist/mcp/terraform.d.ts +4 -0
- package/dist/modes.d.ts +1 -1
- package/dist/toolState.d.ts +1 -0
- package/dist/utils/moduleFetch.d.ts +42 -0
- package/dist/utils/payload.d.ts +4 -0
- package/dist/utils/remediationCommand.d.ts +3 -0
- package/dist/utils/terraformMcp.d.ts +2 -2
- package/dist/utils/terramendConfig.d.ts +51 -0
- package/dist/utils/toolLicensing.d.ts +56 -0
- package/dist/utils/toolSelection.d.ts +72 -0
- package/package.json +9 -8
- package/src/agents/claudePretoolGate.ts +3 -3
- package/src/mcp/assess.test.ts +135 -0
- package/src/mcp/assess.ts +341 -0
- package/src/mcp/changeSummary.test.ts +94 -0
- package/src/mcp/changeSummary.ts +145 -0
- package/src/mcp/crosswalk.ts +15 -1
- package/src/mcp/guardrails.ts +11 -6
- package/src/mcp/localContext.ts +7 -0
- package/src/mcp/localServer.test.ts +2 -0
- package/src/mcp/localServer.ts +14 -0
- package/src/mcp/server.ts +6 -0
- package/src/mcp/terraform/evidence.test.ts +72 -0
- package/src/mcp/terraform/evidence.ts +187 -0
- package/src/mcp/terraform/scanners.ts +86 -9
- package/src/mcp/terraform/tools.test.ts +96 -1
- package/src/mcp/terraform/tools.ts +115 -32
- package/src/mcp/terraform/types.ts +24 -0
- package/src/mcp/terraform/verification.test.ts +85 -0
- package/src/mcp/terraform/verification.ts +133 -0
- package/src/mcp/terraform.test.ts +108 -0
- package/src/mcp/terraform.ts +4 -0
- package/src/modes.test.ts +9 -1
- package/src/modes.ts +81 -11
- package/src/toolState.ts +6 -0
- package/src/utils/moduleFetch.test.ts +68 -0
- package/src/utils/moduleFetch.ts +86 -0
- package/src/utils/payload.test.ts +66 -1
- package/src/utils/payload.ts +39 -11
- package/src/utils/remediationCommand.test.ts +32 -0
- package/src/utils/remediationCommand.ts +11 -0
- package/src/utils/terraformMcp.ts +6 -5
- package/src/utils/terramendConfig.test.ts +98 -0
- package/src/utils/terramendConfig.ts +143 -0
- package/src/utils/toolLicensing.test.ts +54 -0
- package/src/utils/toolLicensing.ts +103 -0
- package/src/utils/toolSelection.test.ts +140 -0
- 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
|
+
}
|
package/src/mcp/crosswalk.ts
CHANGED
|
@@ -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({
|
|
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);
|
package/src/mcp/guardrails.ts
CHANGED
|
@@ -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
|
|
53
|
-
//
|
|
54
|
-
|
|
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
|
-
|
|
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${
|
|
396
|
+
`» secret-scan guardrail ok (no inlined secrets in the diff${gitleaksEnabled ? ", built-in + gitleaks" : ""})`,
|
|
392
397
|
);
|
|
393
398
|
}
|
|
394
399
|
|
package/src/mcp/localContext.ts
CHANGED
|
@@ -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;
|