@nimbus-dev/connectors 0.9.0 → 0.11.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.
@@ -17,28 +17,66 @@ import {
17
17
  import { withPrivateKubeconfig } from "../../../shared/private-kubeconfig.ts";
18
18
  import { withScratchDir } from "../../../shared/private-scratch.ts";
19
19
  import { runCliCode, runCliJsonThrowing, runCliOkThrowing } from "../../../shared/run-cli-json.ts";
20
+ import {
21
+ compareCodeUnits,
22
+ diffSha256,
23
+ planDiff,
24
+ RESOURCE_PREFIX,
25
+ unpackKubeJson,
26
+ } from "./manifest-diff.ts";
20
27
  import { type ScannedObject, scanNamespacedManifest } from "./manifest-scan.ts";
21
28
 
22
- function kubectlBase(): string[] {
23
- const args = ["kubectl"];
24
- const ctx = process.env["KUBE_CONTEXT"]?.trim();
25
- if (ctx !== undefined && ctx !== "") {
26
- args.push("--context", ctx);
29
+ /** `kubectl --context <ctx>`: every call names its context, none relies on the kubeconfig's current one. */
30
+ function kubectlBase(context: string): string[] {
31
+ return ["kubectl", "--context", context];
32
+ }
33
+
34
+ const CONTEXT_MISMATCH = "context does not match the configured context";
35
+
36
+ /**
37
+ * The context every call of this process works against: `KUBE_CONTEXT` if the owner set it, else
38
+ * what `kubectl config current-context` prints for the call's private kubeconfig (never a
39
+ * hand-parse of the file). Fails closed: a kubectl error, no output, or a value that is not a plain
40
+ * argument is an error, so no call ever falls back to some other context.
41
+ */
42
+ async function effectiveContext(kube: { readonly KUBECONFIG: string }): Promise<string> {
43
+ let ctx = process.env["KUBE_CONTEXT"]?.trim();
44
+ if (ctx === undefined || ctx === "") {
45
+ const r = await runCliCode(["kubectl", "config", "current-context"], kube);
46
+ if (r.code !== 0) {
47
+ throw new Error(`could not determine the kubectl context: kubectl exited ${String(r.code)}`);
48
+ }
49
+ ctx = r.stdout.trim();
50
+ if (ctx === "") {
51
+ throw new Error("could not determine the kubectl context: kubectl printed none");
52
+ }
27
53
  }
28
- return args;
54
+ if (!cliArg.safeParse(ctx).success) {
55
+ throw new Error("the configured kubectl context is not a plain value");
56
+ }
57
+ return ctx;
29
58
  }
30
59
 
31
60
  /**
32
- * Run `body` against a private copy of the kubeconfig `KUBECONFIG` names (shared/private-kubeconfig.ts):
33
- * one tool call, one copy, so a call can neither change the owner's file nor race another call.
61
+ * Run `body` on a private kubeconfig copy (shared/private-kubeconfig.ts: one tool call, one copy, so
62
+ * a call can neither change the owner's file nor race another) with the effective context. A
63
+ * `given` context that is not that context throws before `body` runs.
34
64
  */
35
- function withKube<T>(body: (env: { readonly KUBECONFIG: string }) => Promise<T>): Promise<T> {
36
- return withPrivateKubeconfig({ source: process.env["KUBECONFIG"] ?? "" }, body);
65
+ function withKube<T>(
66
+ given: string | undefined,
67
+ body: (kube: { readonly KUBECONFIG: string }, context: string) => Promise<T>,
68
+ ): Promise<T> {
69
+ return withPrivateKubeconfig({ source: process.env["KUBECONFIG"] ?? "" }, async (kube) => {
70
+ const context = await effectiveContext(kube);
71
+ if (given !== undefined && given !== context) throw new Error(CONTEXT_MISMATCH);
72
+ return body(kube, context);
73
+ });
37
74
  }
38
75
 
39
- function kubectlJson(rest: string[]): Promise<unknown> {
40
- const cmd = [...kubectlBase(), ...rest, "-o", "json"];
41
- return withKube((env) => runCliJsonThrowing(cmd, env));
76
+ function kubectlJson(rest: string[], given: string | undefined): Promise<unknown> {
77
+ return withKube(given, (env, context) =>
78
+ runCliJsonThrowing([...kubectlBase(context), ...rest, "-o", "json"], env),
79
+ );
42
80
  }
43
81
 
44
82
  /** A manifest may be at most this many UTF-8 bytes (the schema's character cap is a floor of it). */
@@ -47,9 +85,10 @@ const MANIFEST_MAX_BYTES = 262_144;
47
85
  const DIFF_MAX_BYTES = 65_536;
48
86
  /** The whole plan reply, which the gateway refuses above this size. */
49
87
  const PLAN_REPLY_MAX_BYTES = 262_144;
50
- /** Domain separator of the plan hash: sha256 of this, a newline, the namespace, a newline, the manifest. */
51
- const PLAN_HASH_DOMAIN = "nimbus-k8s-manifest-v1";
88
+ /** Domain separator of the plan hash (see {@link planHash}). */
89
+ const PLAN_HASH_DOMAIN = "nimbus-k8s-manifest-v2";
52
90
  const MISMATCH = "manifest does not match the approved hash";
91
+ const CLUSTER_CHANGED = "the cluster changed since the plan was approved";
53
92
 
54
93
  interface ManifestInput {
55
94
  readonly namespace: string;
@@ -58,37 +97,39 @@ interface ManifestInput {
58
97
 
59
98
  /**
60
99
  * The checks every manifest tool makes before it touches a file or a process, in this order: size
61
- * in UTF-8 bytes, the namespaced-subset scan (manifest-scan.ts), and the hash of the exact bytes
62
- * kubectl will be handed. Throws a fixed message.
100
+ * in UTF-8 bytes, then the namespaced-subset scan (manifest-scan.ts). Throws a fixed message.
63
101
  */
64
- function checkedManifest(p: ManifestInput): {
65
- bytes: Buffer;
66
- sha256: string;
67
- scanned: ScannedObject[];
68
- } {
102
+ function checkedManifest(p: ManifestInput): { bytes: Buffer; scanned: ScannedObject[] } {
69
103
  const bytes = Buffer.from(p.manifest, "utf8");
70
104
  if (bytes.length > MANIFEST_MAX_BYTES) {
71
105
  throw new Error(`manifest is larger than ${String(MANIFEST_MAX_BYTES)} bytes`);
72
106
  }
73
- const scanned = scanNamespacedManifest(p.manifest, p.namespace);
74
- // The hash binds the namespace to the manifest, so an approved plan for one namespace cannot be
75
- // replayed against another. The kube CONTEXT is owner environment (KUBE_CONTEXT), not caller
76
- // input, and is deliberately not part of it.
77
- const hashed = [PLAN_HASH_DOMAIN, p.namespace, p.manifest].join("\n");
78
- const sha256 = createHash("sha256").update(hashed, "utf8").digest("hex");
79
- return { bytes, sha256, scanned };
107
+ return { bytes, scanned: scanNamespacedManifest(p.manifest, p.namespace) };
108
+ }
109
+
110
+ /**
111
+ * The plan hash (v2): sha256 of the domain, the EFFECTIVE context, the namespace and the exact
112
+ * manifest text, newline-separated. The context and namespace are in it, so an approved plan
113
+ * cannot be replayed on another cluster or in another namespace. v1 (no context) is retired.
114
+ */
115
+ export function planHash(context: string, namespace: string, manifest: string): string {
116
+ return createHash("sha256")
117
+ .update([PLAN_HASH_DOMAIN, context, namespace, manifest].join("\n"), "utf8")
118
+ .digest("hex");
119
+ }
120
+
121
+ /** Constant-time equality of two lowercase-hex digests (equal length is checked first). */
122
+ function sameHex(a: string, b: string): boolean {
123
+ const want = Buffer.from(a, "hex");
124
+ const have = Buffer.from(b, "hex");
125
+ return want.length === have.length && timingSafeEqual(want, have);
80
126
  }
81
127
 
82
- /** {@link checkedManifest}, then the apply's own check: the manifest must hash to what was approved. */
83
- function approvedManifest(p: ManifestInput & { readonly planSha256: string }) {
84
- const checked = checkedManifest(p);
85
- const want = Buffer.from(p.planSha256, "hex");
86
- const have = Buffer.from(checked.sha256, "hex");
87
- // Equal length is checked first: timingSafeEqual throws on unequal buffers. The schema pins 64 hex.
88
- if (want.length !== have.length || !timingSafeEqual(want, have)) {
128
+ /** The apply's own check: the manifest must hash, under this context, to what was approved. */
129
+ function assertApprovedHash(context: string, p: ManifestInput & { readonly planSha256: string }) {
130
+ if (!sameHex(p.planSha256, planHash(context, p.namespace, p.manifest))) {
89
131
  throw new Error(MISMATCH);
90
132
  }
91
- return checked;
92
133
  }
93
134
 
94
135
  function refusalOf(check: () => unknown): string | undefined {
@@ -119,37 +160,6 @@ function kubectlFailure(
119
160
  /** What a plan's `diff` holds in place of kubectl's when the manifest holds a Secret. */
120
161
  export const SECRET_DIFF_WITHHELD = "(diff withheld: the manifest holds a Secret)";
121
162
 
122
- /** The `kubectl ... -o name` resource prefix of each allowlisted kind. */
123
- const RESOURCE_PREFIX: Readonly<Record<string, string>> = {
124
- ConfigMap: "configmap",
125
- Secret: "secret",
126
- Service: "service",
127
- Pod: "pod",
128
- ServiceAccount: "serviceaccount",
129
- PersistentVolumeClaim: "persistentvolumeclaim",
130
- Deployment: "deployment.apps",
131
- StatefulSet: "statefulset.apps",
132
- DaemonSet: "daemonset.apps",
133
- ReplicaSet: "replicaset.apps",
134
- Job: "job.batch",
135
- CronJob: "cronjob.batch",
136
- Ingress: "ingress.networking.k8s.io",
137
- NetworkPolicy: "networkpolicy.networking.k8s.io",
138
- Role: "role.rbac.authorization.k8s.io",
139
- RoleBinding: "rolebinding.rbac.authorization.k8s.io",
140
- HorizontalPodAutoscaler: "horizontalpodautoscaler.autoscaling",
141
- PodDisruptionBudget: "poddisruptionbudget.policy",
142
- };
143
-
144
- /**
145
- * Order by UTF-16 code unit, the same on every machine. Not `localeCompare`: collation can tie two
146
- * distinct strings, and these lists are compared element by element.
147
- */
148
- function compareCodeUnits(a: string, b: string): number {
149
- if (a < b) return -1;
150
- return a > b ? 1 : 0;
151
- }
152
-
153
163
  /** The sorted `<prefix>/<name>` list the scan expects kubectl to report. */
154
164
  function expectedNames(scanned: readonly ScannedObject[]): string[] {
155
165
  return scanned
@@ -184,16 +194,42 @@ async function writeManifestFile(dir: string, bytes: Buffer): Promise<string> {
184
194
  return file;
185
195
  }
186
196
 
187
- /** `kubectl apply --server-side --dry-run=server -o name`, cross-checked against the scan. */
188
- async function serverDryRun(
197
+ /** Parse a kubectl `-o json` reply; an unparsable one is an error that never echoes the text (it may hold a Secret). */
198
+ function parseKubeReply(stdout: string, what: string): unknown {
199
+ const trimmed = stdout.trim();
200
+ if (trimmed === "") return null;
201
+ try {
202
+ return JSON.parse(trimmed) as unknown;
203
+ } catch {
204
+ throw new Error(`kubectl ${what} returned invalid JSON`);
205
+ }
206
+ }
207
+
208
+ /** What the plan computes, and what the apply recomputes to compare. */
209
+ interface Computed {
210
+ readonly objects: string[];
211
+ /** The diff as the reply shows it (withheld when the manifest holds a Secret). */
212
+ readonly diff: string;
213
+ readonly hasDifferences: boolean;
214
+ readonly diffSha256: string;
215
+ }
216
+
217
+ /**
218
+ * The plan: a server-side dry run (`-o json`, the objects as the API server would admit them,
219
+ * cross-checked against the scan), one `kubectl get` of the same objects as they are now
220
+ * (`--ignore-not-found`), and the built-in diff of the two (manifest-diff.ts). Changes nothing.
221
+ */
222
+ async function computePlan(
189
223
  kube: { readonly KUBECONFIG: string },
224
+ context: string,
190
225
  ns: string,
191
226
  file: string,
192
227
  scanned: readonly ScannedObject[],
193
- ): Promise<string[]> {
228
+ ): Promise<Computed> {
229
+ const base = kubectlBase(context);
194
230
  const dry = await runCliCode(
195
231
  [
196
- ...kubectlBase(),
232
+ ...base,
197
233
  "apply",
198
234
  "--server-side",
199
235
  "--dry-run=server",
@@ -203,14 +239,43 @@ async function serverDryRun(
203
239
  "-f",
204
240
  file,
205
241
  "-o",
206
- "name",
242
+ "json",
207
243
  ],
208
244
  kube,
209
245
  );
210
246
  if (dry.code !== 0) throw kubectlFailure("kubectl", dry.code, dry.stderr, scanned);
211
- const objects = objectLines(dry.stdout);
247
+ const planned = unpackKubeJson(parseKubeReply(dry.stdout, "dry run"));
248
+ const objects = [...planned.keys()];
212
249
  assertSameObjects(objects, scanned, "dry run");
213
- return objects;
250
+
251
+ const names = [...new Set(expectedNames(scanned))];
252
+ const get = await runCliCode(
253
+ [...base, "get", ...names, "-n", ns, "-o", "json", "--ignore-not-found"],
254
+ kube,
255
+ );
256
+ if (get.code !== 0) throw kubectlFailure("kubectl get", get.code, get.stderr, scanned);
257
+ const live = unpackKubeJson(parseKubeReply(get.stdout, "get"));
258
+
259
+ const full = planDiff(planned, live);
260
+ if (Buffer.byteLength(full, "utf8") > DIFF_MAX_BYTES) {
261
+ throw new Error(`diff too large (over ${String(DIFF_MAX_BYTES)} bytes)`);
262
+ }
263
+ // A Secret's plaintext can sit in either side of the diff, so with a Secret in the manifest its
264
+ // part of the diff is withheld: the reply's diff is a fixed marker line, then the diff of every
265
+ // object that is NOT a Secret. The hash covers that text, so the apply's re-diff still sees a
266
+ // change to any other object (stated bound: it cannot see a change to a Secret).
267
+ const holdsSecret = scanned.some((o) => o.kind === "Secret");
268
+ const diff = holdsSecret
269
+ ? `${SECRET_DIFF_WITHHELD}
270
+ ${planDiff(withoutSecrets(planned), withoutSecrets(live))}`
271
+ : full;
272
+ return { objects, diff, hasDifferences: full !== "", diffSha256: diffSha256(diff) };
273
+ }
274
+
275
+ /** `objects` without its Secrets (keyed `<RESOURCE_PREFIX.Secret>/<name>`). */
276
+ function withoutSecrets<V>(objects: ReadonlyMap<string, V>): Map<string, V> {
277
+ const secret = `${RESOURCE_PREFIX["Secret"] ?? "secret"}/`;
278
+ return new Map([...objects].filter(([key]) => !key.startsWith(secret)));
214
279
  }
215
280
 
216
281
  function objectLines(stdout: string): string[] {
@@ -220,11 +285,14 @@ function objectLines(stdout: string): string[] {
220
285
  .filter((l) => l !== "");
221
286
  }
222
287
 
223
- const optionalNamespaceSchema = z.object({ namespace: cliArg.optional() });
288
+ /** Reads: `context` is optional; when given it must be the effective one. */
289
+ const readSchema = z.object({ context: cliArg.optional(), namespace: cliArg.optional() });
224
290
 
225
- async function k8sListNamespacedResource(p: { namespace?: string | undefined }, resource: string) {
226
- const ns = p.namespace ?? "default";
227
- const data = await kubectlJson(["get", resource, "-n", ns]);
291
+ async function k8sListNamespacedResource(
292
+ p: { context?: string | undefined; namespace?: string | undefined },
293
+ resource: string,
294
+ ) {
295
+ const data = await kubectlJson(["get", resource, "-n", p.namespace ?? "default"], p.context);
228
296
  return jsonResult(data ?? {});
229
297
  }
230
298
 
@@ -269,21 +337,22 @@ export function registerKubernetesTools(
269
337
 
270
338
  reg(
271
339
  "k8s_pod_list",
272
- "List pods in a namespace (default namespace: default).",
273
- optionalNamespaceSchema,
340
+ "List pods in a namespace (default namespace: default). `context` is optional; when given it must be the configured context (`KUBE_CONTEXT`, else the kubeconfig's current one).",
341
+ readSchema,
274
342
  (p) => k8sListNamespacedResource(p, "pods"),
275
343
  );
276
344
 
277
345
  reg(
278
346
  "k8s_deployment_get",
279
- "Get one deployment's state: {name, namespace, replicas, readyReplicas, generation, observedGeneration, restartedAt, images}. The before-state of a restart or a scale.",
280
- z.object({ namespace: cliArg.optional(), name: cliArg }),
347
+ "Get one deployment's state: {name, namespace, replicas, readyReplicas, generation, observedGeneration, restartedAt, images}. The before-state of a restart or a scale. `context` is optional; when given it must be the configured context.",
348
+ z.object({ context: cliArg.optional(), namespace: cliArg.optional(), name: cliArg }),
281
349
  async (p) => {
282
350
  let data: unknown;
283
351
  try {
284
- // kubectlJson builds on kubectlBase(), so the configured KUBE_CONTEXT applies as it does
285
- // to every other call here.
286
- data = await kubectlJson(["get", "deployment", p.name, "-n", p.namespace ?? "default"]);
352
+ data = await kubectlJson(
353
+ ["get", "deployment", p.name, "-n", p.namespace ?? "default"],
354
+ p.context,
355
+ );
287
356
  } catch (e) {
288
357
  // Exit code of a missing object is not documented, so NotFound is read from the message.
289
358
  if (e instanceof Error && isKubectlNotFound(e.message)) {
@@ -295,12 +364,18 @@ export function registerKubernetesTools(
295
364
  },
296
365
  );
297
366
 
298
- reg("k8s_deployment_list", "List deployments in a namespace.", optionalNamespaceSchema, (p) =>
299
- k8sListNamespacedResource(p, "deployments"),
367
+ reg(
368
+ "k8s_deployment_list",
369
+ "List deployments in a namespace. `context` is optional; when given it must be the configured context.",
370
+ readSchema,
371
+ (p) => k8sListNamespacedResource(p, "deployments"),
300
372
  );
301
373
 
302
- reg("k8s_event_list", "List events in a namespace.", optionalNamespaceSchema, (p) =>
303
- k8sListNamespacedResource(p, "events"),
374
+ reg(
375
+ "k8s_event_list",
376
+ "List events in a namespace. `context` is optional; when given it must be the configured context.",
377
+ readSchema,
378
+ (p) => k8sListNamespacedResource(p, "events"),
304
379
  );
305
380
 
306
381
  registerWriteTool(
@@ -308,18 +383,30 @@ export function registerKubernetesTools(
308
383
  {
309
384
  mutates: "kubernetes.rollout.restart",
310
385
  recoverable: true,
311
- scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace ?? "default" }),
386
+ scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace }),
312
387
  },
313
- "Restart a rollout (e.g. deployment).",
388
+ "Restart a rollout (e.g. deployment). `context` and `namespace` are required; `context` must be the configured context (`KUBE_CONTEXT`, else the kubeconfig's current one).",
314
389
  z.object({
315
- namespace: cliArg.optional(),
390
+ context: cliArg,
391
+ namespace: cliArg,
316
392
  resourceType: cliArg,
317
393
  name: cliArg,
318
394
  }),
319
395
  async (p) => {
320
- const ns = p.namespace ?? "default";
321
- const cmd = [...kubectlBase(), "rollout", "restart", p.resourceType, p.name, "-n", ns];
322
- await withKube((env) => runCliOkThrowing(cmd, env));
396
+ await withKube(p.context, (env, context) =>
397
+ runCliOkThrowing(
398
+ [
399
+ ...kubectlBase(context),
400
+ "rollout",
401
+ "restart",
402
+ p.resourceType,
403
+ p.name,
404
+ "-n",
405
+ p.namespace,
406
+ ],
407
+ env,
408
+ ),
409
+ );
323
410
  return jsonResult({ ok: true });
324
411
  },
325
412
  );
@@ -328,22 +415,25 @@ export function registerKubernetesTools(
328
415
  "k8s_pod_delete",
329
416
  {
330
417
  mutates: "kubernetes.pod.delete",
331
- // A deleted pod is not restorable; its identity is all that remains to record — including
332
- // the namespace the delete defaulted to, without which the record cannot say where it was.
418
+ // A deleted pod is not restorable; its identity is all that remains to record.
333
419
  recoverable: false,
334
420
  capturePreState: (p) =>
335
- Promise.resolve({ namespace: p.namespace ?? "default", podName: p.podName }),
336
- scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace ?? "default" }),
421
+ Promise.resolve({ context: p.context, namespace: p.namespace, podName: p.podName }),
422
+ scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace }),
337
423
  },
338
- "Delete a pod.",
424
+ "Delete a pod. `context` and `namespace` are required; `context` must be the configured context.",
339
425
  z.object({
340
- namespace: cliArg.optional(),
426
+ context: cliArg,
427
+ namespace: cliArg,
341
428
  podName: cliArg,
342
429
  }),
343
430
  async (p) => {
344
- const ns = p.namespace ?? "default";
345
- const cmd = [...kubectlBase(), "delete", "pod", p.podName, "-n", ns];
346
- await withKube((env) => runCliOkThrowing(cmd, env));
431
+ await withKube(p.context, (env, context) =>
432
+ runCliOkThrowing(
433
+ [...kubectlBase(context), "delete", "pod", p.podName, "-n", p.namespace],
434
+ env,
435
+ ),
436
+ );
347
437
  return jsonResult({ ok: true });
348
438
  },
349
439
  );
@@ -353,26 +443,30 @@ export function registerKubernetesTools(
353
443
  {
354
444
  mutates: "kubernetes.deployment.scale",
355
445
  recoverable: true,
356
- scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace ?? "default" }),
446
+ scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace }),
357
447
  },
358
- "Scale a deployment.",
448
+ "Scale a deployment. `context` and `namespace` are required; `context` must be the configured context.",
359
449
  z.object({
360
- namespace: cliArg.optional(),
450
+ context: cliArg,
451
+ namespace: cliArg,
361
452
  deploymentName: cliArg,
362
453
  replicas: z.number().int().min(0),
363
454
  }),
364
455
  async (p) => {
365
- const ns = p.namespace ?? "default";
366
- const cmd = [
367
- ...kubectlBase(),
368
- "scale",
369
- "deployment",
370
- p.deploymentName,
371
- `-n`,
372
- ns,
373
- `--replicas=${String(p.replicas)}`,
374
- ];
375
- await withKube((env) => runCliOkThrowing(cmd, env));
456
+ await withKube(p.context, (env, context) =>
457
+ runCliOkThrowing(
458
+ [
459
+ ...kubectlBase(context),
460
+ "scale",
461
+ "deployment",
462
+ p.deploymentName,
463
+ "-n",
464
+ p.namespace,
465
+ `--replicas=${String(p.replicas)}`,
466
+ ],
467
+ env,
468
+ ),
469
+ );
376
470
  return jsonResult({ ok: true });
377
471
  },
378
472
  );
@@ -380,60 +474,26 @@ export function registerKubernetesTools(
380
474
  registerPrepareTool(
381
475
  "k8s_manifest_plan",
382
476
  { scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace }) },
383
- "Plan a k8s_manifest_apply: scan the manifest (namespaced kinds from an allowlist only, in the given namespace), then ask the API server for a server-side dry run and a server-side diff. Returns {planSha256, namespace, objects, scanned, diff, hasDifferences}; the apply must be given the same namespace, manifest and planSha256. Changes nothing.",
384
- z.object({ namespace: cliArg, manifest: z.string().min(1).max(MANIFEST_MAX_BYTES) }),
477
+ "Plan a k8s_manifest_apply: scan the manifest (namespaced kinds from an allowlist only, in the given namespace), ask the API server for a server-side dry run, read the same objects as they are now, and diff the two in this connector (no `diff` program). Returns {planSha256, diffSha256, namespace, objects, scanned, diff, hasDifferences}; the apply must be given the same context, namespace, manifest, planSha256 and diffSha256. `context` must be the configured context (`KUBE_CONTEXT`, else the kubeconfig's current one) and is part of planSha256. Changes nothing.",
478
+ z.object({
479
+ context: cliArg,
480
+ namespace: cliArg,
481
+ manifest: z.string().min(1).max(MANIFEST_MAX_BYTES),
482
+ }),
385
483
  async (p) => {
386
- const { bytes, sha256, scanned } = checkedManifest(p);
387
- const ns = p.namespace;
484
+ const { bytes, scanned } = checkedManifest(p);
388
485
  const reply = await withScratchDir("nimbus-k8s-", async (dir) => {
389
486
  const file = await writeManifestFile(dir, bytes);
390
- return withKube(async (kube) => {
391
- // The dry run: the API server validates and admits the manifest and names each object,
392
- // and the names must be the ones the scan read (see assertSameObjects).
393
- const objects = await serverDryRun(kube, ns, file, scanned);
394
-
395
- // kubectl diff runs the program KUBECTL_EXTERNAL_DIFF names (default `diff -u -N`). The
396
- // variable is removed from the child whatever the owner's shell sets, so the diff is
397
- // never produced by an arbitrary program. TMPDIR/TMP/TEMP point kubectl's own temp files
398
- // (Go reads TMP and TEMP on Windows) into this call's private directory. Exit codes:
399
- // https://kubernetes.io/docs/reference/kubectl/generated/kubectl_diff/ (0 no
400
- // differences, 1 differences, >1 error). --force-conflicts is never passed.
401
- const diff = await runCliCode(
402
- [
403
- ...kubectlBase(),
404
- "diff",
405
- "--server-side",
406
- "--field-manager=nimbus",
407
- "-n",
408
- ns,
409
- "-f",
410
- file,
411
- ],
412
- {
413
- KUBECONFIG: kube.KUBECONFIG,
414
- KUBECTL_EXTERNAL_DIFF: undefined,
415
- TMPDIR: dir,
416
- TMP: dir,
417
- TEMP: dir,
418
- },
419
- );
420
- if (diff.code !== 0 && diff.code !== 1) {
421
- throw kubectlFailure("kubectl diff", diff.code, diff.stderr, scanned);
422
- }
423
- if (Buffer.byteLength(diff.stdout, "utf8") > DIFF_MAX_BYTES) {
424
- throw new Error(`diff too large (over ${String(DIFF_MAX_BYTES)} bytes)`);
425
- }
426
- // kubectl masks a Secret's `data`, but the diff's context lines can still carry its
427
- // plaintext (a `last-applied-configuration` annotation, `stringData`), so with a Secret
428
- // in the manifest only the fact of a difference is returned, never the body.
429
- const holdsSecret = scanned.some((o) => o.kind === "Secret");
487
+ return withKube(p.context, async (kube, context) => {
488
+ const plan = await computePlan(kube, context, p.namespace, file, scanned);
430
489
  return {
431
- planSha256: sha256,
432
- namespace: ns,
433
- objects,
490
+ planSha256: planHash(context, p.namespace, p.manifest),
491
+ diffSha256: plan.diffSha256,
492
+ namespace: p.namespace,
493
+ objects: plan.objects,
434
494
  scanned,
435
- diff: holdsSecret ? SECRET_DIFF_WITHHELD : diff.stdout,
436
- hasDifferences: diff.code === 1,
495
+ diff: plan.diff,
496
+ hasDifferences: plan.hasDifferences,
437
497
  };
438
498
  });
439
499
  });
@@ -452,37 +512,57 @@ export function registerKubernetesTools(
452
512
  recoverable: false,
453
513
  capturePreState: (p) =>
454
514
  Promise.resolve({
515
+ context: p.context,
455
516
  namespace: p.namespace,
456
517
  planSha256: p.planSha256,
518
+ diffSha256: p.diffSha256,
457
519
  objects: checkedManifest(p).scanned,
458
520
  }),
459
521
  scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace }),
460
522
  // Standalone only: a manifest no consent could make safe is refused before any prompt. The
461
- // handler repeats the same checks, because gateway mode registers the raw handler.
462
- refuseBefore: (p) => refusalOf(() => approvedManifest(p)),
523
+ // handler repeats the same checks, because gateway mode registers the raw handler. The hash
524
+ // needs the effective context, which may take a kubectl call, so it is checked here only when
525
+ // KUBE_CONTEXT names it; the handler always checks it.
526
+ refuseBefore: (p) =>
527
+ refusalOf(() => {
528
+ checkedManifest(p);
529
+ const configured = process.env["KUBE_CONTEXT"]?.trim();
530
+ if (configured === undefined || configured === "") return;
531
+ if (p.context !== configured) throw new Error(CONTEXT_MISMATCH);
532
+ assertApprovedHash(configured, p);
533
+ }),
463
534
  },
464
- "Apply a manifest with a server-side apply, in the given namespace. The namespace and manifest must be the ones k8s_manifest_plan planned (planSha256) and the manifest must hold namespaced objects of allowlisted kinds only. Runs a server-side dry run first and applies only if kubectl reports the objects the scan read.",
535
+ "Apply a manifest with a server-side apply, in the given context and namespace. The context, namespace and manifest must be the ones k8s_manifest_plan planned (planSha256), and diffSha256 must be the plan's: the apply first recomputes the plan against the cluster and applies only if the diff is the one that was approved, else it throws 'the cluster changed since the plan was approved' and applies nothing. The manifest must hold namespaced objects of allowlisted kinds only. `context` must be the configured context.",
465
536
  z.object({
537
+ context: cliArg,
466
538
  namespace: cliArg,
467
539
  manifest: z.string().min(1).max(MANIFEST_MAX_BYTES),
468
540
  planSha256: z.string().regex(/^[0-9a-f]{64}$/),
541
+ diffSha256: z.string().regex(/^[0-9a-f]{64}$/),
469
542
  }),
470
543
  async (p) => {
471
- const { bytes, scanned } = approvedManifest(p);
544
+ const { bytes, scanned } = checkedManifest(p);
472
545
  return withScratchDir("nimbus-k8s-", async (dir) => {
473
546
  const file = await writeManifestFile(dir, bytes);
474
- return withKube(async (kube) => {
475
- // Dry run and cross-check FIRST, so an object kubectl would apply that the scan did not
476
- // read stops the call before anything changes.
477
- await serverDryRun(kube, p.namespace, file, scanned);
478
- // The file the real apply reads must still hold the approved bytes: the dry run was a
479
- // child process with the same file, and on Windows the 0600 mode is not enforced.
547
+ return withKube(p.context, async (kube, context) => {
548
+ assertApprovedHash(context, p);
549
+ // The re-diff: exactly the plan's dry run, get and diff, again, now. Any failure of it, or
550
+ // a diff that is not the approved one, means the cluster is not what the owner saw.
551
+ let again: Computed;
552
+ try {
553
+ again = await computePlan(kube, context, p.namespace, file, scanned);
554
+ } catch (e) {
555
+ throw new Error(CLUSTER_CHANGED, { cause: e });
556
+ }
557
+ if (!sameHex(again.diffSha256, p.diffSha256)) throw new Error(CLUSTER_CHANGED);
558
+ // The file the real apply reads must still hold the approved bytes: the re-diff ran
559
+ // child processes on the same file, and on Windows the 0600 mode is not enforced.
480
560
  if (!bytes.equals(await readFile(file))) {
481
561
  throw new Error("the manifest file changed before the apply");
482
562
  }
483
563
  const applied = await runCliCode(
484
564
  [
485
- ...kubectlBase(),
565
+ ...kubectlBase(context),
486
566
  "apply",
487
567
  "--server-side",
488
568
  "--field-manager=nimbus",
@@ -2,14 +2,14 @@
2
2
  "id": "com.nimbus.netlify",
3
3
  "displayName": "Netlify",
4
4
  "version": "0.1.0",
5
- "description": "Netlify site connector (read-only). Surfaces site id, name, the production URL + SSL URL, the admin dashboard URL, the linked git repo + branch, and the embedded published-deploy status (state / branch / commit ref / commit URL / deploy preview URL) into the local index as `netlify:site` items — useful for answering \"is the latest deploy live?\" and \"which site shipped this commit?\". Bearer-token (PAT) auth. The SaaS host (api.netlify.com) is fixed. Per-deploy history, forms, functions, env vars, and DNS are deferred follow-ups.",
5
+ "description": "Netlify site connector. Surfaces site id, name, the production URL + SSL URL, the admin dashboard URL, the linked git repo + branch, and the embedded published-deploy status (state / branch / commit ref / commit URL / deploy preview URL) into the local index as `netlify:site` items — useful for answering \"is the latest deploy live?\" and \"which site shipped this commit?\". Bearer-token (PAT) auth. The SaaS host (api.netlify.com) is fixed. Write tool (needs owner approval): `netlify.build.trigger`; a build with no branch is a PRODUCTION deploy. Forms, functions, env vars, and DNS are deferred follow-ups.",
6
6
  "author": "Nimbus",
7
7
  "entrypoint": "dist/server.js",
8
8
  "runtime": "bun",
9
9
  "permissions": {
10
10
  "network": ["api.netlify.com"]
11
11
  },
12
- "hitlRequired": [],
12
+ "hitlRequired": ["write"],
13
13
  "syncInterval": 600,
14
14
  "minNimbusVersion": "0.2.0"
15
15
  }
@@ -1,6 +1,14 @@
1
- import { runReadOnlyMcpConnector } from "../../../shared/run-read-only-mcp-connector.ts";
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { CONSENT_SERVER_CAPABILITIES } from "../../../shared/consent-kit.ts";
2
4
  import { registerNetlifyTools } from "./tools.ts";
3
5
 
4
- await runReadOnlyMcpConnector("nimbus-netlify", (reg) => {
5
- registerNetlifyTools(reg);
6
- });
6
+ const server = new McpServer(
7
+ { name: "nimbus-netlify", version: "0.1.0" },
8
+ { capabilities: CONSENT_SERVER_CAPABILITIES },
9
+ );
10
+
11
+ registerNetlifyTools(server);
12
+
13
+ const transport = new StdioServerTransport();
14
+ await server.connect(transport);