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
@@ -21,6 +21,7 @@ import { tmpdir } from "node:os";
21
21
  import { join } from "node:path";
22
22
  import { FastMCP, type Tool } from "fastmcp";
23
23
  import { terramendMcpName } from "#app/external";
24
+ import { TerraformAssessTool } from "#app/mcp/assess";
24
25
  import type { LocalToolContext } from "#app/mcp/localContext";
25
26
  import { ModuleExtractionCandidatesTool } from "#app/mcp/moduleExtraction";
26
27
  import {
@@ -34,6 +35,7 @@ import { TerraformRootsTool } from "#app/mcp/roots";
34
35
  import {
35
36
  InfracostDiffTool,
36
37
  ReadFindingsTool,
38
+ TerraformEmitEvidenceTool,
37
39
  TerraformEmitSarifTool,
38
40
  TerraformPlanTool,
39
41
  TerraformScanTool,
@@ -43,6 +45,7 @@ import {
43
45
  } from "#app/mcp/terraform";
44
46
  import { initToolState } from "#app/toolState";
45
47
  import { log } from "#app/utils/cli";
48
+ import { parseToolSelection } from "#app/utils/toolSelection";
46
49
 
47
50
  export interface LocalMcpOptions {
48
51
  /** absolute workspace directory the tools operate on. */
@@ -51,6 +54,10 @@ export interface LocalMcpOptions {
51
54
  scanScope?: "full" | "diff" | undefined;
52
55
  /** newline/comma-separated approved module list (same as the action input). */
53
56
  moduleCatalogue?: string | undefined;
57
+ /** §1.5 — the unified tool-selection list (same syntax as the action input). */
58
+ toolsEnabled?: string | undefined;
59
+ /** §1.5 — scoped token to fetch private cross-repo `git::` modules at init. */
60
+ moduleFetchToken?: string | undefined;
54
61
  }
55
62
 
56
63
  /** build the cwd-scoped context the read-only tools run against. */
@@ -63,6 +70,11 @@ export function buildLocalContext(options: LocalMcpOptions): LocalToolContext {
63
70
  autonomyThreshold: undefined,
64
71
  costIncreaseBlockUsd: undefined,
65
72
  moduleCatalogue: options.moduleCatalogue,
73
+ toolsEnabled: parseToolSelection(options.toolsEnabled),
74
+ gitleaks: false,
75
+ terratest: false,
76
+ terraformMcp: false,
77
+ moduleFetchToken: options.moduleFetchToken,
66
78
  },
67
79
  toolState: initToolState({ progressComment: undefined }),
68
80
  tmpdir: mkdtempSync(join(tmpdir(), "terramend-mcp-")),
@@ -77,6 +89,7 @@ export function buildLocalContext(options: LocalMcpOptions): LocalToolContext {
77
89
  export function buildLocalTools(ctx: LocalToolContext): Tool<any, any>[] {
78
90
  return [
79
91
  TerraformScanTool(ctx),
92
+ TerraformAssessTool(ctx),
80
93
  TerraformValidateTool(ctx),
81
94
  TerraformVerifyRemediationTool(ctx),
82
95
  TerraformPlanTool(ctx),
@@ -84,6 +97,7 @@ export function buildLocalTools(ctx: LocalToolContext): Tool<any, any>[] {
84
97
  InfracostDiffTool(ctx),
85
98
  ReadFindingsTool(ctx),
86
99
  TerraformEmitSarifTool(ctx),
100
+ TerraformEmitEvidenceTool(ctx),
87
101
  ListModulesTool(ctx),
88
102
  TerraformModuleGraphTool(ctx),
89
103
  TerraformModuleInterfaceTool(ctx),
package/src/mcp/server.ts CHANGED
@@ -5,6 +5,8 @@ import { createServer } from "node:net";
5
5
  import { setTimeout as sleep } from "node:timers/promises";
6
6
  import { FastMCP, type Tool } from "fastmcp";
7
7
  import { type AgentId, terramendMcpName } from "#app/external";
8
+ import { TerraformAssessTool } from "#app/mcp/assess";
9
+ import { TerraformChangeSummaryTool } from "#app/mcp/changeSummary";
8
10
  import { CheckoutPrTool } from "#app/mcp/checkout";
9
11
  import { GetCheckSuiteLogsTool } from "#app/mcp/checkSuite";
10
12
  import {
@@ -57,6 +59,7 @@ import { ClosePullRequestTool, ListRemediationPrsTool } from "#app/mcp/staleFix"
57
59
  import {
58
60
  InfracostDiffTool,
59
61
  ReadFindingsTool,
62
+ TerraformEmitEvidenceTool,
60
63
  TerraformEmitSarifTool,
61
64
  TerraformPlanTool,
62
65
  TerraformScanTool,
@@ -166,6 +169,7 @@ function buildCommonTools(ctx: ToolContext, outputSchema?: JsonSchema): Tool<any
166
169
  PullRequestInfoTool(ctx),
167
170
  CommitInfoTool(ctx),
168
171
  CheckoutPrTool(ctx),
172
+ TerraformChangeSummaryTool(ctx),
169
173
  GetReviewCommentsTool(ctx),
170
174
  ListPullRequestReviewsTool(ctx),
171
175
  ResolveReviewThreadTool(ctx),
@@ -177,6 +181,7 @@ function buildCommonTools(ctx: ToolContext, outputSchema?: JsonSchema): Tool<any
177
181
  // Terraform best-practice check tools (read-only). Always available so the
178
182
  // Remediate / GenerateTerraform modes can scan + gate without extra perms.
179
183
  TerraformScanTool(ctx),
184
+ TerraformAssessTool(ctx),
180
185
  TerraformValidateTool(ctx),
181
186
  TerraformVerifyRemediationTool(ctx),
182
187
  InfracostDiffTool(ctx),
@@ -192,6 +197,7 @@ function buildCommonTools(ctx: ToolContext, outputSchema?: JsonSchema): Tool<any
192
197
  TerraformRootsTool(ctx),
193
198
  ScaffoldTerratestTool(ctx),
194
199
  TerraformEmitSarifTool(ctx),
200
+ TerraformEmitEvidenceTool(ctx),
195
201
  PolicyCheckTool(ctx),
196
202
  ComplianceCrosswalkTool(ctx),
197
203
  ];
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildAssessment } from "#app/mcp/assess";
3
+ import { buildCrosswalkReport } from "#app/mcp/crosswalk";
4
+ import { buildEvidenceBundle, EVIDENCE_SCHEMA } from "#app/mcp/terraform/evidence";
5
+ import type { Concern } from "#app/mcp/terraform/types";
6
+ import { buildVerificationSummary } from "#app/mcp/terraform/verification";
7
+
8
+ let n = 0;
9
+ function concern(partial: Partial<Concern> = {}): Concern {
10
+ n += 1;
11
+ return {
12
+ id: partial.id ?? `id${n}`,
13
+ source: partial.source ?? "trivy",
14
+ rule_id: partial.rule_id ?? "trivy:AVD-AWS-0088",
15
+ severity: partial.severity ?? "high",
16
+ category: partial.category ?? "security",
17
+ evidence: partial.evidence ?? "S3 bucket is unencrypted at rest",
18
+ location: partial.location ?? { file: "main.tf", line: 1 },
19
+ remediation_hint: null,
20
+ };
21
+ }
22
+
23
+ function bundleFor(concerns: Concern[]) {
24
+ const crosswalk = buildCrosswalkReport(
25
+ concerns.map((c) => ({ id: c.id, rule_id: c.rule_id, evidence: c.evidence })),
26
+ );
27
+ const scorecard = buildAssessment(concerns, crosswalk, buildVerificationSummary(concerns, []));
28
+ return buildEvidenceBundle({
29
+ scorecard,
30
+ crosswalk,
31
+ subject: { scanned_dir: "/repo", repo: "acme/infra", ref: "main", commit: "abc123" },
32
+ generatedAt: "2026-06-13T00:00:00.000Z",
33
+ version: "0.2.0",
34
+ });
35
+ }
36
+
37
+ describe("buildEvidenceBundle", () => {
38
+ it("packages posture, subject, and the caller's timestamp under our own schema (not OSCAL)", () => {
39
+ const b = bundleFor([concern()]);
40
+ expect(b.schema).toBe(EVIDENCE_SCHEMA);
41
+ expect(b.schema).not.toMatch(/oscal/i);
42
+ expect(b.generated_at).toBe("2026-06-13T00:00:00.000Z");
43
+ expect(b.tool).toEqual({ name: "terramend", version: "0.2.0" });
44
+ expect(b.subject).toMatchObject({ repo: "acme/infra", ref: "main", commit: "abc123" });
45
+ expect(b.posture).toBe("action-required");
46
+ });
47
+
48
+ it("emits one control statement per mapped concern, each carrying its status + controls", () => {
49
+ const b = bundleFor([
50
+ concern({ id: "enc", rule_id: "trivy:AVD-AWS-0088", evidence: "unencrypted at rest" }),
51
+ concern({ id: "iam", rule_id: "checkov:CKV_AWS_1", evidence: "IAM wildcard * policy" }),
52
+ ]);
53
+ const byId = Object.fromEntries(b.control_statements.map((s) => [s.concern_id, s]));
54
+ expect(byId.enc?.status).toBe("fail");
55
+ expect(byId.iam?.status).toBe("not-code-verifiable");
56
+ expect(byId.enc?.controls.length).toBeGreaterThan(0);
57
+ });
58
+
59
+ it("never claims a pass it can't verify — carries the legend + honest disclaimer", () => {
60
+ const b = bundleFor([concern()]);
61
+ expect(Object.keys(b.legend)).toContain("not-code-verifiable");
62
+ expect(b.legend.inconclusive).toMatch(/not a pass/i);
63
+ expect(b.disclaimer).toMatch(/not an audit verdict/i);
64
+ expect(b.disclaimer).toMatch(/absence of a finding is not proof/i);
65
+ });
66
+
67
+ it("is deterministic for the same inputs (reproducible evidence)", () => {
68
+ const a = JSON.stringify(bundleFor([concern({ id: "x" })]));
69
+ const b = JSON.stringify(bundleFor([concern({ id: "x" })]));
70
+ expect(a).toBe(b);
71
+ });
72
+ });
@@ -0,0 +1,187 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import { type } from "arktype";
3
+ import { type AssessmentScorecard, runAssessmentPipeline } from "#app/mcp/assess";
4
+ import type { CrosswalkReport } from "#app/mcp/crosswalk";
5
+ import type { LocalToolContext } from "#app/mcp/localContext";
6
+ import { resolveWithinCwd } from "#app/mcp/pathSafety";
7
+ import { execute, tool, toolOk } from "#app/mcp/shared";
8
+ import type { Severity } from "#app/mcp/terraform/types";
9
+ import {
10
+ VERIFICATION_STATUS_LABEL,
11
+ type VerificationStatus,
12
+ type VerificationSummary,
13
+ } from "#app/mcp/terraform/verification";
14
+ import { log } from "#app/utils/cli";
15
+ import packageJson from "#package.json" with { type: "json" };
16
+
17
+ /**
18
+ * Backend-free compliance evidence bundle (the WS4a wedge — an auditor-facing
19
+ * artifact the OSS action can emit with **zero cloud and no backend**, committed
20
+ * to a `compliance/` path). It packages the read-only assessment — posture,
21
+ * per-control statements with their five-status verdict ([[verification]]), the
22
+ * crosswalk index, and the scanner coverage — into one deterministic JSON file.
23
+ *
24
+ * SCHEMA / HONESTY: this is Terramend's OWN structured schema, NOT OSCAL. A
25
+ * strict OSCAL (or compliance-trestle / C2P) emitter is a deliberate follow-up,
26
+ * gated on a buyer who actually needs OSCAL — emitting OSCAL nobody consumes is
27
+ * cost without value. The bundle is INDICATIVE alignment guidance, never an audit
28
+ * verdict, and it never claims `pass` for an unfired control (absence of a
29
+ * finding is not proof). Pure builder + a thin file-writing tool.
30
+ */
31
+
32
+ export const EVIDENCE_SCHEMA = "terramend-evidence/v0.1" as const;
33
+ export const DEFAULT_EVIDENCE_PATH = "compliance/terramend-evidence.json";
34
+
35
+ export interface EvidenceControlStatement {
36
+ concern_id: string;
37
+ rule_id: string;
38
+ /** the five-status verdict for this statement (fail / not-code-verifiable). */
39
+ status: VerificationStatus;
40
+ severity?: string | undefined;
41
+ file?: string | undefined;
42
+ line?: number | null | undefined;
43
+ controls: { framework: string; control: string; title: string }[];
44
+ }
45
+
46
+ export interface EvidenceBundle {
47
+ /** Terramend's own schema id — explicitly NOT OSCAL (see module note). */
48
+ schema: typeof EVIDENCE_SCHEMA;
49
+ /** caller-supplied ISO timestamp (kept out of the builder so it stays pure). */
50
+ generated_at: string;
51
+ tool: { name: "terramend"; version: string };
52
+ subject: {
53
+ scanned_dir: string;
54
+ repo?: string | undefined;
55
+ ref?: string | undefined;
56
+ commit?: string | undefined;
57
+ };
58
+ posture: AssessmentScorecard["posture"];
59
+ summary: {
60
+ total: number;
61
+ by_severity: AssessmentScorecard["by_severity"];
62
+ verification: VerificationSummary["counts"];
63
+ };
64
+ /** one statement per mapped concern, each carrying its status + controls. */
65
+ control_statements: EvidenceControlStatement[];
66
+ /** which scanners code-verified vs which were inconclusive (coverage gaps). */
67
+ coverage: VerificationSummary["coverage"];
68
+ crosswalk: {
69
+ version: string;
70
+ reviewed: string;
71
+ by_framework: CrosswalkReport["by_framework"];
72
+ };
73
+ /** the five-status legend, so the bundle is self-describing for an assessor. */
74
+ legend: Record<VerificationStatus, string>;
75
+ disclaimer: string;
76
+ }
77
+
78
+ const DISCLAIMER =
79
+ "Indicative alignment guidance from a deterministic starter rule-pack — NOT an " +
80
+ "audit verdict. Statuses are code-verified only; an inconclusive check is a " +
81
+ "coverage gap (not a pass) and absence of a finding is not proof of compliance.";
82
+
83
+ export interface EvidenceSubject {
84
+ scanned_dir: string;
85
+ repo?: string | undefined;
86
+ ref?: string | undefined;
87
+ commit?: string | undefined;
88
+ }
89
+
90
+ /**
91
+ * Build the evidence bundle from an assessment's scorecard + crosswalk. Pure —
92
+ * `generatedAt` and the subject identifiers are passed in so the same inputs
93
+ * always produce the same bytes (and tests don't need a clock). Control
94
+ * statements come from the crosswalk entries (which already carry the
95
+ * verification status), enriched with the concern's severity/location.
96
+ */
97
+ export function buildEvidenceBundle(args: {
98
+ scorecard: AssessmentScorecard;
99
+ crosswalk: CrosswalkReport;
100
+ subject: EvidenceSubject;
101
+ generatedAt: string;
102
+ version?: string;
103
+ }): EvidenceBundle {
104
+ const { scorecard, crosswalk, subject, generatedAt } = args;
105
+ const control_statements: EvidenceControlStatement[] = crosswalk.entries.map((e) => ({
106
+ concern_id: e.concern_id,
107
+ rule_id: e.rule_id,
108
+ status: e.status,
109
+ controls: e.controls,
110
+ }));
111
+ return {
112
+ schema: EVIDENCE_SCHEMA,
113
+ generated_at: generatedAt,
114
+ tool: { name: "terramend", version: args.version ?? packageJson.version },
115
+ subject,
116
+ posture: scorecard.posture,
117
+ summary: {
118
+ total: scorecard.total,
119
+ by_severity: scorecard.by_severity,
120
+ verification: scorecard.verification.counts,
121
+ },
122
+ control_statements,
123
+ coverage: scorecard.verification.coverage,
124
+ crosswalk: {
125
+ version: crosswalk.version,
126
+ reviewed: crosswalk.reviewed,
127
+ by_framework: crosswalk.by_framework,
128
+ },
129
+ legend: VERIFICATION_STATUS_LABEL,
130
+ disclaimer: DISCLAIMER,
131
+ };
132
+ }
133
+
134
+ export const TerraformEmitEvidenceParams = type({
135
+ "output_path?": type.string.describe(
136
+ "where to write the evidence bundle (default: ./compliance/terramend-evidence.json in the workspace). Commit it so the compliance/ path is the auditable evidence trail.",
137
+ ),
138
+ "severity_threshold?": type("'critical' | 'high' | 'medium' | 'low' | 'info'").describe(
139
+ "minimum severity to include (default: the run's configured threshold, else low).",
140
+ ),
141
+ });
142
+
143
+ export function TerraformEmitEvidenceTool(ctx: LocalToolContext) {
144
+ return tool({
145
+ name: "terraform_emit_evidence",
146
+ description:
147
+ "Emit a backend-free compliance EVIDENCE BUNDLE (auditor-facing JSON) for the workspace — the wedge that " +
148
+ "produces assessor-ready evidence with no cloud and no backend. Runs the read-only assessment and writes " +
149
+ "a deterministic bundle (default `compliance/terramend-evidence.json`): overall `posture`, per-control " +
150
+ "statements each with a five-status verdict (fail / not-code-verifiable), the scanner `coverage` " +
151
+ "(verified vs inconclusive), and the indicative crosswalk index. Commit the file so `compliance/` is the " +
152
+ "auditable trail. It NEVER modifies Terraform or opens a PR. The schema is Terramend's own (not OSCAL); " +
153
+ "the bundle is indicative alignment guidance, never an audit verdict, and never claims a pass it can't " +
154
+ "code-verify. Pairs with `terraform_assess` (the human-readable report) and `terraform_emit_sarif`.",
155
+ parameters: TerraformEmitEvidenceParams,
156
+ execute: execute(async ({ output_path, severity_threshold }) => {
157
+ const configured = ctx.payload.severityThreshold as Severity | undefined;
158
+ const threshold: Severity = severity_threshold ?? configured ?? "low";
159
+ const { cwd, scorecard, crosswalk } = runAssessmentPipeline(ctx, threshold);
160
+
161
+ // SECURITY: confine the agent-supplied path to the workspace (same guard as
162
+ // terraform_emit_sarif) so it can't clobber arbitrary files on the runner.
163
+ const target = resolveWithinCwd(cwd, output_path ?? DEFAULT_EVIDENCE_PATH);
164
+ const bundle = buildEvidenceBundle({
165
+ scorecard,
166
+ crosswalk,
167
+ subject: {
168
+ scanned_dir: cwd,
169
+ repo: process.env.GITHUB_REPOSITORY,
170
+ ref: process.env.GITHUB_REF_NAME,
171
+ commit: process.env.GITHUB_SHA,
172
+ },
173
+ generatedAt: new Date().toISOString(),
174
+ });
175
+ writeFileSync(target, `${JSON.stringify(bundle, null, 2)}\n`);
176
+ log.info(
177
+ `» terraform_emit_evidence: ${scorecard.posture} — ${bundle.control_statements.length} control statement(s) → ${target}`,
178
+ );
179
+ return toolOk({
180
+ output_path: target,
181
+ posture: bundle.posture,
182
+ statements: bundle.control_statements.length,
183
+ verification: bundle.summary.verification,
184
+ });
185
+ }),
186
+ });
187
+ }
@@ -18,6 +18,7 @@ import {
18
18
  toRepoRelative,
19
19
  } from "#app/mcp/terraform/types";
20
20
  import { log } from "#app/utils/cli";
21
+ import { type ResolvedToolSelection, scannerToolId } from "#app/utils/toolSelection";
21
22
 
22
23
  // dirs already `terraform init`-ed this process, so repeated scans don't re-init.
23
24
  const initedDirs = new Set<string>();
@@ -30,9 +31,16 @@ const initedDirs = new Set<string>();
30
31
  * `-input=false` keeps it non-interactive. Network-dependent and best-effort: if
31
32
  * it fails (offline, private module, etc.) validate still runs, just shallow.
32
33
  */
33
- function ensureTerraformInit(cwd: string): void {
34
+ function ensureTerraformInit(cwd: string, extraEnv?: Record<string, string>): void {
34
35
  if (initedDirs.has(cwd)) return;
35
- const r = run("terraform", ["init", "-backend=false", "-input=false", "-no-color"], cwd);
36
+ // `extraEnv` carries the optional module-fetch credential (§1.5) so init can
37
+ // resolve a private cross-repo `git::` module; absent for the common case.
38
+ const r = run(
39
+ "terraform",
40
+ ["init", "-backend=false", "-input=false", "-no-color"],
41
+ cwd,
42
+ extraEnv,
43
+ );
36
44
  // mark done even on non-zero: a failed init won't succeed on retry within the
37
45
  // same run, and we don't want to re-run it for every scanner call.
38
46
  initedDirs.add(cwd);
@@ -93,8 +101,8 @@ const VALIDATE_NOISE = [
93
101
  ];
94
102
 
95
103
  /** run `terraform validate` in one root and return concerns re-based onto cwd. */
96
- function scanValidateRoot(root: ResolvedRoot): ScannerOutcome {
97
- ensureTerraformInit(root.absDir);
104
+ function scanValidateRoot(root: ResolvedRoot, extraEnv?: Record<string, string>): ScannerOutcome {
105
+ ensureTerraformInit(root.absDir, extraEnv);
98
106
  const r = run("terraform", ["validate", "-json"], root.absDir);
99
107
  if (r.missing) return skipped("terraform-validate", "terraform not installed");
100
108
  try {
@@ -120,14 +128,14 @@ function scanValidateRoot(root: ResolvedRoot): ScannerOutcome {
120
128
  * whole tree), so a multi-root repo only catches subdir-root validate errors
121
129
  * when we visit each root.
122
130
  */
123
- export function scanValidate(cwd: string): ScannerOutcome {
131
+ export function scanValidate(cwd: string, extraEnv?: Record<string, string>): ScannerOutcome {
124
132
  const roots = resolveRoots(cwd);
125
133
  const concerns: Concern[] = [];
126
134
  let anyRan = false;
127
135
  let sawMissing = false;
128
136
  let unvalidated = 0;
129
137
  for (const root of roots) {
130
- const outcome = scanValidateRoot(root);
138
+ const outcome = scanValidateRoot(root, extraEnv);
131
139
  unvalidated += outcome.unvalidated ?? 0;
132
140
  if (outcome.ran) {
133
141
  anyRan = true;
@@ -747,10 +755,37 @@ export function changedTerraformFiles(cwd: string): Set<string> | null {
747
755
  return new Set(files);
748
756
  }
749
757
 
758
+ export interface RunScannersOptions {
759
+ /** §1.5 — the resolved tool selection. A scanner whose tool is gated/disabled
760
+ * is reported as a `skipped` outcome (with the licence/disable reason) instead
761
+ * of running, so `terraform_scan` and the ✗→✓ verifier see the SAME toolchain. */
762
+ selection?: ResolvedToolSelection | undefined;
763
+ /** §1.5 — module-fetch credential env, threaded into `terraform validate`'s
764
+ * init so a private cross-repo `git::` module resolves during a scan. */
765
+ terraformEnv?: Record<string, string> | undefined;
766
+ }
767
+
750
768
  /** run every scanner once over `cwd`. shared by `terraform_scan` and the
751
- * deterministic remediation verifier so both see the identical toolchain. */
752
- export function runScanners(cwd: string): ScannerOutcome[] {
753
- return [scanFmt(cwd), scanValidate(cwd), scanTflint(cwd), scanTrivy(cwd), scanCheckov(cwd)];
769
+ * deterministic remediation verifier so both see the identical toolchain. A
770
+ * scanner the selection has turned off is emitted as `skipped` (never run), so
771
+ * the gate applies identically to the scan and its verification re-scan. */
772
+ export function runScanners(cwd: string, opts: RunScannersOptions = {}): ScannerOutcome[] {
773
+ const { selection, terraformEnv } = opts;
774
+ const gate = (source: Concern["source"], runner: () => ScannerOutcome): ScannerOutcome => {
775
+ if (!selection) return runner();
776
+ const id = scannerToolId(source);
777
+ if (id && !selection.enabled(id)) {
778
+ return skipped(source, selection.offReason(id) ?? "disabled by tools_enabled");
779
+ }
780
+ return runner();
781
+ };
782
+ return [
783
+ gate("terraform-fmt", () => scanFmt(cwd)),
784
+ gate("terraform-validate", () => scanValidate(cwd, terraformEnv)),
785
+ gate("tflint", () => scanTflint(cwd)),
786
+ gate("trivy", () => scanTrivy(cwd)),
787
+ gate("checkov", () => scanCheckov(cwd)),
788
+ ];
754
789
  }
755
790
 
756
791
  export interface RemediationVerdict {
@@ -807,3 +842,45 @@ export function computeRegressions(
807
842
  }
808
843
  return [...regressions].sort();
809
844
  }
845
+
846
+ /**
847
+ * Line-INDEPENDENT ✗→✓ partition (the integrity-preserving replacement for the
848
+ * raw-id `computeRemediationVerdict` when scan context is available). Each
849
+ * requested entry carries its display `id` and its `key` (see `concernKeyOf`);
850
+ * a concern is `remaining` iff its KEY still appears in the re-scan, else
851
+ * `resolved`. Because a fix that shifts lines keeps the same (source|rule|file)
852
+ * key, an unfixed concern can no longer be mis-reported as resolved. Pure.
853
+ */
854
+ export function partitionByKey(
855
+ requested: { id: string; key: string }[],
856
+ currentKeys: Set<string>,
857
+ ): RemediationVerdict {
858
+ const resolved: string[] = [];
859
+ const remaining: string[] = [];
860
+ for (const r of requested) {
861
+ if (currentKeys.has(r.key)) remaining.push(r.id);
862
+ else resolved.push(r.id);
863
+ }
864
+ return { verified: remaining.length === 0, resolved, remaining };
865
+ }
866
+
867
+ /**
868
+ * Line-INDEPENDENT regression set: one representative current concern id per KEY
869
+ * present in the re-scan but absent from the pre-fix baseline keys. A pre-existing
870
+ * concern that merely shifted to a new line (same key) is NOT a regression — only
871
+ * a genuinely new (rule, file) defect is. Replaces the raw-id `computeRegressions`
872
+ * for the integrity path. Pure; returns sorted ids for a stable PR body.
873
+ */
874
+ export function regressionIdsByKey(
875
+ current: { id: string; key: string }[],
876
+ baselineKeys: Set<string>,
877
+ ): string[] {
878
+ const seen = new Set<string>();
879
+ const out: string[] = [];
880
+ for (const c of current) {
881
+ if (baselineKeys.has(c.key) || seen.has(c.key)) continue;
882
+ seen.add(c.key);
883
+ out.push(c.id);
884
+ }
885
+ return out.sort();
886
+ }
@@ -23,6 +23,7 @@ vi.mock("#app/mcp/shared", async (importOriginal) => {
23
23
  };
24
24
  });
25
25
 
26
+ import { TerraformAssessTool } from "#app/mcp/assess";
26
27
  import { _clearProviderSchemaCache } from "#app/mcp/providerSchema";
27
28
  import type { ToolContext } from "#app/mcp/server";
28
29
  import { changedTerraformFiles } from "#app/mcp/terraform/scanners";
@@ -36,6 +37,7 @@ import {
36
37
  TerraformVerifyRemediationTool,
37
38
  } from "#app/mcp/terraform/tools";
38
39
  import { concernId } from "#app/mcp/terraform/types";
40
+ import { parseToolSelection } from "#app/utils/toolSelection";
39
41
 
40
42
  // --- fake subprocess plumbing ----------------------------------------------
41
43
 
@@ -70,7 +72,12 @@ function makeCtx(
70
72
  over: { payload?: Record<string, unknown>; toolState?: Record<string, unknown> } = {},
71
73
  ): ToolContext {
72
74
  return {
73
- payload: { cwd, ...(over.payload ?? {}) },
75
+ // §1.5 default these tests to the full toolchain (`tools_enabled: all`) so
76
+ // they exercise every scanner; the licence gate's default (non-permissive
77
+ // tools off) is covered explicitly in the gate tests + toolSelection.test.ts.
78
+ // `over.payload` can override (e.g. pass toolsEnabled: undefined to assert the
79
+ // bare default).
80
+ payload: { cwd, toolsEnabled: parseToolSelection("all"), ...(over.payload ?? {}) },
74
81
  toolState: { ...(over.toolState ?? {}) },
75
82
  tmpdir: makeDir(),
76
83
  } as unknown as ToolContext;
@@ -240,6 +247,59 @@ describe("TerraformScanTool", () => {
240
247
  );
241
248
  });
242
249
 
250
+ it("§1.5 licence-gates tflint off by default and reports it as a gated skip", async () => {
251
+ const cwd = makeDir({ "main.tf": tf });
252
+ dispatch = scannersDispatch();
253
+ // a bare run (no tools_enabled) → the default licence gate applies.
254
+ const ctx = makeCtx(cwd, { payload: { toolsEnabled: undefined } });
255
+
256
+ const result = await runTool(TerraformScanTool(ctx));
257
+
258
+ // tflint (MPL-2.0) is not run; the permissive scanners still are.
259
+ expect(result.scanners_ran).not.toContain("tflint");
260
+ expect(result.scanners_ran).toEqual(
261
+ expect.arrayContaining(["terraform-fmt", "terraform-validate", "trivy"]),
262
+ );
263
+ // it's surfaced as a licence-gated skip + in the tool_selection summary.
264
+ const tflintSkip = (result.scanners_skipped as Array<{ source: string; reason?: string }>).find(
265
+ (s) => s.source === "tflint",
266
+ );
267
+ expect(tflintSkip?.reason).toMatch(/licence-gated/i);
268
+ expect(result.tool_selection).toMatchObject({
269
+ licence_gated: expect.arrayContaining(["tflint"]),
270
+ });
271
+ // the tflint concern is absent from the baseline, so verify stays consistent.
272
+ expect(ctx.toolState.baselineConcernIds).toHaveLength(3);
273
+ });
274
+
275
+ it("§1.5 runs tflint when it is the licence-aware opt-in in tools_enabled", async () => {
276
+ const cwd = makeDir({ "main.tf": tf });
277
+ dispatch = scannersDispatch();
278
+ const ctx = makeCtx(cwd, { payload: { toolsEnabled: parseToolSelection("tflint") } });
279
+
280
+ const result = await runTool(TerraformScanTool(ctx));
281
+
282
+ expect(result.scanners_ran).toContain("tflint");
283
+ // tflint is no longer gated (it was opted in); terraform_mcp still is.
284
+ const gated = (result.tool_selection as { licence_gated: string[] }).licence_gated;
285
+ expect(gated).not.toContain("tflint");
286
+ });
287
+
288
+ it("§1.5 disables a permissive scanner when tools_enabled vetoes it", async () => {
289
+ const cwd = makeDir({ "main.tf": tf });
290
+ dispatch = scannersDispatch();
291
+ const ctx = makeCtx(cwd, { payload: { toolsEnabled: parseToolSelection("all, -trivy") } });
292
+
293
+ const result = await runTool(TerraformScanTool(ctx));
294
+
295
+ expect(result.scanners_ran).not.toContain("trivy");
296
+ const trivySkip = (result.scanners_skipped as Array<{ source: string; reason?: string }>).find(
297
+ (s) => s.source === "trivy",
298
+ );
299
+ expect(trivySkip?.reason).toMatch(/disabled via tools_enabled/);
300
+ expect(result.tool_selection).toMatchObject({ disabled: expect.arrayContaining(["trivy"]) });
301
+ });
302
+
243
303
  it("filters by the explicit severity_threshold but keeps the baseline unfiltered", async () => {
244
304
  const cwd = makeDir({ "main.tf": tf });
245
305
  dispatch = scannersDispatch();
@@ -345,6 +405,41 @@ describe("TerraformScanTool", () => {
345
405
  });
346
406
  });
347
407
 
408
+ describe("TerraformAssessTool — §1.5 toolchain transparency (read-only surface)", () => {
409
+ const tf = 'resource "aws_s3_bucket" "b" {\n bucket = "x"\n}\n';
410
+
411
+ it("surfaces the licence-gated tool in tool_selection on a bare (default-gate) run", async () => {
412
+ const cwd = makeDir({ "main.tf": tf });
413
+ dispatch = scannersDispatch();
414
+ // toolsEnabled: undefined → the default licence gate applies (tflint off).
415
+ const ctx = makeCtx(cwd, { payload: { toolsEnabled: undefined } });
416
+
417
+ const result = await runTool(TerraformAssessTool(ctx));
418
+
419
+ // the assessment still produced a scorecard from the permissive scanners…
420
+ expect(result).toMatchObject({ ok: true, scanned_dir: cwd });
421
+ expect(result.scanners_ran).not.toContain("tflint");
422
+ // …and is honest that tflint was licence-gated off, exactly like terraform_scan.
423
+ expect(result.tool_selection).toMatchObject({
424
+ licence_gated: expect.arrayContaining(["tflint"]),
425
+ disabled: [],
426
+ unknown_tokens: [],
427
+ });
428
+ });
429
+
430
+ it("surfaces a mistyped tools_enabled token instead of silently ignoring it", async () => {
431
+ const cwd = makeDir({ "main.tf": tf });
432
+ dispatch = scannersDispatch();
433
+ const ctx = makeCtx(cwd, { payload: { toolsEnabled: parseToolSelection("trivy, nope") } });
434
+
435
+ const result = await runTool(TerraformAssessTool(ctx));
436
+
437
+ expect((result.tool_selection as { unknown_tokens: string[] }).unknown_tokens).toEqual([
438
+ "nope",
439
+ ]);
440
+ });
441
+ });
442
+
348
443
  describe("TerraformValidateTool", () => {
349
444
  const versionsTf = `terraform {
350
445
  required_providers {