@nimbus-dev/connectors 0.8.0 → 0.9.0

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.
@@ -1,66 +1,687 @@
1
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { chmod, lstat, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
3
+ import { basename, isAbsolute, join, relative, sep } from "node:path";
4
4
  import { z } from "zod";
5
5
  import { awsCliArg, cliArg } from "../../../shared/cli-json-kit.ts";
6
6
  import type { ConsentServer } from "../../../shared/consent-kit.ts";
7
- import { createWriteToolRegistrar } from "../../../shared/consent-kit.ts";
7
+ import {
8
+ createPrepareToolRegistrar,
9
+ createWriteToolRegistrar,
10
+ } from "../../../shared/consent-kit.ts";
8
11
  import {
9
12
  createRegisterSimpleTool,
10
13
  createZodToolRegistrar,
11
14
  mcpJsonResult as jsonResult,
12
15
  } from "../../../shared/mcp-tool-kit.ts";
13
- import { runCliOkThrowing } from "../../../shared/run-cli-json.ts";
16
+ import { scratchDirBase, withScratchDir } from "../../../shared/private-scratch.ts";
17
+ import { runCliCode, runCliOkThrowing } from "../../../shared/run-cli-json.ts";
14
18
 
15
19
  /**
16
- * The most a template may be when it is deployed without an S3 bucket, which this tool does not
17
- * take: `aws cloudformation deploy` refuses a larger one itself, and only after the prompt.
20
+ * The most a template may be when it is handed to `create-change-set` as `--template-body`, which
21
+ * is how this connector hands one over (no S3 bucket): CloudFormation refuses a larger one itself.
22
+ * https://docs.aws.amazon.com/AWSCloudFormation/latest/APIReference/API_CreateChangeSet.html
18
23
  */
19
24
  const TEMPLATE_MAX_BYTES = 51_200;
20
25
 
26
+ /** A saved plan file is read whole (to hash it), so it is bounded. */
27
+ const PLAN_FILE_MAX_BYTES = 64 * 1024 * 1024;
28
+ /** `terraform show -json` output is returned whole, so it is bounded. */
29
+ const PLAN_JSON_MAX_BYTES = 4 * 1024 * 1024;
30
+ /** Most resource changes one change-set reply carries. */
31
+ const CHANGES_MAX = 500;
32
+ /** Most bytes of one change-set reply (the prepare tool's whole answer). */
33
+ const CHANGESET_REPLY_MAX_BYTES = 256 * 1024;
34
+ /** Longest the change set is polled for, and how often. */
35
+ const CHANGESET_POLL_DEADLINE_MS = 120_000;
36
+ const CHANGESET_POLL_INTERVAL_MS = 2000;
37
+ /** Page guard for `NextToken` following, so a misbehaving CLI cannot loop forever. */
38
+ const CHANGESET_PAGES_MAX = 100;
39
+
40
+ /**
41
+ * Time seams for the change-set poll, exported so a test never waits two real seconds per poll.
42
+ * Production never replaces them.
43
+ */
44
+ export const iacTimeHooks: {
45
+ sleep: (ms: number) => Promise<void>;
46
+ now: () => number;
47
+ } = {
48
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
49
+ now: () => Date.now(),
50
+ };
51
+
21
52
  /**
22
53
  * A CloudFormation template. It reaches the AWS CLI as a file, never as an argument (see
23
- * {@link withTemplateFile}), so none of the argument rules apply to it: a YAML template may well
24
- * start with `---`. Its size is refused here, in the schema, because no consent can lift it: a
25
- * larger template would be put to the human, spend a unit of write budget, and then fail. The
26
- * character bound is the one a client is shown; no string over it can be within the byte bound.
54
+ * `withScratchDir` below), so none of the argument rules apply to it: a YAML template may well
55
+ * start with `---`. The schema carries only the character bound a client is shown; the UTF-8 byte
56
+ * bound is {@link templateProblem}, run by the handlers and by `refuseBefore` (a `.refine()` on the
57
+ * registered schema would lose its `.shape`).
27
58
  */
28
- const templateDocument = z
59
+ const templateDocument = z.string().min(1).max(TEMPLATE_MAX_BYTES);
60
+
61
+ /** Why `template` may not be sent, or `undefined`. A fixed message, before any spawn. */
62
+ export function templateProblem(template: string): string | undefined {
63
+ if (new TextEncoder().encode(template).length > TEMPLATE_MAX_BYTES) {
64
+ return `templateBody must be at most ${String(TEMPLATE_MAX_BYTES)} bytes as UTF-8, the most create-change-set takes without an S3 bucket`;
65
+ }
66
+ return undefined;
67
+ }
68
+
69
+ const CAPABILITY_VALUES = [
70
+ "CAPABILITY_IAM",
71
+ "CAPABILITY_NAMED_IAM",
72
+ "CAPABILITY_AUTO_EXPAND",
73
+ ] as const;
74
+ const capabilitiesSchema = z.array(z.enum(CAPABILITY_VALUES)).max(3).optional();
75
+
76
+ /** Why `capabilities` may not be sent (a duplicate), or `undefined`. */
77
+ function capabilitiesProblem(capabilities: readonly string[] | undefined): string | undefined {
78
+ if (capabilities !== undefined && new Set(capabilities).size !== capabilities.length) {
79
+ return "capabilities must not repeat a value";
80
+ }
81
+ return undefined;
82
+ }
83
+
84
+ /**
85
+ * A change set's ARN. The CLI's `--change-set-name` takes a name or an ARN; the ARN alone names
86
+ * one change set of one stack unambiguously, and starts with `arn:` so it can never read as a flag
87
+ * or a `file://` load.
88
+ */
89
+ const changeSetArn = z
29
90
  .string()
30
- .min(1)
31
- .max(TEMPLATE_MAX_BYTES)
32
- .refine((t) => new TextEncoder().encode(t).length <= TEMPLATE_MAX_BYTES, {
33
- message: `must be at most ${String(TEMPLATE_MAX_BYTES)} bytes as UTF-8, the most aws cloudformation deploy takes without an S3 bucket`,
34
- });
91
+ .regex(
92
+ /^arn:aws[a-z-]*:cloudformation:[a-z0-9-]+:\d{12}:changeSet\/[A-Za-z][-A-Za-z0-9]{0,127}\/[0-9a-f-]{36}$/,
93
+ "must be a CloudFormation change set ARN",
94
+ );
95
+
96
+ /** The change set name inside an ARN that already matched {@link changeSetArn}. */
97
+ function changeSetNameOf(arn: string): string {
98
+ return arn.split("/")[1] ?? "";
99
+ }
100
+
101
+ /**
102
+ * A saved plan file path. `cliArg` refuses a leading `-` and control characters, and caps the
103
+ * length at 1024 characters (the repo-wide CLI argument bound, which `cli-argument-guards.test.ts`
104
+ * pins), a little under the 4096 the plan first named: a plan path under NIMBUS_IAC_PLAN_DIR is
105
+ * far shorter.
106
+ */
107
+ const planFileArg = cliArg;
108
+
109
+ const sha256Hex = z.string().regex(/^[0-9a-f]{64}$/, "must be a lower-case SHA-256 hex digest");
110
+
111
+ function sha256Of(data: string | Uint8Array): string {
112
+ return createHash("sha256").update(data).digest("hex");
113
+ }
114
+
115
+ /** The file names a plan is saved under: the KIND of plan is its name (apply vs destroy). */
116
+ const PLAN_NAME = "plan.tfplan";
117
+ const DESTROY_PLAN_NAME = "destroy.tfplan";
118
+
119
+ /**
120
+ * Why a plan file path is refused before the disk is touched (shared with refuseBefore): not
121
+ * absolute, a Windows device or UNC form (a leading backslash, which covers the
122
+ * long-path, device and host forms; on win32 also two forward slashes), or not one of the file
123
+ * names allowed (apply takes only plan.tfplan, destroy only destroy.tfplan).
124
+ */
125
+ function planFilePathProblem(planFile: string, names: readonly string[]): string | undefined {
126
+ if (planFile.startsWith("\\") || (process.platform === "win32" && planFile.startsWith("//"))) {
127
+ return "planFile must not be a device or network path";
128
+ }
129
+ if (!isAbsolute(planFile)) return "planFile must be an absolute path";
130
+ if (!names.includes(basename(planFile))) {
131
+ return `planFile must be named ${names.join(" or ")}`;
132
+ }
133
+ return undefined;
134
+ }
35
135
 
36
136
  /**
37
- * Run `body` with `template` written to a file of its own, removed afterwards however `body` ends.
137
+ * Check a plan file and return its REAL path, which is what is hashed and handed to terraform.
38
138
  *
39
- * `aws cloudformation deploy` takes a template only as `--template-file`, and passing one as an
40
- * argument would not work anyway: a template can run past the 32,767 characters a Windows command
41
- * line holds, and an `aws` that is a batch file, as a pip-installed v1 is, has cmd.exe parse every
42
- * quote in it.
139
+ * It must be a regular, non-linked file of a sane size that lies strictly inside the real
140
+ * NIMBUS_IAC_PLAN_DIR (a symlinked parent directory, or a sibling whose name merely starts with
141
+ * the plan dir's, is outside). NO copy is made. Making that directory read-only to everyone but the
142
+ * gateway between approval and apply is the gateway's job; this connector can only narrow the
143
+ * window, not close it (README: stated bound).
43
144
  */
44
- async function withTemplateFile<T>(
45
- template: string,
46
- body: (path: string) => Promise<T>,
47
- ): Promise<T> {
48
- const dir = await mkdtemp(join(tmpdir(), "nimbus-cfn-"));
145
+ async function resolvePlanFile(planFile: string, names: readonly string[]): Promise<string> {
146
+ const problem = planFilePathProblem(planFile, names);
147
+ if (problem !== undefined) throw new Error(problem);
148
+ const base = await planDirBase();
149
+ let st: Awaited<ReturnType<typeof lstat>>;
150
+ try {
151
+ st = await lstat(planFile);
152
+ } catch {
153
+ throw new Error("planFile does not exist");
154
+ }
155
+ if (st.isSymbolicLink() || !st.isFile()) {
156
+ throw new Error("planFile must be a regular file, not a link");
157
+ }
158
+ if (st.size > PLAN_FILE_MAX_BYTES) {
159
+ throw new Error("planFile is too large");
160
+ }
161
+ const realBase = await realpath(base);
162
+ const real = await realpath(planFile);
163
+ const rel = relative(realBase, real);
164
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
165
+ throw new Error(`planFile must be inside ${IAC_PLAN_DIR_ENV}`);
166
+ }
167
+ return real;
168
+ }
169
+
170
+ /** Environment of every terraform child: automation mode, and never an interactive prompt. */
171
+ function terraformEnv(): Record<string, string | undefined> {
172
+ return { ...process.env, TF_IN_AUTOMATION: "1", TF_INPUT: "0" };
173
+ }
174
+
175
+ /** The directory plan files live under: absolute, an existing real directory. */
176
+ export const IAC_PLAN_DIR_ENV = "NIMBUS_IAC_PLAN_DIR";
177
+
178
+ async function planDirBase(): Promise<string> {
179
+ const configured = process.env[IAC_PLAN_DIR_ENV]?.trim();
180
+ if (configured === undefined || configured === "") {
181
+ throw new Error(`${IAC_PLAN_DIR_ENV} is not set: a plan needs a directory to be saved in`);
182
+ }
183
+ if (!isAbsolute(configured)) {
184
+ throw new Error(`${IAC_PLAN_DIR_ENV} must be an absolute path`);
185
+ }
186
+ let st: Awaited<ReturnType<typeof lstat>>;
187
+ try {
188
+ st = await lstat(configured);
189
+ } catch {
190
+ throw new Error(`${IAC_PLAN_DIR_ENV} does not name an existing directory`);
191
+ }
192
+ if (st.isSymbolicLink() || !st.isDirectory()) {
193
+ throw new Error(`${IAC_PLAN_DIR_ENV} must be a real directory, not a link`);
194
+ }
195
+ return configured;
196
+ }
197
+
198
+ /** `terraform plan` / `plan -destroy`, saved under a fresh per-plan directory. */
199
+ async function terraformPlan(
200
+ workingDirectory: string,
201
+ destroy: boolean,
202
+ ): Promise<ReturnType<typeof jsonResult>> {
203
+ const base = await planDirBase();
204
+ const planId = randomBytes(16).toString("hex");
205
+ const planDir = join(base, planId);
206
+ // Non-recursive: an existing directory of that name is an error, never silently reused.
207
+ await mkdir(planDir, { mode: 0o700 });
208
+ const planFile = join(planDir, destroy ? DESTROY_PLAN_NAME : PLAN_NAME);
209
+ try {
210
+ const r = await runCliCode(
211
+ [
212
+ "terraform",
213
+ `-chdir=${workingDirectory}`,
214
+ "plan",
215
+ ...(destroy ? ["-destroy"] : []),
216
+ "-input=false",
217
+ "-no-color",
218
+ "-detailed-exitcode",
219
+ "-lock-timeout=30s",
220
+ `-out=${planFile}`,
221
+ ],
222
+ terraformEnv(),
223
+ );
224
+ // -detailed-exitcode: 0 = empty diff, 1 = error, 2 = changes present.
225
+ // https://developer.hashicorp.com/terraform/cli/commands/plan
226
+ if (r.code !== 0 && r.code !== 2) {
227
+ throw new Error(`terraform exited ${String(r.code)}: ${r.stderr.slice(0, 500)}`);
228
+ }
229
+ try {
230
+ await chmod(planFile, 0o600);
231
+ } catch {
232
+ // Best effort: Windows has no POSIX modes; the directory is already 0700 where it matters.
233
+ }
234
+ const planSha256 = sha256Of(await readFile(planFile));
235
+ return jsonResult({ planId, planFile, planSha256, hasChanges: r.code === 2 });
236
+ } catch (e) {
237
+ await rm(planDir, { recursive: true, force: true });
238
+ throw e;
239
+ }
240
+ }
241
+
242
+ /** `terraform apply <planFile>` after the file's hash is proven to be the approved one. */
243
+ async function terraformApplyPlan(
244
+ p: { workingDirectory: string; planFile: string; planSha256: string },
245
+ name: string,
246
+ ): Promise<ReturnType<typeof jsonResult>> {
247
+ const planFile = await resolvePlanFile(p.planFile, [name]);
248
+ // Read ONCE; the hash is of exactly these bytes.
249
+ const actual = Buffer.from(sha256Of(await readFile(planFile)), "hex");
250
+ const approved = Buffer.from(p.planSha256, "hex");
251
+ if (actual.length !== approved.length || !timingSafeEqual(actual, approved)) {
252
+ throw new Error("plan file does not match the approved hash");
253
+ }
254
+ // No -auto-approve and no -destroy: a saved plan applies as it was planned, and `apply <file>`
255
+ // runs without a prompt. https://developer.hashicorp.com/terraform/cli/commands/apply
256
+ const r = await runCliCode(
257
+ ["terraform", `-chdir=${p.workingDirectory}`, "apply", "-input=false", "-no-color", planFile],
258
+ terraformEnv(),
259
+ );
260
+ if (r.code !== 0) {
261
+ throw new Error(`terraform exited ${String(r.code)}: ${r.stderr.slice(0, 500)}`);
262
+ }
263
+ // A plan carries secrets and is stale once applied. Best effort: a directory the gateway made
264
+ // read-only makes this fail, which is ignored.
49
265
  try {
50
- const path = join(dir, "template");
51
- await writeFile(path, template, { encoding: "utf8", mode: 0o600, flag: "wx" });
52
- return await body(path);
53
- } finally {
54
- await rm(dir, { recursive: true, force: true });
266
+ await rm(planFile, { force: true });
267
+ } catch {
268
+ // ignored, see above
269
+ }
270
+ return jsonResult({ ok: true, status: "applied" });
271
+ }
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // CloudFormation
275
+
276
+ /** The aws CLI's environment: the operator's, plus UTF-8 for the template file it reads. */
277
+ function awsEnv(): Record<string, string | undefined> {
278
+ // The file is UTF-8. Left to itself the CLI reads it in the locale's encoding, and on a
279
+ // Windows machine using cp1252 a template holding "Á" fails to decode while one holding
280
+ // "é" deploys as "é". AWS CLI v2 reads AWS_CLI_FILE_ENCODING and ignores PYTHONUTF8; v1,
281
+ // plain Python, reads PYTHONUTF8 and ignores the other. Both are set.
282
+ return { ...process.env, AWS_CLI_FILE_ENCODING: "UTF-8", PYTHONUTF8: "1" };
283
+ }
284
+
285
+ /** Run an aws command; any non-zero exit throws the one failure message. Resolves to stdout. */
286
+ async function awsOk(argv: readonly string[]): Promise<string> {
287
+ const r = await runCliCode(["aws", ...argv], awsEnv());
288
+ if (r.code !== 0) {
289
+ throw new Error(`aws exited ${String(r.code)}: ${r.stderr.slice(0, 500)}`);
290
+ }
291
+ return r.stdout;
292
+ }
293
+
294
+ function parseObject(text: string, what: string): Record<string, unknown> {
295
+ let v: unknown;
296
+ try {
297
+ v = JSON.parse(text);
298
+ } catch {
299
+ throw new Error(`invalid JSON from aws ${what}`);
300
+ }
301
+ if (typeof v !== "object" || v === null || Array.isArray(v)) {
302
+ throw new Error(`unexpected reply from aws ${what}`);
303
+ }
304
+ return v as Record<string, unknown>;
305
+ }
306
+
307
+ function str(v: unknown): string | undefined {
308
+ return typeof v === "string" ? v : undefined;
309
+ }
310
+
311
+ type StackState =
312
+ | { exists: false }
313
+ | { exists: true; status: string; statusReason?: string; lastUpdatedTime?: string };
314
+
315
+ async function describeStack(stackName: string): Promise<StackState> {
316
+ const r = await runCliCode(
317
+ ["aws", "cloudformation", "describe-stacks", "--stack-name", stackName, "--output", "json"],
318
+ awsEnv(),
319
+ );
320
+ if (r.code !== 0) {
321
+ // A missing stack is an ordinary answer (a first deploy), reported on stderr as
322
+ // "Stack with id X does not exist".
323
+ if (/does not exist/.test(r.stderr)) return { exists: false };
324
+ throw new Error(`aws exited ${String(r.code)}: ${r.stderr.slice(0, 500)}`);
325
+ }
326
+ const stacks = parseObject(r.stdout, "describe-stacks")["Stacks"];
327
+ const first: unknown = Array.isArray(stacks) ? stacks[0] : undefined;
328
+ if (typeof first !== "object" || first === null) return { exists: false };
329
+ const s = first as Record<string, unknown>;
330
+ const status = str(s["StackStatus"]);
331
+ if (status === undefined) throw new Error("unexpected reply from aws describe-stacks");
332
+ const out: StackState = { exists: true, status };
333
+ const reason = str(s["StackStatusReason"]);
334
+ if (reason !== undefined) out.statusReason = reason;
335
+ const updated = str(s["LastUpdatedTime"]) ?? str(s["CreationTime"]);
336
+ if (updated !== undefined) out.lastUpdatedTime = updated;
337
+ return out;
338
+ }
339
+
340
+ /**
341
+ * Remove a change set, and the empty stack shell a CREATE-type change set leaves behind.
342
+ *
343
+ * A first-time deploy's change set puts the stack in REVIEW_IN_PROGRESS, a shell with no resources
344
+ * that lingers. It is deleted only when it is in that status AND holds no change set at all; a
345
+ * stack in any other status is never deleted here.
346
+ * https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-changesets.html
347
+ */
348
+ async function cleanupChangeSet(
349
+ stackName: string,
350
+ changeSet: string,
351
+ skipChangeSetDelete: boolean,
352
+ ): Promise<{ changeSetDeleted: boolean; stackDeleted: boolean }> {
353
+ if (!skipChangeSetDelete) {
354
+ await awsOk(["cloudformation", "delete-change-set", "--change-set-name", changeSet]);
355
+ }
356
+ let stackDeleted = false;
357
+ const stack = await describeStack(stackName);
358
+ if (stack.exists && stack.status === "REVIEW_IN_PROGRESS") {
359
+ const listed = await awsOk([
360
+ "cloudformation",
361
+ "list-change-sets",
362
+ "--stack-name",
363
+ stackName,
364
+ "--output",
365
+ "json",
366
+ ]);
367
+ const summaries = parseObject(listed, "list-change-sets")["Summaries"];
368
+ if (!Array.isArray(summaries) || summaries.length === 0) {
369
+ await awsOk(["cloudformation", "delete-stack", "--stack-name", stackName]);
370
+ stackDeleted = true;
371
+ }
372
+ }
373
+ return { changeSetDeleted: !skipChangeSetDelete, stackDeleted };
374
+ }
375
+
376
+ type ChangeRow = {
377
+ action: string;
378
+ logicalId: string;
379
+ physicalId?: string;
380
+ resourceType: string;
381
+ replacement: string;
382
+ scope: string[];
383
+ };
384
+
385
+ function changeRowOf(raw: unknown): ChangeRow | undefined {
386
+ if (typeof raw !== "object" || raw === null) return undefined;
387
+ const rc = (raw as Record<string, unknown>)["ResourceChange"];
388
+ if (typeof rc !== "object" || rc === null) return undefined;
389
+ const c = rc as Record<string, unknown>;
390
+ const row: ChangeRow = {
391
+ action: str(c["Action"]) ?? "unknown",
392
+ logicalId: str(c["LogicalResourceId"]) ?? "",
393
+ resourceType: str(c["ResourceType"]) ?? "",
394
+ replacement: str(c["Replacement"]) ?? "unknown",
395
+ scope: stringList(c["Scope"]),
396
+ };
397
+ const physical = str(c["PhysicalResourceId"]);
398
+ if (physical !== undefined) row.physicalId = physical;
399
+ return row;
400
+ }
401
+
402
+ function stringList(v: unknown): string[] {
403
+ return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
404
+ }
405
+
406
+ function setEqual(a: readonly string[], b: readonly string[]): boolean {
407
+ return new Set(a).size === a.length && a.length === b.length && a.every((x) => b.includes(x));
408
+ }
409
+
410
+ /** The name every change set of this connector carries: bound to the exact template it is for. */
411
+ function changeSetNameFor(templateSha256: string): string {
412
+ return `nimbus-${templateSha256}-${randomBytes(6).toString("hex")}`;
413
+ }
414
+
415
+ /** Sync refusals shared by the execute handler and `refuseBefore`. */
416
+ function deployProblem(p: {
417
+ templateBody: string;
418
+ capabilities?: readonly string[] | undefined;
419
+ changeSetId: string;
420
+ }): string | undefined {
421
+ const t = templateProblem(p.templateBody) ?? capabilitiesProblem(p.capabilities);
422
+ if (t !== undefined) return t;
423
+ const expected = new RegExp(`^nimbus-${sha256Of(p.templateBody)}-[0-9a-f]{12}$`);
424
+ if (!expected.test(changeSetNameOf(p.changeSetId))) {
425
+ return "the change set was not made for this template";
426
+ }
427
+ return undefined;
428
+ }
429
+
430
+ /** Poll the change set until it is CREATE_COMPLETE; returns that reply. Throws otherwise. */
431
+ async function awaitChangeSet(
432
+ describe: (nextToken?: string) => Promise<string>,
433
+ ): Promise<Record<string, unknown>> {
434
+ const deadline = iacTimeHooks.now() + CHANGESET_POLL_DEADLINE_MS;
435
+ for (;;) {
436
+ const d = parseObject(await describe(), "describe-change-set");
437
+ const status = str(d["Status"]);
438
+ if (status === "CREATE_COMPLETE") return d;
439
+ if (status !== "CREATE_PENDING" && status !== "CREATE_IN_PROGRESS") {
440
+ // FAILED (a template with no changes lands here too: its StatusReason says so) or any
441
+ // other terminal status.
442
+ // Capped like every stderr echo here: the reason is AWS's text and may quote the template.
443
+ throw new Error(
444
+ `change set ${status ?? "unknown"}: ${(str(d["StatusReason"]) ?? "no reason given").slice(0, 500)}`,
445
+ );
446
+ }
447
+ if (iacTimeHooks.now() >= deadline) {
448
+ throw new Error("change set was not ready within 120 seconds");
449
+ }
450
+ await iacTimeHooks.sleep(CHANGESET_POLL_INTERVAL_MS);
451
+ }
452
+ }
453
+
454
+ /** Every resource change, following `NextToken` by hand. */
455
+ async function collectChanges(
456
+ first: Record<string, unknown>,
457
+ describe: (nextToken?: string) => Promise<string>,
458
+ ): Promise<ChangeRow[]> {
459
+ const changes: ChangeRow[] = [];
460
+ let page = first;
461
+ for (let pages = 1; ; pages += 1) {
462
+ const raw = page["Changes"];
463
+ for (const item of Array.isArray(raw) ? raw : []) {
464
+ const row = changeRowOf(item);
465
+ if (row !== undefined) changes.push(row);
466
+ }
467
+ if (changes.length > CHANGES_MAX) throw new Error("change set too large");
468
+ const token = str(page["NextToken"]);
469
+ if (token === undefined || token === "") return changes;
470
+ if (pages >= CHANGESET_PAGES_MAX) throw new Error("change set too large");
471
+ // U: whether the CLI paginates describe-change-set itself is unconfirmed; see the README.
472
+ page = parseObject(await describe(token), "describe-change-set");
473
+ }
474
+ }
475
+
476
+ async function changeSetCreate(p: {
477
+ stackName: string;
478
+ templateBody: string;
479
+ capabilities?: readonly string[] | undefined;
480
+ }): Promise<ReturnType<typeof jsonResult>> {
481
+ const bad = templateProblem(p.templateBody) ?? capabilitiesProblem(p.capabilities);
482
+ if (bad !== undefined) throw new Error(bad);
483
+ const capabilities = p.capabilities ?? [];
484
+ const templateSha256 = sha256Of(p.templateBody);
485
+ // Refuse a missing scratch grant (Windows under the gateway) before the first aws call, as the
486
+ // kubernetes plan does: the template file is written under it below.
487
+ scratchDirBase();
488
+
489
+ const stack = await describeStack(p.stackName);
490
+ // REVIEW_IN_PROGRESS: a shell left by an earlier CREATE change set, which only a CREATE change
491
+ // set may be added to. DELETE_COMPLETE: a deleted stack can be created again under its name.
492
+ const changeSetType =
493
+ !stack.exists || stack.status === "REVIEW_IN_PROGRESS" || stack.status === "DELETE_COMPLETE"
494
+ ? "CREATE"
495
+ : "UPDATE";
496
+ const name = changeSetNameFor(templateSha256);
497
+
498
+ return withScratchDir("nimbus-cfn-", async (dir) => {
499
+ const templatePath = join(dir, "template");
500
+ await writeFile(templatePath, p.templateBody, { encoding: "utf8", mode: 0o600, flag: "wx" });
501
+ const created = parseObject(
502
+ await awsOk([
503
+ "cloudformation",
504
+ "create-change-set",
505
+ "--stack-name",
506
+ p.stackName,
507
+ "--change-set-name",
508
+ name,
509
+ "--change-set-type",
510
+ changeSetType,
511
+ "--template-body",
512
+ `file://${templatePath}`,
513
+ ...(capabilities.length > 0 ? ["--capabilities", ...capabilities] : []),
514
+ "--output",
515
+ "json",
516
+ ]),
517
+ "create-change-set",
518
+ );
519
+ const changeSetId = str(created["Id"]);
520
+ if (changeSetId === undefined) {
521
+ // The call succeeded, so a change set (and, for CREATE, a REVIEW_IN_PROGRESS stack) may
522
+ // exist: delete it by name, best effort, rather than leave it behind with no Id to report.
523
+ await runCliCode(
524
+ [
525
+ "aws",
526
+ "cloudformation",
527
+ "delete-change-set",
528
+ "--change-set-name",
529
+ name,
530
+ "--stack-name",
531
+ p.stackName,
532
+ ],
533
+ awsEnv(),
534
+ );
535
+ throw new Error("unexpected reply from aws create-change-set");
536
+ }
537
+ const stackId = str(created["StackId"]) ?? "";
538
+
539
+ const describe = (nextToken?: string): Promise<string> =>
540
+ awsOk([
541
+ "cloudformation",
542
+ "describe-change-set",
543
+ "--change-set-name",
544
+ changeSetId,
545
+ ...(nextToken === undefined ? [] : ["--next-token", nextToken]),
546
+ "--output",
547
+ "json",
548
+ ]);
549
+
550
+ try {
551
+ const changes = await collectChanges(await awaitChangeSet(describe), describe);
552
+ const reply = {
553
+ changeSetId,
554
+ changeSetName: name,
555
+ stackId,
556
+ stackName: p.stackName,
557
+ changeSetType,
558
+ templateSha256,
559
+ capabilities,
560
+ changes,
561
+ };
562
+ if (Buffer.byteLength(JSON.stringify(reply), "utf8") > CHANGESET_REPLY_MAX_BYTES) {
563
+ throw new Error("change set too large");
564
+ }
565
+ return jsonResult(reply);
566
+ } catch (e) {
567
+ try {
568
+ await cleanupChangeSet(p.stackName, changeSetId, false);
569
+ } catch {
570
+ // Best effort: the failure being reported is the original one.
571
+ }
572
+ throw e;
573
+ }
574
+ });
575
+ }
576
+
577
+ async function changeSetExecute(p: {
578
+ stackName: string;
579
+ templateBody: string;
580
+ capabilities?: readonly string[] | undefined;
581
+ changeSetId: string;
582
+ }): Promise<ReturnType<typeof jsonResult>> {
583
+ const bad = deployProblem(p);
584
+ if (bad !== undefined) throw new Error(bad);
585
+ const d = parseObject(
586
+ await awsOk([
587
+ "cloudformation",
588
+ "describe-change-set",
589
+ "--change-set-name",
590
+ p.changeSetId,
591
+ "--output",
592
+ "json",
593
+ ]),
594
+ "describe-change-set",
595
+ );
596
+ if (str(d["Status"]) !== "CREATE_COMPLETE") {
597
+ throw new Error(`change set is ${str(d["Status"]) ?? "unknown"}, not CREATE_COMPLETE`);
598
+ }
599
+ if (str(d["ExecutionStatus"]) !== "AVAILABLE") {
600
+ throw new Error(`change set cannot be executed: ${str(d["ExecutionStatus"]) ?? "unknown"}`);
601
+ }
602
+ if (str(d["StackName"]) !== p.stackName) {
603
+ throw new Error("change set belongs to a different stack");
55
604
  }
605
+ if (!setEqual(stringList(d["Capabilities"]), p.capabilities ?? [])) {
606
+ throw new Error("change set capabilities do not match the approved capabilities");
607
+ }
608
+ await awsOk(["cloudformation", "execute-change-set", "--change-set-name", p.changeSetId]);
609
+ return jsonResult({
610
+ ok: true,
611
+ status: "execution started",
612
+ changeSetId: p.changeSetId,
613
+ stackId: str(d["StackId"]) ?? "",
614
+ });
615
+ }
616
+
617
+ /**
618
+ * The exact shape of a change set name this connector makes (`nimbus-<template sha256>-<12 hex>`).
619
+ * Discard requires all of it, not a `nimbus-` prefix: a person's own `nimbus-prod-hotfix` change
620
+ * set must not be deletable through a tool that says it deletes only its own.
621
+ */
622
+ const NIMBUS_CHANGE_SET_NAME = /^nimbus-[0-9a-f]{64}-[0-9a-f]{12}$/;
623
+
624
+ function discardProblem(changeSetId: string): string | undefined {
625
+ return NIMBUS_CHANGE_SET_NAME.test(changeSetNameOf(changeSetId))
626
+ ? undefined
627
+ : "only a change set this connector made (nimbus-<template sha256>-<12 hex>) can be discarded";
628
+ }
629
+
630
+ async function describeChangeSetOrMissing(
631
+ changeSetId: string,
632
+ ): Promise<Record<string, unknown> | undefined> {
633
+ const r = await runCliCode(
634
+ [
635
+ "aws",
636
+ "cloudformation",
637
+ "describe-change-set",
638
+ "--change-set-name",
639
+ changeSetId,
640
+ "--output",
641
+ "json",
642
+ ],
643
+ awsEnv(),
644
+ );
645
+ if (r.code !== 0) {
646
+ if (/ChangeSetNotFound|does not exist/.test(r.stderr)) return undefined;
647
+ throw new Error(`aws exited ${String(r.code)}: ${r.stderr.slice(0, 500)}`);
648
+ }
649
+ return parseObject(r.stdout, "describe-change-set");
56
650
  }
57
651
 
652
+ async function changeSetDiscard(p: {
653
+ stackName: string;
654
+ changeSetId: string;
655
+ }): Promise<ReturnType<typeof jsonResult>> {
656
+ const bad = discardProblem(p.changeSetId);
657
+ if (bad !== undefined) throw new Error(bad);
658
+ const d = await describeChangeSetOrMissing(p.changeSetId);
659
+ if (d !== undefined && str(d["StackName"]) !== p.stackName) {
660
+ throw new Error("change set belongs to a different stack");
661
+ }
662
+ // Not found: nothing of ours to remove, so the empty-shell deletion is NOT attempted either. It
663
+ // runs only after THIS call deleted a change set that belongs to the stack. (Bound: between
664
+ // list-change-sets and delete-stack another change set could be added; README.)
665
+ if (d === undefined)
666
+ return jsonResult({ ok: true, changeSetDeleted: false, stackDeleted: false });
667
+ const done = await cleanupChangeSet(p.stackName, p.changeSetId, false);
668
+ return jsonResult({ ok: true, ...done });
669
+ }
670
+
671
+ const PULUMI_UP_REFUSAL =
672
+ "iac_pulumi_up is refused: Pulumi saved update plans are documented as experimental, so no plan-bound apply exists (OR-W34-2)";
673
+
58
674
  /** Tool names exposed by this connector — for contract/introspection tests. */
59
675
  export const IAC_TOOL_NAMES = [
60
676
  "iac_terraform_plan",
677
+ "iac_terraform_destroy_plan",
678
+ "iac_terraform_show_json",
61
679
  "iac_terraform_apply",
62
680
  "iac_terraform_destroy",
681
+ "iac_cloudformation_changeset_create",
682
+ "iac_cloudformation_changeset_discard",
63
683
  "iac_cloudformation_deploy",
684
+ "iac_cloudformation_stack_get",
64
685
  "iac_pulumi_preview",
65
686
  "iac_pulumi_up",
66
687
  ] as const;
@@ -70,110 +691,195 @@ export function registerIacTools(
70
691
  ): void {
71
692
  const reg = createZodToolRegistrar(createRegisterSimpleTool(server));
72
693
 
694
+ const cfg = {
695
+ connector: "iac",
696
+ scopeEnv: "NIMBUS_MCP_IAC_WRITE_SCOPE",
697
+ scopeKinds: ["dir", "stack"],
698
+ } as const;
699
+
73
700
  /**
74
701
  * Every MUTATING iac tool goes through here. Outside the gateway this adds the
75
702
  * consent gate, the write-scope allow-list, the mutation budget and the audit record; inside
76
703
  * the gateway it is a pass-through, because executor.ts (I2) is the gate there.
77
704
  */
78
- const registerWriteTool = createWriteToolRegistrar(server, {
79
- connector: "iac",
80
- scopeEnv: "NIMBUS_MCP_IAC_WRITE_SCOPE",
81
- scopeKinds: ["dir", "stack"],
82
- });
705
+ const registerWriteTool = createWriteToolRegistrar(server, cfg);
706
+ /** Tools the gateway calls before a write's prompt: they change nothing but a scratch area. */
707
+ const registerPrepareTool = createPrepareToolRegistrar(server, cfg);
83
708
 
84
- const processEnv = process.env as Record<string, string | undefined>;
709
+ const dirScope = (p: { workingDirectory: string }): { kind: string; value: string } => ({
710
+ kind: "dir",
711
+ value: p.workingDirectory,
712
+ });
713
+ const stackScope = (p: { stackName: string }): { kind: string; value: string } => ({
714
+ kind: "stack",
715
+ value: p.stackName,
716
+ });
85
717
 
86
- reg(
718
+ registerPrepareTool(
87
719
  "iac_terraform_plan",
88
- "Run terraform plan in a directory.",
720
+ { scopeTargetOf: dirScope },
721
+ "Run terraform plan in a directory and save the plan under NIMBUS_IAC_PLAN_DIR. Returns the plan id, file, its SHA-256 and whether it has changes.",
722
+ z.object({ workingDirectory: cliArg }),
723
+ (p) => terraformPlan(p.workingDirectory, false),
724
+ );
725
+
726
+ registerPrepareTool(
727
+ "iac_terraform_destroy_plan",
728
+ { scopeTargetOf: dirScope },
729
+ "Run terraform plan -destroy in a directory and save the plan under NIMBUS_IAC_PLAN_DIR.",
89
730
  z.object({ workingDirectory: cliArg }),
731
+ (p) => terraformPlan(p.workingDirectory, true),
732
+ );
733
+
734
+ registerPrepareTool(
735
+ "iac_terraform_show_json",
736
+ { scopeTargetOf: dirScope },
737
+ "Render a saved terraform plan file as JSON (terraform show -json).",
738
+ z.object({ workingDirectory: cliArg, planFile: planFileArg }),
90
739
  async (p) => {
91
- await runCliOkThrowing(
92
- ["terraform", `-chdir=${p.workingDirectory}`, "plan", "-input=false"],
93
- {
94
- ...processEnv,
95
- },
740
+ const planFile = await resolvePlanFile(p.planFile, [PLAN_NAME, DESTROY_PLAN_NAME]);
741
+ // Read once, immediately before the show: the hash is of the bytes the owner is shown.
742
+ const planSha256 = sha256Of(await readFile(planFile));
743
+ const r = await runCliCode(
744
+ ["terraform", `-chdir=${p.workingDirectory}`, "show", "-json", "-no-color", planFile],
745
+ terraformEnv(),
96
746
  );
97
- return jsonResult({ ok: true });
747
+ if (r.code !== 0) {
748
+ throw new Error(`terraform exited ${String(r.code)}: ${r.stderr.slice(0, 500)}`);
749
+ }
750
+ if (Buffer.byteLength(r.stdout, "utf8") > PLAN_JSON_MAX_BYTES) {
751
+ throw new Error("plan JSON too large");
752
+ }
753
+ try {
754
+ JSON.parse(r.stdout);
755
+ } catch {
756
+ throw new Error("terraform show -json did not print JSON");
757
+ }
758
+ return jsonResult({ planJson: r.stdout, planSha256 });
98
759
  },
99
760
  );
100
761
 
762
+ const applyShape = {
763
+ workingDirectory: cliArg,
764
+ planFile: planFileArg,
765
+ planSha256: sha256Hex,
766
+ };
767
+ const applyPreState = (p: {
768
+ workingDirectory: string;
769
+ planFile: string;
770
+ planSha256: string;
771
+ }): Promise<Record<string, unknown>> =>
772
+ Promise.resolve({
773
+ workingDirectory: p.workingDirectory,
774
+ planFile: p.planFile,
775
+ planSha256: p.planSha256,
776
+ });
777
+
101
778
  registerWriteTool(
102
779
  "iac_terraform_apply",
103
780
  {
104
781
  mutates: "iac.terraform.apply",
105
- recoverable: true,
106
- scopeTargetOf: (p) => ({ kind: "dir", value: p.workingDirectory }),
107
- },
108
- "Run terraform apply.",
109
- z.object({ workingDirectory: cliArg }),
110
- async (p) => {
111
- await runCliOkThrowing(
112
- ["terraform", `-chdir=${p.workingDirectory}`, "apply", "-auto-approve", "-input=false"],
113
- { ...processEnv },
114
- );
115
- return jsonResult({ ok: true });
782
+ // Applying infrastructure cannot be undone from its own result; the plan's identity is the
783
+ // record.
784
+ recoverable: false,
785
+ capturePreState: applyPreState,
786
+ scopeTargetOf: dirScope,
787
+ refuseBefore: (p) => planFilePathProblem(p.planFile, [PLAN_NAME]),
116
788
  },
789
+ "Apply a saved terraform plan file (plan.tfplan) whose SHA-256 matches the approved one.",
790
+ z.object(applyShape),
791
+ (p) => terraformApplyPlan(p, PLAN_NAME),
117
792
  );
118
793
 
119
794
  registerWriteTool(
120
795
  "iac_terraform_destroy",
121
796
  {
122
797
  mutates: "iac.terraform.destroy",
123
- // Destroying infrastructure cannot be undone from its own result, and there is nothing
124
- // queryable to snapshot, so the identifying parameters ARE the pre-state: a record naming
125
- // WHICH directory was destroyed still beats silence.
126
798
  recoverable: false,
127
- capturePreState: (p) => Promise.resolve({ workingDirectory: p.workingDirectory }),
128
- scopeTargetOf: (p) => ({ kind: "dir", value: p.workingDirectory }),
799
+ capturePreState: applyPreState,
800
+ scopeTargetOf: dirScope,
801
+ refuseBefore: (p) => planFilePathProblem(p.planFile, [DESTROY_PLAN_NAME]),
129
802
  },
130
- "Run terraform destroy.",
131
- z.object({ workingDirectory: cliArg }),
132
- async (p) => {
133
- await runCliOkThrowing(
134
- ["terraform", `-chdir=${p.workingDirectory}`, "destroy", "-auto-approve", "-input=false"],
135
- { ...processEnv },
136
- );
137
- return jsonResult({ ok: true });
803
+ "Apply a saved terraform destroy plan file (destroy.tfplan) whose SHA-256 matches the approved one.",
804
+ z.object(applyShape),
805
+ (p) => terraformApplyPlan(p, DESTROY_PLAN_NAME),
806
+ );
807
+
808
+ registerPrepareTool(
809
+ "iac_cloudformation_changeset_create",
810
+ { scopeTargetOf: stackScope },
811
+ "Create a CloudFormation change set for a template and list its resource changes. Nothing is executed.",
812
+ z.object({
813
+ stackName: awsCliArg,
814
+ templateBody: templateDocument,
815
+ capabilities: capabilitiesSchema,
816
+ }),
817
+ changeSetCreate,
818
+ );
819
+
820
+ registerWriteTool(
821
+ "iac_cloudformation_changeset_discard",
822
+ {
823
+ mutates: "iac.cloudformation.changeset.discard",
824
+ recoverable: false,
825
+ capturePreState: async (p) => {
826
+ const d = await describeChangeSetOrMissing(p.changeSetId);
827
+ return d === undefined
828
+ ? { exists: false }
829
+ : {
830
+ exists: true,
831
+ status: str(d["Status"]) ?? null,
832
+ executionStatus: str(d["ExecutionStatus"]) ?? null,
833
+ };
834
+ },
835
+ scopeTargetOf: stackScope,
836
+ refuseBefore: (p) => discardProblem(p.changeSetId),
138
837
  },
838
+ "Delete a change set made by iac_cloudformation_changeset_create, and the empty stack shell it left.",
839
+ z.object({ stackName: awsCliArg, changeSetId: changeSetArn }),
840
+ changeSetDiscard,
139
841
  );
140
842
 
141
843
  registerWriteTool(
142
844
  "iac_cloudformation_deploy",
143
845
  {
144
846
  mutates: "iac.cloudformation.deploy",
145
- recoverable: true,
146
- scopeTargetOf: (p) => ({ kind: "stack", value: p.stackName }),
847
+ recoverable: false,
848
+ capturePreState: async (p) => {
849
+ const s = await describeStack(p.stackName);
850
+ return s.exists
851
+ ? { exists: true, stackStatus: s.status, lastUpdatedTime: s.lastUpdatedTime ?? null }
852
+ : { exists: false, stackStatus: null, lastUpdatedTime: null };
853
+ },
854
+ scopeTargetOf: stackScope,
855
+ refuseBefore: deployProblem,
147
856
  },
148
- "Deploy a CloudFormation stack via AWS CLI.",
857
+ "Execute a reviewed CloudFormation change set (made by iac_cloudformation_changeset_create for exactly this template).",
149
858
  z.object({
150
859
  stackName: awsCliArg,
151
860
  templateBody: templateDocument,
861
+ capabilities: capabilitiesSchema,
862
+ changeSetId: changeSetArn,
152
863
  }),
864
+ changeSetExecute,
865
+ );
866
+
867
+ reg(
868
+ "iac_cloudformation_stack_get",
869
+ "Describe a CloudFormation stack's status.",
870
+ z.object({ stackName: awsCliArg }),
153
871
  async (p) => {
154
- await withTemplateFile(p.templateBody, (templateFile) =>
155
- runCliOkThrowing(
156
- [
157
- "aws",
158
- "cloudformation",
159
- "deploy",
160
- "--stack-name",
161
- p.stackName,
162
- "--template-file",
163
- templateFile,
164
- "--capabilities",
165
- "CAPABILITY_IAM",
166
- // A deploy of a template that changes nothing has done what was asked, not failed.
167
- "--no-fail-on-empty-changeset",
168
- ],
169
- // The file is UTF-8. Left to itself the CLI reads it in the locale's encoding, and on a
170
- // Windows machine using cp1252 a template holding "Á" fails to decode while one holding
171
- // "é" deploys as "é". AWS CLI v2 reads AWS_CLI_FILE_ENCODING and ignores PYTHONUTF8; v1,
172
- // plain Python, reads PYTHONUTF8 and ignores the other. Both are set.
173
- { ...processEnv, AWS_CLI_FILE_ENCODING: "UTF-8", PYTHONUTF8: "1" },
174
- ),
872
+ const s = await describeStack(p.stackName);
873
+ return jsonResult(
874
+ s.exists
875
+ ? {
876
+ exists: true,
877
+ status: s.status,
878
+ statusReason: s.statusReason ?? null,
879
+ lastUpdatedTime: s.lastUpdatedTime ?? null,
880
+ }
881
+ : { exists: false },
175
882
  );
176
- return jsonResult({ ok: true });
177
883
  },
178
884
  );
179
885
 
@@ -184,7 +890,7 @@ export function registerIacTools(
184
890
  async (p) => {
185
891
  await runCliOkThrowing(
186
892
  ["pulumi", "preview", "--cwd", p.workingDirectory, "--non-interactive"],
187
- { ...processEnv },
893
+ { ...process.env },
188
894
  );
189
895
  return jsonResult({ ok: true });
190
896
  },
@@ -195,16 +901,13 @@ export function registerIacTools(
195
901
  {
196
902
  mutates: "iac.pulumi.up",
197
903
  recoverable: true,
198
- scopeTargetOf: (p) => ({ kind: "dir", value: p.workingDirectory }),
904
+ scopeTargetOf: dirScope,
905
+ refuseBefore: () => PULUMI_UP_REFUSAL,
199
906
  },
200
- "Run pulumi up.",
907
+ "Refused: no plan-bound Pulumi apply exists. Kept registered so the refusal names its reason.",
201
908
  z.object({ workingDirectory: cliArg }),
202
- async (p) => {
203
- await runCliOkThrowing(
204
- ["pulumi", "up", "--yes", "--cwd", p.workingDirectory, "--non-interactive"],
205
- { ...processEnv },
206
- );
207
- return jsonResult({ ok: true });
208
- },
909
+ // Pulumi's saved update plans are experimental, so there is nothing the owner could approve
910
+ // that an apply is then bound to. Refused on every door, spawning nothing.
911
+ () => Promise.reject(new Error(PULUMI_UP_REFUSAL)),
209
912
  );
210
913
  }