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
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import { spawnSync } from "node:child_process";
19
- import type { ResolvedPayload } from "#app/utils/payload";
19
+ import { resolveToolSelection, type ToolSelectionFlags } from "#app/utils/toolSelection";
20
20
 
21
21
  /** pinned release of hashicorp/terraform-mcp-server. Bump deliberately. */
22
22
  export const TERRAFORM_MCP_IMAGE = "hashicorp/terraform-mcp-server:0.5.2";
@@ -66,10 +66,11 @@ function dockerAvailable(): boolean {
66
66
  * server (`available`), log the degrade-green note (`docker_missing`), or do
67
67
  * nothing (`disabled`).
68
68
  */
69
- export function resolveTerraformMcp(
70
- payload: Pick<ResolvedPayload, "terraformMcp">,
71
- ): TerraformMcpResolution {
72
- if (!payload.terraformMcp) return { kind: "disabled" };
69
+ export function resolveTerraformMcp(payload: ToolSelectionFlags): TerraformMcpResolution {
70
+ // terraform-mcp-server is licence-gated (HashiCorp, §1.5): on via the
71
+ // `terraform_mcp` input OR by naming "terraform_mcp" in tools_enabled; an
72
+ // explicit `-terraform_mcp` there turns it off.
73
+ if (!resolveToolSelection(payload).enabled("terraform_mcp")) return { kind: "disabled" };
73
74
  if (!dockerAvailable()) {
74
75
  return {
75
76
  kind: "docker_missing",
@@ -0,0 +1,98 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { loadTerramendConfig, parseTerramendConfig } from "#app/utils/terramendConfig";
6
+
7
+ describe("parseTerramendConfig", () => {
8
+ it("reads scalar values verbatim", () => {
9
+ const { values, warnings } = parseTerramendConfig(
10
+ ["scan_scope: diff", "severity_threshold: high", "module_catalogue: ./modules/net"].join(
11
+ "\n",
12
+ ),
13
+ );
14
+ expect(values).toEqual({
15
+ scan_scope: "diff",
16
+ severity_threshold: "high",
17
+ module_catalogue: "./modules/net",
18
+ });
19
+ expect(warnings).toEqual([]);
20
+ });
21
+
22
+ it("joins list values with newlines (so the input parsers split them)", () => {
23
+ const { values } = parseTerramendConfig(
24
+ [
25
+ "tools_enabled:",
26
+ " - trivy",
27
+ " - -tflint",
28
+ "protected_paths:",
29
+ " - prod/**",
30
+ " - '**/state/**'",
31
+ ].join("\n"),
32
+ );
33
+ expect(values.tools_enabled).toBe("trivy\n-tflint");
34
+ expect(values.protected_paths).toBe("prod/**\n**/state/**");
35
+ });
36
+
37
+ it("warns and skips an unrecognised key", () => {
38
+ const { values, warnings } = parseTerramendConfig("nope: 1\ntools_enabled: trivy");
39
+ expect(values).toEqual({ tools_enabled: "trivy" });
40
+ expect(warnings).toEqual([expect.stringContaining('unrecognised key "nope"')]);
41
+ });
42
+
43
+ it("warns and skips a value of the wrong shape (a mapping)", () => {
44
+ const { values, warnings } = parseTerramendConfig("tools_enabled:\n a: 1");
45
+ expect(values).toEqual({});
46
+ expect(warnings).toEqual([expect.stringContaining("expected a string or a list")]);
47
+ });
48
+
49
+ it("ignores blank list entries, dropping the key when nothing remains", () => {
50
+ const { values } = parseTerramendConfig("allowed_paths:\n - ''\n - ' '");
51
+ expect(values.allowed_paths).toBeUndefined();
52
+ });
53
+
54
+ it("treats an empty / comment-only file as a valid no-op", () => {
55
+ expect(parseTerramendConfig("# just a comment\n")).toEqual({ values: {}, warnings: [] });
56
+ expect(parseTerramendConfig("")).toEqual({ values: {}, warnings: [] });
57
+ });
58
+
59
+ it("warns on malformed YAML instead of throwing", () => {
60
+ const { values, warnings } = parseTerramendConfig("tools_enabled: [unterminated");
61
+ expect(values).toEqual({});
62
+ expect(warnings).toEqual([expect.stringContaining("not valid YAML")]);
63
+ });
64
+
65
+ it("warns when the document is a list, not a mapping", () => {
66
+ const { values, warnings } = parseTerramendConfig("- trivy\n- checkov");
67
+ expect(values).toEqual({});
68
+ expect(warnings).toEqual([expect.stringContaining("must be a YAML mapping")]);
69
+ });
70
+ });
71
+
72
+ describe("loadTerramendConfig", () => {
73
+ const dirs: string[] = [];
74
+ const makeDir = (files: Record<string, string>): string => {
75
+ const dir = mkdtempSync(join(tmpdir(), "terramend-cfg-"));
76
+ dirs.push(dir);
77
+ for (const [name, content] of Object.entries(files)) writeFileSync(join(dir, name), content);
78
+ return dir;
79
+ };
80
+ afterEach(() => {
81
+ for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
82
+ });
83
+
84
+ it("reads a .terramend.yml from the given dir", () => {
85
+ const dir = makeDir({ ".terramend.yml": "tools_enabled: tflint\nscan_scope: diff" });
86
+ expect(loadTerramendConfig(dir)).toEqual({ tools_enabled: "tflint", scan_scope: "diff" });
87
+ });
88
+
89
+ it("falls back to the .yaml spelling", () => {
90
+ const dir = makeDir({ ".terramend.yaml": "severity_threshold: high" });
91
+ expect(loadTerramendConfig(dir)).toEqual({ severity_threshold: "high" });
92
+ });
93
+
94
+ it("returns {} when no config file is present", () => {
95
+ const dir = makeDir({ "main.tf": "" });
96
+ expect(loadTerramendConfig(dir)).toEqual({});
97
+ });
98
+ });
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Repo-committed `.terramend.yml` config (the §1.5 follow-up to the unified
3
+ * `tools_enabled` input). A thin layer that sits **under** the action inputs: an
4
+ * explicit workflow input always wins; the file only fills the gaps. It versions
5
+ * the toolchain + scoping policy *with the code* (and doubles as the auditable
6
+ * record of which non-permissive tools the repo owner opted into), so the
7
+ * workflow file can stay minimal and the policy lives next to the Terraform.
8
+ *
9
+ * Scope is deliberate — only repo-level **policy** knobs live here. Each maps
10
+ * 1:1 to the matching action input and flows through the SAME parser, so the file
11
+ * and the input validate identically. Secrets (`module_fetch_token`) and
12
+ * per-run/workflow knobs (`mode`, `max_prs`, `base_branch`, `allow_replace`) are
13
+ * intentionally NOT read from the file: a committed file is the wrong place for a
14
+ * credential, and the run's shape belongs to the workflow that dispatches it.
15
+ *
16
+ * Trust boundary: `.terramend.yml` is controlled by whoever can push to the repo
17
+ * — the same surface as the Terraform being remediated. It can only RELAX within
18
+ * the licence gate's structure (naming a non-permissive tool is the repo owner's
19
+ * licence acknowledgement, exactly as on the input) and it can never disable the
20
+ * required substrate. A workflow author who needs to *enforce* a policy sets the
21
+ * action input, which wins over the file.
22
+ *
23
+ * Degrade-green: a missing file is silent; malformed YAML, a non-mapping
24
+ * document, an unknown key, or a value of the wrong shape yields a warning and is
25
+ * ignored — never a hard failure.
26
+ */
27
+
28
+ import { readFileSync } from "node:fs";
29
+ import { join } from "node:path";
30
+ import { parse as parseYaml } from "yaml";
31
+ import { log } from "#app/utils/cli";
32
+
33
+ /** filenames checked, in order; the first that exists wins. */
34
+ export const TERRAMEND_CONFIG_FILENAMES = [".terramend.yml", ".terramend.yaml"] as const;
35
+
36
+ /** the repo-level keys `.terramend.yml` may set. Each is the snake_case name of
37
+ * the matching action input, so the file value can be fed straight through the
38
+ * input's own parser in `resolvePayload`. */
39
+ export const TERRAMEND_CONFIG_KEYS = [
40
+ "tools_enabled",
41
+ "protected_paths",
42
+ "allowed_paths",
43
+ "scan_scope",
44
+ "severity_threshold",
45
+ "autonomy_threshold",
46
+ "module_catalogue",
47
+ ] as const;
48
+
49
+ export type TerramendConfigKey = (typeof TERRAMEND_CONFIG_KEYS)[number];
50
+
51
+ /** normalized string values (lists newline-joined), keyed by input name. */
52
+ export type TerramendFileValues = Partial<Record<TerramendConfigKey, string>>;
53
+
54
+ export interface ParsedTerramendConfig {
55
+ values: TerramendFileValues;
56
+ /** non-fatal problems (malformed value, unknown key) for the caller to log. */
57
+ warnings: string[];
58
+ }
59
+
60
+ const KEY_SET: ReadonlySet<string> = new Set(TERRAMEND_CONFIG_KEYS);
61
+
62
+ /**
63
+ * Normalize a YAML value to the string shape the action-input parsers expect:
64
+ * a scalar → its trimmed string; a list → newline-joined (the tool-selection,
65
+ * glob, and module-catalogue parsers all split on newlines *or* commas). Returns
66
+ * null for an unusable value (a mapping, an empty/blank string, null) so the
67
+ * caller can warn and skip it.
68
+ */
69
+ function normalizeValue(value: unknown): string | null {
70
+ if (value === null || value === undefined) return null;
71
+ if (typeof value === "string") return value.trim() || null;
72
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
73
+ if (Array.isArray(value)) {
74
+ const items = value
75
+ .filter((v) => v !== null && v !== undefined && typeof v !== "object")
76
+ .map((v) => String(v).trim())
77
+ .filter(Boolean);
78
+ return items.length > 0 ? items.join("\n") : null;
79
+ }
80
+ return null; // a nested mapping isn't a valid value for any of these keys.
81
+ }
82
+
83
+ /**
84
+ * Parse raw `.terramend.yml` text into normalized string values + warnings.
85
+ * Pure — no I/O, no logging — so the parsing/validation is unit-testable.
86
+ */
87
+ export function parseTerramendConfig(raw: string): ParsedTerramendConfig {
88
+ let doc: unknown;
89
+ try {
90
+ doc = parseYaml(raw);
91
+ } catch (err) {
92
+ const detail = err instanceof Error ? err.message : String(err);
93
+ return { values: {}, warnings: [`.terramend.yml is not valid YAML — ignored (${detail})`] };
94
+ }
95
+ // an empty file / only comments parses to null|undefined — a valid no-op.
96
+ if (doc === null || doc === undefined) return { values: {}, warnings: [] };
97
+ if (typeof doc !== "object" || Array.isArray(doc)) {
98
+ return {
99
+ values: {},
100
+ warnings: [".terramend.yml must be a YAML mapping (key: value) — ignored"],
101
+ };
102
+ }
103
+
104
+ const values: TerramendFileValues = {};
105
+ const warnings: string[] = [];
106
+ for (const [key, value] of Object.entries(doc as Record<string, unknown>)) {
107
+ if (!KEY_SET.has(key)) {
108
+ warnings.push(`.terramend.yml: ignoring unrecognised key "${key}"`);
109
+ continue;
110
+ }
111
+ const normalized = normalizeValue(value);
112
+ if (normalized === null) {
113
+ warnings.push(`.terramend.yml: ignoring "${key}" — expected a string or a list of strings`);
114
+ continue;
115
+ }
116
+ values[key as TerramendConfigKey] = normalized;
117
+ }
118
+ return { values, warnings };
119
+ }
120
+
121
+ /**
122
+ * Read + parse the repo's `.terramend.yml` (first of the supported filenames
123
+ * found under `cwd`). Returns the normalized values, logging any warnings. A
124
+ * missing file resolves to `{}` silently — the common, valid case.
125
+ */
126
+ export function loadTerramendConfig(cwd: string | undefined): TerramendFileValues {
127
+ const root = cwd ?? process.cwd();
128
+ for (const name of TERRAMEND_CONFIG_FILENAMES) {
129
+ let raw: string;
130
+ try {
131
+ raw = readFileSync(join(root, name), "utf-8");
132
+ } catch {
133
+ continue; // not this filename — try the next, or fall through to {}.
134
+ }
135
+ const { values, warnings } = parseTerramendConfig(raw);
136
+ for (const w of warnings) log.warning(`» ${w}`);
137
+ if (Object.keys(values).length > 0) {
138
+ log.info(`» loaded ${name} (${Object.keys(values).join(", ")})`);
139
+ }
140
+ return values;
141
+ }
142
+ return {};
143
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ ALL_TOOL_IDS,
4
+ isLicenseGated,
5
+ isPermissive,
6
+ LICENSE_GATED_TOOLS,
7
+ TOOL_LICENSES,
8
+ type ToolId,
9
+ } from "#app/utils/toolLicensing";
10
+
11
+ describe("TOOL_LICENSES catalogue", () => {
12
+ it("classifies every tool with a self-consistent id", () => {
13
+ for (const id of ALL_TOOL_IDS) {
14
+ const t = TOOL_LICENSES[id];
15
+ expect(t.id).toBe(id);
16
+ expect(t.name).toBeTruthy();
17
+ expect(t.license).toBeTruthy();
18
+ expect(["permissive", "copyleft", "source-available"]).toContain(t.class);
19
+ }
20
+ });
21
+
22
+ it("treats only MIT/Apache/BSD-style licences as permissive", () => {
23
+ expect(isPermissive("permissive")).toBe(true);
24
+ expect(isPermissive("copyleft")).toBe(false);
25
+ expect(isPermissive("source-available")).toBe(false);
26
+ });
27
+ });
28
+
29
+ describe("isLicenseGated", () => {
30
+ it("gates non-permissive optional tools (tflint, terraform_mcp)", () => {
31
+ expect(isLicenseGated("tflint")).toBe(true);
32
+ expect(isLicenseGated("terraform_mcp")).toBe(true);
33
+ });
34
+
35
+ it("does not gate permissive tools (trivy, checkov, gitleaks)", () => {
36
+ expect(isLicenseGated("trivy")).toBe(false);
37
+ expect(isLicenseGated("checkov")).toBe(false);
38
+ expect(isLicenseGated("gitleaks")).toBe(false);
39
+ });
40
+
41
+ it("exempts the required substrate even though Terraform is BUSL", () => {
42
+ expect(TOOL_LICENSES.terraform.class).toBe("source-available");
43
+ expect(TOOL_LICENSES.terraform.required).toBe(true);
44
+ // required ⇒ never gated, so a Terraform fixer always has Terraform.
45
+ expect(isLicenseGated("terraform")).toBe(false);
46
+ });
47
+
48
+ it("LICENSE_GATED_TOOLS is exactly the gated set", () => {
49
+ const expected = ALL_TOOL_IDS.filter((id: ToolId) => isLicenseGated(id));
50
+ expect([...LICENSE_GATED_TOOLS].sort()).toEqual([...expected].sort());
51
+ // and it matches the documented gated tools.
52
+ expect([...LICENSE_GATED_TOOLS].sort()).toEqual(["terraform_mcp", "tflint"]);
53
+ });
54
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Tool-licence classification (§5 dependency posture; §1.5 "non-permissive-tool
3
+ * confirmation gate").
4
+ *
5
+ * Terramend ORCHESTRATES external tools — it never bundles or redistributes
6
+ * their binaries (§5 "be an orchestrator, not a redistributor"). This module
7
+ * makes the licence of every selectable tool explicit so the engine can DEFAULT
8
+ * to the permissively-licensed tools and treat a non-permissive one (tflint's
9
+ * embedded BUSL Terraform fork, HashiCorp's terraform-mcp-server) as an
10
+ * *informed, named opt-in* rather than something whose output is consumed
11
+ * silently. Pure data + pure predicates — the gate that consumes it lives in
12
+ * [[toolSelection]].
13
+ */
14
+
15
+ /** every external tool the engine can be configured to run. */
16
+ export type ToolId =
17
+ | "terraform"
18
+ | "tflint"
19
+ | "trivy"
20
+ | "checkov"
21
+ | "infracost"
22
+ | "gitleaks"
23
+ | "conftest"
24
+ | "terratest"
25
+ | "terraform_mcp";
26
+
27
+ /**
28
+ * Coarse licence family the gate reasons about:
29
+ * - `permissive` — MIT / Apache-2.0 / BSD / ISC: default-enabled.
30
+ * - `copyleft` — MPL / (L)GPL / AGPL: gated (explicit opt-in).
31
+ * - `source-available` — BUSL / SSPL / Elastic: gated (explicit opt-in).
32
+ */
33
+ export type LicenseClass = "permissive" | "copyleft" | "source-available";
34
+
35
+ export interface ToolLicense {
36
+ id: ToolId;
37
+ /** display name for logs / PR notes. */
38
+ name: string;
39
+ /** human-facing SPDX-ish identifier (shown, never parsed). */
40
+ license: string;
41
+ class: LicenseClass;
42
+ /**
43
+ * The core Terraform CLI is the SUBSTRATE the engine cannot run without and
44
+ * which the operator installs themselves — invoking it is never
45
+ * redistribution. It is licence-classified for honesty (Terraform moved to
46
+ * BUSL-1.1 at v1.6) but EXEMPT from the opt-in gate, so a Terraform fixer
47
+ * always has Terraform.
48
+ */
49
+ required?: boolean;
50
+ }
51
+
52
+ /**
53
+ * The single source of truth for what each tool is licensed under. Verified
54
+ * against the §5 "third-party dependency posture" note (June 2026). Keep in sync
55
+ * when a tool's licence changes (e.g. a vendor relicensing event).
56
+ */
57
+ export const TOOL_LICENSES: Readonly<Record<ToolId, ToolLicense>> = {
58
+ terraform: {
59
+ id: "terraform",
60
+ name: "Terraform CLI",
61
+ license: "BUSL-1.1",
62
+ class: "source-available",
63
+ required: true,
64
+ },
65
+ // tflint is MPL-2.0, but it embeds a BUSL Terraform fork — §5 flags it as the
66
+ // canonical "never bundle/redistribute; invoke as an external process" case.
67
+ tflint: { id: "tflint", name: "TFLint", license: "MPL-2.0", class: "copyleft" },
68
+ trivy: { id: "trivy", name: "Trivy", license: "Apache-2.0", class: "permissive" },
69
+ checkov: { id: "checkov", name: "Checkov", license: "Apache-2.0", class: "permissive" },
70
+ infracost: { id: "infracost", name: "Infracost CLI", license: "Apache-2.0", class: "permissive" },
71
+ gitleaks: { id: "gitleaks", name: "gitleaks", license: "MIT", class: "permissive" },
72
+ conftest: { id: "conftest", name: "Conftest (OPA)", license: "Apache-2.0", class: "permissive" },
73
+ terratest: { id: "terratest", name: "Terratest", license: "Apache-2.0", class: "permissive" },
74
+ // HashiCorp's terraform-mcp-server, run as a Docker image — a redistribution-
75
+ // sensitive HashiCorp surface, so it is gated like tflint.
76
+ terraform_mcp: {
77
+ id: "terraform_mcp",
78
+ name: "terraform-mcp-server",
79
+ license: "MPL-2.0",
80
+ class: "copyleft",
81
+ },
82
+ } as const;
83
+
84
+ export const ALL_TOOL_IDS = Object.keys(TOOL_LICENSES) as ToolId[];
85
+
86
+ /** a permissively-licensed tool can be enabled by default; anything else needs
87
+ * an explicit, licence-named opt-in. */
88
+ export function isPermissive(c: LicenseClass): boolean {
89
+ return c === "permissive";
90
+ }
91
+
92
+ /**
93
+ * A tool whose output must NOT be consumed without an explicit, licence-named
94
+ * opt-in: non-permissive AND not the required substrate. This is the predicate
95
+ * the confirmation gate is built on.
96
+ */
97
+ export function isLicenseGated(id: ToolId): boolean {
98
+ const t = TOOL_LICENSES[id];
99
+ return !t.required && !isPermissive(t.class);
100
+ }
101
+
102
+ /** every tool currently behind the licence gate (for docs / reporting). */
103
+ export const LICENSE_GATED_TOOLS: ToolId[] = ALL_TOOL_IDS.filter(isLicenseGated);
@@ -0,0 +1,140 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ parseToolSelection,
4
+ resolveToolSelection,
5
+ scannerToolId,
6
+ type ToolSelectionFlags,
7
+ } from "#app/utils/toolSelection";
8
+
9
+ describe("parseToolSelection", () => {
10
+ it("returns undefined for an unset / blank input", () => {
11
+ expect(parseToolSelection(undefined)).toBeUndefined();
12
+ expect(parseToolSelection(" ")).toBeUndefined();
13
+ });
14
+
15
+ it("reads the all / none bases", () => {
16
+ expect(parseToolSelection("all")?.base).toBe("all");
17
+ expect(parseToolSelection("none")?.base).toBe("none");
18
+ expect(parseToolSelection("*")?.base).toBe("all");
19
+ });
20
+
21
+ it("parses +/-/bare overrides, comma or newline separated", () => {
22
+ const d = parseToolSelection("trivy, -tflint\n+gitleaks");
23
+ expect(d?.explicit.get("trivy")).toBe(true);
24
+ expect(d?.explicit.get("tflint")).toBe(false);
25
+ expect(d?.explicit.get("gitleaks")).toBe(true);
26
+ });
27
+
28
+ it("canonicalises aliases (tf, terraform-mcp-server, opa)", () => {
29
+ const d = parseToolSelection("tf, terraform-mcp-server, opa");
30
+ expect(d?.explicit.get("terraform")).toBe(true);
31
+ expect(d?.explicit.get("terraform_mcp")).toBe(true);
32
+ expect(d?.explicit.get("conftest")).toBe(true);
33
+ });
34
+
35
+ it("collects unrecognised tokens instead of failing", () => {
36
+ const d = parseToolSelection("trivy, banana");
37
+ expect(d?.explicit.get("trivy")).toBe(true);
38
+ expect(d?.unknown).toEqual(["banana"]);
39
+ });
40
+ });
41
+
42
+ describe("resolveToolSelection — default (no tools_enabled)", () => {
43
+ const sel = resolveToolSelection({});
44
+
45
+ it("enables permissive scanners and the required substrate", () => {
46
+ expect(sel.enabled("terraform")).toBe(true);
47
+ expect(sel.enabled("trivy")).toBe(true);
48
+ expect(sel.enabled("checkov")).toBe(true);
49
+ expect(sel.enabled("infracost")).toBe(true);
50
+ expect(sel.enabled("conftest")).toBe(true);
51
+ });
52
+
53
+ it("gates non-permissive tools off with a licence-named reason", () => {
54
+ expect(sel.enabled("tflint")).toBe(false);
55
+ expect(sel.offReason("tflint")).toMatch(/licence-gated.*tflint/i);
56
+ expect(sel.enabled("terraform_mcp")).toBe(false);
57
+ expect(sel.gated).toEqual(expect.arrayContaining(["tflint", "terraform_mcp"]));
58
+ });
59
+
60
+ it("keeps the flag-opt-in extras off until their input is set", () => {
61
+ expect(sel.enabled("gitleaks")).toBe(false);
62
+ expect(sel.enabled("terratest")).toBe(false);
63
+ });
64
+ });
65
+
66
+ describe("resolveToolSelection — dedicated booleans still opt in", () => {
67
+ it("gitleaks: true enables it", () => {
68
+ expect(resolveToolSelection({ gitleaks: true }).enabled("gitleaks")).toBe(true);
69
+ });
70
+
71
+ it("terraform_mcp: true is the licence opt-in for the gated server", () => {
72
+ const sel = resolveToolSelection({ terraformMcp: true });
73
+ expect(sel.enabled("terraform_mcp")).toBe(true);
74
+ expect(sel.gated).not.toContain("terraform_mcp");
75
+ });
76
+
77
+ it("terratest: true enables the scaffold", () => {
78
+ expect(resolveToolSelection({ terratest: true }).enabled("terratest")).toBe(true);
79
+ });
80
+ });
81
+
82
+ describe("resolveToolSelection — tools_enabled overrides", () => {
83
+ const resolve = (raw: string, flags: ToolSelectionFlags = {}) =>
84
+ resolveToolSelection({ ...flags, toolsEnabled: parseToolSelection(raw) });
85
+
86
+ it("naming a gated tool is the explicit licence opt-in", () => {
87
+ expect(resolve("tflint").enabled("tflint")).toBe(true);
88
+ });
89
+
90
+ it("an explicit disable always wins, even over its dedicated flag", () => {
91
+ const sel = resolve("-gitleaks", { gitleaks: true });
92
+ expect(sel.enabled("gitleaks")).toBe(false);
93
+ expect(sel.disabled).toContain("gitleaks");
94
+ });
95
+
96
+ it("an explicit disable wins over the all base", () => {
97
+ const sel = resolve("all, -trivy");
98
+ expect(sel.enabled("trivy")).toBe(false);
99
+ expect(sel.enabled("tflint")).toBe(true); // all enables the gated tool too
100
+ });
101
+
102
+ it("base all accepts every tool (incl. gated + flag-opt-in)", () => {
103
+ const sel = resolve("all");
104
+ expect(sel.enabled("tflint")).toBe(true);
105
+ expect(sel.enabled("terraform_mcp")).toBe(true);
106
+ expect(sel.enabled("gitleaks")).toBe(true);
107
+ expect(sel.gated).toEqual([]);
108
+ });
109
+
110
+ it("base none enables nothing but the substrate + the explicitly/flag-added", () => {
111
+ const sel = resolve("none, +trivy");
112
+ expect(sel.enabled("terraform")).toBe(true); // required, always on
113
+ expect(sel.enabled("trivy")).toBe(true);
114
+ expect(sel.enabled("checkov")).toBe(false);
115
+ expect(sel.enabled("tflint")).toBe(false);
116
+ });
117
+
118
+ it("the required substrate cannot be disabled", () => {
119
+ expect(resolve("-terraform").enabled("terraform")).toBe(true);
120
+ expect(resolve("none").enabled("terraform")).toBe(true);
121
+ });
122
+
123
+ it("surfaces unknown tokens for a warning", () => {
124
+ expect(resolve("trivy, nope").unknownTokens).toEqual(["nope"]);
125
+ });
126
+ });
127
+
128
+ describe("scannerToolId", () => {
129
+ it("maps scanner sources to their tool id", () => {
130
+ expect(scannerToolId("terraform-fmt")).toBe("terraform");
131
+ expect(scannerToolId("terraform-validate")).toBe("terraform");
132
+ expect(scannerToolId("tflint")).toBe("tflint");
133
+ expect(scannerToolId("trivy")).toBe("trivy");
134
+ expect(scannerToolId("checkov")).toBe("checkov");
135
+ });
136
+
137
+ it("returns null for the reviewer pseudo-source the gate never governs", () => {
138
+ expect(scannerToolId("reviewer")).toBeNull();
139
+ });
140
+ });