@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.
- package/connectors/iac/nimbus.extension.json +1 -1
- package/connectors/iac/src/tools.ts +808 -105
- package/connectors/kubernetes/nimbus.extension.json +1 -1
- package/connectors/kubernetes/src/manifest-scan.ts +602 -0
- package/connectors/kubernetes/src/tools.ts +331 -2
- package/package.json +1 -1
- package/shared/consent-kit.ts +15 -0
- package/shared/prepare-tool-ids.ts +13 -4
- package/shared/private-kubeconfig.ts +10 -9
- package/shared/private-scratch.ts +68 -0
- package/shared/run-cli-json.ts +17 -1
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
1
4
|
import { z } from "zod";
|
|
2
5
|
import { cliArg } from "../../../shared/cli-json-kit.ts";
|
|
3
6
|
import type { ConsentServer } from "../../../shared/consent-kit.ts";
|
|
4
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
createPrepareToolRegistrar,
|
|
9
|
+
createWriteToolRegistrar,
|
|
10
|
+
} from "../../../shared/consent-kit.ts";
|
|
5
11
|
import { isKubectlNotFound, projectDeployment } from "../../../shared/k8s-deployment-view.ts";
|
|
6
12
|
import {
|
|
7
13
|
createRegisterSimpleTool,
|
|
@@ -9,7 +15,9 @@ import {
|
|
|
9
15
|
mcpJsonResult as jsonResult,
|
|
10
16
|
} from "../../../shared/mcp-tool-kit.ts";
|
|
11
17
|
import { withPrivateKubeconfig } from "../../../shared/private-kubeconfig.ts";
|
|
12
|
-
import {
|
|
18
|
+
import { withScratchDir } from "../../../shared/private-scratch.ts";
|
|
19
|
+
import { runCliCode, runCliJsonThrowing, runCliOkThrowing } from "../../../shared/run-cli-json.ts";
|
|
20
|
+
import { type ScannedObject, scanNamespacedManifest } from "./manifest-scan.ts";
|
|
13
21
|
|
|
14
22
|
function kubectlBase(): string[] {
|
|
15
23
|
const args = ["kubectl"];
|
|
@@ -33,6 +41,185 @@ function kubectlJson(rest: string[]): Promise<unknown> {
|
|
|
33
41
|
return withKube((env) => runCliJsonThrowing(cmd, env));
|
|
34
42
|
}
|
|
35
43
|
|
|
44
|
+
/** A manifest may be at most this many UTF-8 bytes (the schema's character cap is a floor of it). */
|
|
45
|
+
const MANIFEST_MAX_BYTES = 262_144;
|
|
46
|
+
/** The diff in a plan reply; the owner approves what the preview shows, so a bigger one is refused. */
|
|
47
|
+
const DIFF_MAX_BYTES = 65_536;
|
|
48
|
+
/** The whole plan reply, which the gateway refuses above this size. */
|
|
49
|
+
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";
|
|
52
|
+
const MISMATCH = "manifest does not match the approved hash";
|
|
53
|
+
|
|
54
|
+
interface ManifestInput {
|
|
55
|
+
readonly namespace: string;
|
|
56
|
+
readonly manifest: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 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.
|
|
63
|
+
*/
|
|
64
|
+
function checkedManifest(p: ManifestInput): {
|
|
65
|
+
bytes: Buffer;
|
|
66
|
+
sha256: string;
|
|
67
|
+
scanned: ScannedObject[];
|
|
68
|
+
} {
|
|
69
|
+
const bytes = Buffer.from(p.manifest, "utf8");
|
|
70
|
+
if (bytes.length > MANIFEST_MAX_BYTES) {
|
|
71
|
+
throw new Error(`manifest is larger than ${String(MANIFEST_MAX_BYTES)} bytes`);
|
|
72
|
+
}
|
|
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 };
|
|
80
|
+
}
|
|
81
|
+
|
|
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)) {
|
|
89
|
+
throw new Error(MISMATCH);
|
|
90
|
+
}
|
|
91
|
+
return checked;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function refusalOf(check: () => unknown): string | undefined {
|
|
95
|
+
try {
|
|
96
|
+
check();
|
|
97
|
+
return undefined;
|
|
98
|
+
} catch (e) {
|
|
99
|
+
return e instanceof Error ? e.message : String(e);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* kubectl's failure as an error. A Secret in the manifest means kubectl's stderr may quote its
|
|
105
|
+
* data (an invalid value is often echoed), so the snippet is withheld whenever one is present.
|
|
106
|
+
*/
|
|
107
|
+
function kubectlFailure(
|
|
108
|
+
label: string,
|
|
109
|
+
code: number,
|
|
110
|
+
stderr: string,
|
|
111
|
+
scanned: readonly ScannedObject[],
|
|
112
|
+
): Error {
|
|
113
|
+
const detail = scanned.some((o) => o.kind === "Secret")
|
|
114
|
+
? "(stderr withheld: the manifest holds a Secret)"
|
|
115
|
+
: stderr.slice(0, 500);
|
|
116
|
+
return new Error(`${label} exited ${String(code)}: ${detail}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** What a plan's `diff` holds in place of kubectl's when the manifest holds a Secret. */
|
|
120
|
+
export const SECRET_DIFF_WITHHELD = "(diff withheld: the manifest holds a Secret)";
|
|
121
|
+
|
|
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
|
+
/** The sorted `<prefix>/<name>` list the scan expects kubectl to report. */
|
|
154
|
+
function expectedNames(scanned: readonly ScannedObject[]): string[] {
|
|
155
|
+
return scanned
|
|
156
|
+
.map((o) => `${RESOURCE_PREFIX[o.kind] ?? o.kind}/${o.name}`)
|
|
157
|
+
.sort(compareCodeUnits);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Defence in depth behind the scanner: what kubectl itself reports (`-o name`) must be, as a
|
|
162
|
+
* multiset, exactly what the scan read. A document kubectl reads differently (a List flattened into
|
|
163
|
+
* its items, a kind the scan mis-paired) shows up here as a different set, before the diff and
|
|
164
|
+
* before the real apply. Names only, so the message is safe to show.
|
|
165
|
+
*/
|
|
166
|
+
function assertSameObjects(
|
|
167
|
+
reported: readonly string[],
|
|
168
|
+
scanned: readonly ScannedObject[],
|
|
169
|
+
when: string,
|
|
170
|
+
): void {
|
|
171
|
+
const have = [...reported].sort(compareCodeUnits);
|
|
172
|
+
const want = expectedNames(scanned);
|
|
173
|
+
if (have.length !== want.length || have.some((n, i) => n !== want[i])) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`kubectl ${when} reported different objects than the manifest scan (kubectl: ${have.join(", ")}; scan: ${want.join(", ")})`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Write the manifest into the call's private scratch directory: exclusive create, owner-only. */
|
|
181
|
+
async function writeManifestFile(dir: string, bytes: Buffer): Promise<string> {
|
|
182
|
+
const file = join(dir, "manifest.yaml");
|
|
183
|
+
await writeFile(file, bytes, { flag: "wx", mode: 0o600 });
|
|
184
|
+
return file;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** `kubectl apply --server-side --dry-run=server -o name`, cross-checked against the scan. */
|
|
188
|
+
async function serverDryRun(
|
|
189
|
+
kube: { readonly KUBECONFIG: string },
|
|
190
|
+
ns: string,
|
|
191
|
+
file: string,
|
|
192
|
+
scanned: readonly ScannedObject[],
|
|
193
|
+
): Promise<string[]> {
|
|
194
|
+
const dry = await runCliCode(
|
|
195
|
+
[
|
|
196
|
+
...kubectlBase(),
|
|
197
|
+
"apply",
|
|
198
|
+
"--server-side",
|
|
199
|
+
"--dry-run=server",
|
|
200
|
+
"--field-manager=nimbus",
|
|
201
|
+
"-n",
|
|
202
|
+
ns,
|
|
203
|
+
"-f",
|
|
204
|
+
file,
|
|
205
|
+
"-o",
|
|
206
|
+
"name",
|
|
207
|
+
],
|
|
208
|
+
kube,
|
|
209
|
+
);
|
|
210
|
+
if (dry.code !== 0) throw kubectlFailure("kubectl", dry.code, dry.stderr, scanned);
|
|
211
|
+
const objects = objectLines(dry.stdout);
|
|
212
|
+
assertSameObjects(objects, scanned, "dry run");
|
|
213
|
+
return objects;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function objectLines(stdout: string): string[] {
|
|
217
|
+
return stdout
|
|
218
|
+
.split("\n")
|
|
219
|
+
.map((l) => l.trim())
|
|
220
|
+
.filter((l) => l !== "");
|
|
221
|
+
}
|
|
222
|
+
|
|
36
223
|
const optionalNamespaceSchema = z.object({ namespace: cliArg.optional() });
|
|
37
224
|
|
|
38
225
|
async function k8sListNamespacedResource(p: { namespace?: string | undefined }, resource: string) {
|
|
@@ -50,6 +237,8 @@ export const KUBERNETES_TOOL_NAMES = [
|
|
|
50
237
|
"k8s_rollout_restart",
|
|
51
238
|
"k8s_pod_delete",
|
|
52
239
|
"k8s_deployment_scale",
|
|
240
|
+
"k8s_manifest_plan",
|
|
241
|
+
"k8s_manifest_apply",
|
|
53
242
|
] as const;
|
|
54
243
|
|
|
55
244
|
export function registerKubernetesTools(
|
|
@@ -68,6 +257,16 @@ export function registerKubernetesTools(
|
|
|
68
257
|
scopeKinds: ["namespace"],
|
|
69
258
|
});
|
|
70
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Prepare tools (shared/prepare-tool-ids.ts) read the state a write is bound to, for the gateway
|
|
262
|
+
* to call BEFORE the write's approval prompt; they are neither reads nor writes to the kit.
|
|
263
|
+
*/
|
|
264
|
+
const registerPrepareTool = createPrepareToolRegistrar(server, {
|
|
265
|
+
connector: "kubernetes",
|
|
266
|
+
scopeEnv: "NIMBUS_MCP_KUBERNETES_WRITE_SCOPE",
|
|
267
|
+
scopeKinds: ["namespace"],
|
|
268
|
+
});
|
|
269
|
+
|
|
71
270
|
reg(
|
|
72
271
|
"k8s_pod_list",
|
|
73
272
|
"List pods in a namespace (default namespace: default).",
|
|
@@ -177,4 +376,134 @@ export function registerKubernetesTools(
|
|
|
177
376
|
return jsonResult({ ok: true });
|
|
178
377
|
},
|
|
179
378
|
);
|
|
379
|
+
|
|
380
|
+
registerPrepareTool(
|
|
381
|
+
"k8s_manifest_plan",
|
|
382
|
+
{ 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) }),
|
|
385
|
+
async (p) => {
|
|
386
|
+
const { bytes, sha256, scanned } = checkedManifest(p);
|
|
387
|
+
const ns = p.namespace;
|
|
388
|
+
const reply = await withScratchDir("nimbus-k8s-", async (dir) => {
|
|
389
|
+
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");
|
|
430
|
+
return {
|
|
431
|
+
planSha256: sha256,
|
|
432
|
+
namespace: ns,
|
|
433
|
+
objects,
|
|
434
|
+
scanned,
|
|
435
|
+
diff: holdsSecret ? SECRET_DIFF_WITHHELD : diff.stdout,
|
|
436
|
+
hasDifferences: diff.code === 1,
|
|
437
|
+
};
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
if (Buffer.byteLength(JSON.stringify(reply), "utf8") > PLAN_REPLY_MAX_BYTES) {
|
|
441
|
+
throw new Error("plan reply too large");
|
|
442
|
+
}
|
|
443
|
+
return jsonResult(reply);
|
|
444
|
+
},
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
registerWriteTool(
|
|
448
|
+
"k8s_manifest_apply",
|
|
449
|
+
{
|
|
450
|
+
mutates: "kubernetes.manifest.apply",
|
|
451
|
+
// An applied manifest is not undone by this tool; the objects it names are all that remain.
|
|
452
|
+
recoverable: false,
|
|
453
|
+
capturePreState: (p) =>
|
|
454
|
+
Promise.resolve({
|
|
455
|
+
namespace: p.namespace,
|
|
456
|
+
planSha256: p.planSha256,
|
|
457
|
+
objects: checkedManifest(p).scanned,
|
|
458
|
+
}),
|
|
459
|
+
scopeTargetOf: (p) => ({ kind: "namespace", value: p.namespace }),
|
|
460
|
+
// 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)),
|
|
463
|
+
},
|
|
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.",
|
|
465
|
+
z.object({
|
|
466
|
+
namespace: cliArg,
|
|
467
|
+
manifest: z.string().min(1).max(MANIFEST_MAX_BYTES),
|
|
468
|
+
planSha256: z.string().regex(/^[0-9a-f]{64}$/),
|
|
469
|
+
}),
|
|
470
|
+
async (p) => {
|
|
471
|
+
const { bytes, scanned } = approvedManifest(p);
|
|
472
|
+
return withScratchDir("nimbus-k8s-", async (dir) => {
|
|
473
|
+
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.
|
|
480
|
+
if (!bytes.equals(await readFile(file))) {
|
|
481
|
+
throw new Error("the manifest file changed before the apply");
|
|
482
|
+
}
|
|
483
|
+
const applied = await runCliCode(
|
|
484
|
+
[
|
|
485
|
+
...kubectlBase(),
|
|
486
|
+
"apply",
|
|
487
|
+
"--server-side",
|
|
488
|
+
"--field-manager=nimbus",
|
|
489
|
+
"-n",
|
|
490
|
+
p.namespace,
|
|
491
|
+
"-f",
|
|
492
|
+
file,
|
|
493
|
+
"-o",
|
|
494
|
+
"name",
|
|
495
|
+
],
|
|
496
|
+
kube,
|
|
497
|
+
);
|
|
498
|
+
if (applied.code !== 0) {
|
|
499
|
+
throw kubectlFailure("kubectl", applied.code, applied.stderr, scanned);
|
|
500
|
+
}
|
|
501
|
+
const objects = objectLines(applied.stdout);
|
|
502
|
+
// After the fact: the objects are already applied, so this is reported, not prevented.
|
|
503
|
+
assertSameObjects(objects, scanned, "applied and");
|
|
504
|
+
return jsonResult({ ok: true, status: "applied", objects });
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
},
|
|
508
|
+
);
|
|
180
509
|
}
|
package/package.json
CHANGED
package/shared/consent-kit.ts
CHANGED
|
@@ -82,6 +82,14 @@ export type WriteToolConfig<T> = {
|
|
|
82
82
|
readonly capturePreState?: (args: T) => Promise<Record<string, unknown>>;
|
|
83
83
|
/** The scope target this invocation would touch, checked against the allow-list. */
|
|
84
84
|
readonly scopeTargetOf: (args: T) => { kind: string; value: string };
|
|
85
|
+
/**
|
|
86
|
+
* A refusal no consent can lift (an input the tool will reject, a tool that is refused outright),
|
|
87
|
+
* as its message, or `undefined` to proceed. Standalone only, and checked FIRST, before scope,
|
|
88
|
+
* budget and prompt: asking a human to approve a call that is certain to fail trains them to
|
|
89
|
+
* click through. It does NOT replace the handler's own check: in gateway mode the raw handler is
|
|
90
|
+
* registered and this is never called, so the handler must refuse the same input itself.
|
|
91
|
+
*/
|
|
92
|
+
readonly refuseBefore?: (args: T) => string | undefined;
|
|
85
93
|
};
|
|
86
94
|
|
|
87
95
|
export type WriteToolRegistrar = <T>(
|
|
@@ -348,6 +356,13 @@ export function createWriteToolRegistrar(
|
|
|
348
356
|
const guarded = async (args: T): Promise<McpListResult> => {
|
|
349
357
|
const target = toolCfg.scopeTargetOf(args);
|
|
350
358
|
|
|
359
|
+
// 0. A refusal no consent can lift comes before everything that could involve a human.
|
|
360
|
+
const early = toolCfg.refuseBefore?.(args);
|
|
361
|
+
if (early !== undefined) {
|
|
362
|
+
await record(name, "refused", { reason: early, target });
|
|
363
|
+
return refused(early);
|
|
364
|
+
}
|
|
365
|
+
|
|
351
366
|
// 1. SCOPE FIRST — before prompting. Asking a human to approve something the operator
|
|
352
367
|
// already forbade cannot change the outcome, and training people to click through
|
|
353
368
|
// prompts that do not matter is how you make the prompts that do matter ineffective.
|
|
@@ -7,11 +7,20 @@
|
|
|
7
7
|
* is here. A naming convention would not do: a suffix rule passes `*_files_get`-style names that
|
|
8
8
|
* the gateway treats as plain reads, and ids of the same class do not share a suffix.
|
|
9
9
|
*
|
|
10
|
-
* Grows per release:
|
|
11
|
-
*
|
|
12
|
-
* to be registered exactly once,
|
|
10
|
+
* Grows per release: 0.8.0 listed the two commit tools; the next release adds the Terraform plan,
|
|
11
|
+
* destroy-plan and show-json tools, the CloudFormation change-set create and the Kubernetes
|
|
12
|
+
* manifest plan. `prepare-tool-ids.test.ts` requires every id here to be registered exactly once,
|
|
13
|
+
* through `createPrepareToolRegistrar` and nothing else.
|
|
13
14
|
*/
|
|
14
|
-
export const PREPARE_TOOL_IDS = [
|
|
15
|
+
export const PREPARE_TOOL_IDS = [
|
|
16
|
+
"github_commit_prepare",
|
|
17
|
+
"gitlab_commit_prepare",
|
|
18
|
+
"iac_terraform_plan",
|
|
19
|
+
"iac_terraform_destroy_plan",
|
|
20
|
+
"iac_terraform_show_json",
|
|
21
|
+
"iac_cloudformation_changeset_create",
|
|
22
|
+
"k8s_manifest_plan",
|
|
23
|
+
] as const;
|
|
15
24
|
|
|
16
25
|
export type PrepareToolId = (typeof PREPARE_TOOL_IDS)[number];
|
|
17
26
|
|
|
@@ -21,10 +21,11 @@
|
|
|
21
21
|
import { constants } from "node:fs";
|
|
22
22
|
import { lstat, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
23
23
|
import { tmpdir } from "node:os";
|
|
24
|
-
import { delimiter,
|
|
24
|
+
import { delimiter, join } from "node:path";
|
|
25
|
+
import { configuredScratchBase } from "./private-scratch.ts";
|
|
25
26
|
|
|
26
27
|
/** Names the directory private kubeconfigs are made in; an absolute path, else `os.tmpdir()`. */
|
|
27
|
-
export
|
|
28
|
+
export { SCRATCH_DIR_ENV } from "./private-scratch.ts";
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* The one path `raw` (a `KUBECONFIG` value) names. A list of paths throws: a tool that cannot say
|
|
@@ -57,14 +58,14 @@ export function singleKubeconfigPath(
|
|
|
57
58
|
return only;
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* The base directory private kubeconfigs are made under: the configured scratch base, else
|
|
63
|
+
* `os.tmpdir()` on every platform. Unlike `withScratchDir` (private-scratch.ts) this does NOT
|
|
64
|
+
* refuse on Windows under the gateway: the before-state reads shipped in 0.8.0 run there today
|
|
65
|
+
* without the env var, and the gateway grants it with the Kubernetes write rows (D3).
|
|
66
|
+
*/
|
|
61
67
|
function scratchBase(): string {
|
|
62
|
-
|
|
63
|
-
if (configured === undefined || configured === "") return tmpdir();
|
|
64
|
-
if (!isAbsolute(configured)) {
|
|
65
|
-
throw new Error(`${SCRATCH_DIR_ENV} must be an absolute path`);
|
|
66
|
-
}
|
|
67
|
-
return configured;
|
|
68
|
+
return configuredScratchBase() ?? tmpdir();
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
/** Refuse a source that is not a plain file: a symlink, a directory, a device. */
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* private-scratch — a private directory for one tool call's files, removed when the call settles.
|
|
3
|
+
*
|
|
4
|
+
* The CLI-backed write tools hand their CLI a file rather than an argument: a CloudFormation
|
|
5
|
+
* template, a Kubernetes manifest. Each call gets its own 0700 directory under the configured
|
|
6
|
+
* scratch base, so concurrent calls never share a file and nothing outlives the call.
|
|
7
|
+
*
|
|
8
|
+
* The base is `NIMBUS_CONNECTOR_SCRATCH_DIR` (absolute) when set. When it is not, `os.tmpdir()`,
|
|
9
|
+
* with one exception: a Windows connector running under the gateway REFUSES. The gateway sandboxes
|
|
10
|
+
* a Windows connector in an AppContainer and grants it a per-action directory through that env
|
|
11
|
+
* var; the user temp directory is not that grant, so a file written there is either refused by
|
|
12
|
+
* the sandbox or, worse, shared with every other process of the user. Failing closed names the
|
|
13
|
+
* missing grant instead.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { isAbsolute, join } from "node:path";
|
|
19
|
+
import { getConnectorMode } from "./connector-mode.ts";
|
|
20
|
+
|
|
21
|
+
/** Names the directory scratch directories are made in; an absolute path. */
|
|
22
|
+
export const SCRATCH_DIR_ENV = "NIMBUS_CONNECTOR_SCRATCH_DIR";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The configured scratch base, or `undefined` when none is configured. Throws on a relative
|
|
26
|
+
* value: a relative base would resolve against whatever the connector's cwd happens to be.
|
|
27
|
+
*/
|
|
28
|
+
export function configuredScratchBase(): string | undefined {
|
|
29
|
+
const configured = process.env[SCRATCH_DIR_ENV]?.trim();
|
|
30
|
+
if (configured === undefined || configured === "") return undefined;
|
|
31
|
+
if (!isAbsolute(configured)) {
|
|
32
|
+
throw new Error(`${SCRATCH_DIR_ENV} must be an absolute path`);
|
|
33
|
+
}
|
|
34
|
+
return configured;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The base a {@link withScratchDir} directory is made under (see the module comment for the
|
|
39
|
+
* Windows-under-the-gateway refusal). `platform` is a parameter so both sides are testable.
|
|
40
|
+
*/
|
|
41
|
+
export function scratchDirBase(platform: NodeJS.Platform = process.platform): string {
|
|
42
|
+
const configured = configuredScratchBase();
|
|
43
|
+
if (configured !== undefined) return configured;
|
|
44
|
+
if (platform === "win32" && getConnectorMode() === "gateway") {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`${SCRATCH_DIR_ENV} is not set: on Windows the gateway must grant this connector a scratch ` +
|
|
47
|
+
"directory, and the user temp directory is not one",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return tmpdir();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Run `body` with a fresh 0700 directory of its own, removed when `body` settles, success or
|
|
55
|
+
* failure. `prefix` names the directory (`nimbus-cfn-`, `nimbus-k8s-`).
|
|
56
|
+
*/
|
|
57
|
+
export async function withScratchDir<T>(
|
|
58
|
+
prefix: string,
|
|
59
|
+
body: (dir: string) => Promise<T>,
|
|
60
|
+
): Promise<T> {
|
|
61
|
+
const base = scratchDirBase();
|
|
62
|
+
const dir = await mkdtemp(join(base, prefix));
|
|
63
|
+
try {
|
|
64
|
+
return await body(dir);
|
|
65
|
+
} finally {
|
|
66
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
67
|
+
}
|
|
68
|
+
}
|
package/shared/run-cli-json.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { nimbusSpawn } from "./nimbus-spawn.ts";
|
|
1
|
+
import { nimbusSpawn, type SpawnResult } from "./nimbus-spawn.ts";
|
|
2
2
|
|
|
3
3
|
export type RunCliJsonResult = { ok: true; data: unknown } | { ok: false; message: string };
|
|
4
4
|
|
|
@@ -75,3 +75,19 @@ export async function runCliJsonThrowing(
|
|
|
75
75
|
}
|
|
76
76
|
return r.data;
|
|
77
77
|
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Spawn `command` and hand back its exit code with its output, for a CLI whose non-zero exits are
|
|
81
|
+
* answers rather than failures (`terraform plan -detailed-exitcode` exits 2 for "changes";
|
|
82
|
+
* `kubectl diff` exits 1 for "differences"). The caller maps every code itself, so an
|
|
83
|
+
* unexpected one is never taken for success. An empty command is code 1, as nimbusSpawn reports.
|
|
84
|
+
*/
|
|
85
|
+
export async function runCliCode(
|
|
86
|
+
command: readonly string[],
|
|
87
|
+
env: Record<string, string | undefined>,
|
|
88
|
+
): Promise<SpawnResult> {
|
|
89
|
+
if (command.length === 0) {
|
|
90
|
+
return { code: 1, stdout: "", stderr: "empty command" };
|
|
91
|
+
}
|
|
92
|
+
return nimbusSpawn(command, env);
|
|
93
|
+
}
|