@nimbus-dev/connectors 0.7.0 → 0.8.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/apple/src/adapters.ts +56 -1
- package/connectors/apple/src/caldav-core.ts +7 -0
- package/connectors/apple/src/calendar-tools.ts +34 -0
- package/connectors/apple/src/tools.ts +2 -1
- package/connectors/aws/src/tools.ts +110 -0
- package/connectors/azure/src/tools.ts +70 -0
- package/connectors/circleci/src/tools.ts +63 -1
- package/connectors/gcp/src/tools.ts +74 -16
- package/connectors/github/src/tools.ts +298 -6
- package/connectors/gitlab/src/gitlab-files.ts +213 -0
- package/connectors/gitlab/src/tools.ts +226 -0
- package/connectors/kubernetes/src/tools.ts +35 -15
- package/connectors/snowflake/src/server.ts +69 -2
- package/package.json +1 -1
- package/shared/audit-chain.ts +5 -2
- package/shared/commit-files.ts +138 -0
- package/shared/consent-kit.ts +141 -31
- package/shared/github-files.ts +195 -0
- package/shared/github-rest.ts +12 -0
- package/shared/k8s-deployment-view.ts +68 -0
- package/shared/prepare-tool-ids.ts +21 -0
- package/shared/private-kubeconfig.ts +110 -0
package/shared/consent-kit.ts
CHANGED
|
@@ -2,8 +2,9 @@ import type { McpListResult, ZodObjectSchema } from "@nimbus-dev/sdk/connector-k
|
|
|
2
2
|
|
|
3
3
|
import { type AuditOutcome, appendAuditEntry } from "./audit-chain.ts";
|
|
4
4
|
import { getConnectorMode } from "./connector-mode.ts";
|
|
5
|
+
import { isPrepareToolId } from "./prepare-tool-ids.ts";
|
|
5
6
|
import { createWriteBudget, parseWriteBudget, WRITE_BUDGET_ENV } from "./write-budget.ts";
|
|
6
|
-
import { parseWriteScope, scopeAllows } from "./write-scope.ts";
|
|
7
|
+
import { parseWriteScope, type ScopeTerm, scopeAllows } from "./write-scope.ts";
|
|
7
8
|
|
|
8
9
|
/** The subset of the SDK's `RegisteredTool` this kit needs. */
|
|
9
10
|
export type ToolHandle = { disable(): void };
|
|
@@ -158,40 +159,35 @@ function registerOn<T>(
|
|
|
158
159
|
handles.push(handle);
|
|
159
160
|
}
|
|
160
161
|
|
|
162
|
+
/** What both registrars share: the parsed write scope and the audit/log record. */
|
|
163
|
+
type ConsentContext = {
|
|
164
|
+
readonly scope: readonly ScopeTerm[];
|
|
165
|
+
readonly record: (
|
|
166
|
+
tool: string,
|
|
167
|
+
outcome: AuditOutcome,
|
|
168
|
+
detail: Record<string, unknown>,
|
|
169
|
+
) => Promise<void>;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
type RegistrarConfig = {
|
|
173
|
+
readonly connector: string;
|
|
174
|
+
readonly scopeEnv: string;
|
|
175
|
+
/**
|
|
176
|
+
* Scope kinds this connector accepts. Declared once per connector rather than per tool: the
|
|
177
|
+
* allow-list is parsed at registrar construction, before any tool has registered, so a
|
|
178
|
+
* per-tool declaration would arrive too late to validate the env value.
|
|
179
|
+
*/
|
|
180
|
+
readonly scopeKinds: readonly string[];
|
|
181
|
+
};
|
|
182
|
+
|
|
161
183
|
/**
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
* there, client capabilities are irrelevant, and the connector adds nothing.
|
|
166
|
-
*
|
|
167
|
-
* In `"standalone"` mode registration is QUEUED and flushed on `oninitialized`, because client
|
|
168
|
-
* capabilities are not knowable at module scope — verified against the real SDK, where
|
|
169
|
-
* `getClientCapabilities()` returns `undefined` before `initialize`. Deciding at registration time
|
|
170
|
-
* would register nothing, for every client, forever.
|
|
184
|
+
* The scope parse and the record function, built once per registrar. Extracted (not copied) so the
|
|
185
|
+
* write registrar and the prepare registrar cannot drift apart on what a scope term means or on
|
|
186
|
+
* which channels a record reaches.
|
|
171
187
|
*/
|
|
172
|
-
|
|
173
|
-
server: ConsentServer,
|
|
174
|
-
cfg: {
|
|
175
|
-
readonly connector: string;
|
|
176
|
-
readonly scopeEnv: string;
|
|
177
|
-
/**
|
|
178
|
-
* Scope kinds this connector accepts. Declared once per connector rather than per tool: the
|
|
179
|
-
* allow-list is parsed at registrar construction, before any tool has registered, so a
|
|
180
|
-
* per-tool declaration would arrive too late to validate the env value.
|
|
181
|
-
*/
|
|
182
|
-
readonly scopeKinds: readonly string[];
|
|
183
|
-
},
|
|
184
|
-
): WriteToolRegistrar {
|
|
185
|
-
const handles: ToolHandle[] = [];
|
|
186
|
-
const pending: Array<() => void> = [];
|
|
187
|
-
let flushed = false;
|
|
188
|
-
|
|
188
|
+
function createConsentContext(server: ConsentServer, cfg: RegistrarConfig): ConsentContext {
|
|
189
189
|
const scope = parseWriteScope(process.env[cfg.scopeEnv], cfg.scopeKinds);
|
|
190
190
|
const auditLog = process.env["NIMBUS_MCP_AUDIT_LOG"];
|
|
191
|
-
// Throws on a value that is not plain digits, an empty one included, as the scope parse above
|
|
192
|
-
// does on a malformed term: read with `Number()`, `abc` became NaN, which `remaining <= 0` never
|
|
193
|
-
// refused.
|
|
194
|
-
const budget = createWriteBudget(parseWriteBudget(process.env[WRITE_BUDGET_ENV]));
|
|
195
191
|
|
|
196
192
|
async function record(
|
|
197
193
|
tool: string,
|
|
@@ -227,6 +223,34 @@ export function createWriteToolRegistrar(
|
|
|
227
223
|
}
|
|
228
224
|
}
|
|
229
225
|
|
|
226
|
+
return { scope, record };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Build a connector's write-tool registrar.
|
|
231
|
+
*
|
|
232
|
+
* In `"gateway"` mode the raw handler is registered immediately: `executor.ts` (I2) is the gate
|
|
233
|
+
* there, client capabilities are irrelevant, and the connector adds nothing.
|
|
234
|
+
*
|
|
235
|
+
* In `"standalone"` mode registration is QUEUED and flushed on `oninitialized`, because client
|
|
236
|
+
* capabilities are not knowable at module scope — verified against the real SDK, where
|
|
237
|
+
* `getClientCapabilities()` returns `undefined` before `initialize`. Deciding at registration time
|
|
238
|
+
* would register nothing, for every client, forever.
|
|
239
|
+
*/
|
|
240
|
+
export function createWriteToolRegistrar(
|
|
241
|
+
server: ConsentServer,
|
|
242
|
+
cfg: RegistrarConfig,
|
|
243
|
+
): WriteToolRegistrar {
|
|
244
|
+
const handles: ToolHandle[] = [];
|
|
245
|
+
const pending: Array<() => void> = [];
|
|
246
|
+
let flushed = false;
|
|
247
|
+
|
|
248
|
+
const { scope, record } = createConsentContext(server, cfg);
|
|
249
|
+
// Throws on a value that is not plain digits, an empty one included, as the scope parse above
|
|
250
|
+
// does on a malformed term: read with `Number()`, `abc` became NaN, which `remaining <= 0` never
|
|
251
|
+
// refused.
|
|
252
|
+
const budget = createWriteBudget(parseWriteBudget(process.env[WRITE_BUDGET_ENV]));
|
|
253
|
+
|
|
230
254
|
/**
|
|
231
255
|
* Take one unit of budget if any is left. Spending the last unit disables every write tool and
|
|
232
256
|
* tells the client.
|
|
@@ -398,3 +422,89 @@ export function createWriteToolRegistrar(
|
|
|
398
422
|
});
|
|
399
423
|
};
|
|
400
424
|
}
|
|
425
|
+
|
|
426
|
+
/** What a prepare tool declares: only the scope target, since it mutates nothing. */
|
|
427
|
+
export type PrepareToolConfig<T> = {
|
|
428
|
+
/** The scope target this invocation reads on behalf of, checked against the allow-list. */
|
|
429
|
+
readonly scopeTargetOf: (args: T) => { kind: string; value: string };
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
export type PrepareToolRegistrar = <T>(
|
|
433
|
+
name: string,
|
|
434
|
+
cfg: PrepareToolConfig<T>,
|
|
435
|
+
description: string,
|
|
436
|
+
schema: ZodObjectSchema<T>,
|
|
437
|
+
handler: (args: T) => Promise<McpListResult>,
|
|
438
|
+
) => void;
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Build a connector's prepare-tool registrar.
|
|
442
|
+
*
|
|
443
|
+
* A prepare tool reads the state a write is bound to (the head a commit is prepared against) and is
|
|
444
|
+
* called by the gateway BEFORE the write's approval prompt. It is not a write: it is registered
|
|
445
|
+
* under its own registrar so the gateway's manifest scan, which follows the write registrar's data
|
|
446
|
+
* flow, does not list it as one. And it is not an ordinary read either: a tool is accepted only when
|
|
447
|
+
* its name is in {@link PREPARE_TOOL_IDS} (shared/prepare-tool-ids.ts), so the class is one literal
|
|
448
|
+
* list and not a naming convention.
|
|
449
|
+
*
|
|
450
|
+
* In `"gateway"` mode the raw handler is registered: the executor's prepare step is the gate there.
|
|
451
|
+
* In `"standalone"` mode it is registered at once for EVERY client (a prepare tool prompts no one,
|
|
452
|
+
* so no elicitation capability is needed), behind the same write scope as the writes it serves, and
|
|
453
|
+
* each in-scope run is audited as `prepared`. It spends no write budget and is never disabled by
|
|
454
|
+
* the budget running out: a second prompt before the apply prompt would only train click-through.
|
|
455
|
+
*/
|
|
456
|
+
export function createPrepareToolRegistrar(
|
|
457
|
+
server: ConsentServer,
|
|
458
|
+
cfg: RegistrarConfig,
|
|
459
|
+
): PrepareToolRegistrar {
|
|
460
|
+
const { scope, record } = createConsentContext(server, cfg);
|
|
461
|
+
// Prepare tools are not budget-gated, so nothing ever disables them; the handles are not kept.
|
|
462
|
+
const handles: ToolHandle[] = [];
|
|
463
|
+
|
|
464
|
+
return <T>(
|
|
465
|
+
name: string,
|
|
466
|
+
toolCfg: PrepareToolConfig<T>,
|
|
467
|
+
description: string,
|
|
468
|
+
schema: ZodObjectSchema<T>,
|
|
469
|
+
handler: (args: T) => Promise<McpListResult>,
|
|
470
|
+
): void => {
|
|
471
|
+
if (!isPrepareToolId(name)) {
|
|
472
|
+
throw new Error(
|
|
473
|
+
`${name}: not a listed prepare tool — add the id to PREPARE_TOOL_IDS in ` +
|
|
474
|
+
"shared/prepare-tool-ids.ts, or register it as a read or a write",
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (getConnectorMode() === "gateway") {
|
|
479
|
+
registerOn(server, name, description, schema, handler, handles);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const guarded = async (args: T): Promise<McpListResult> => {
|
|
484
|
+
const target = toolCfg.scopeTargetOf(args);
|
|
485
|
+
if (!scopeAllows(scope, target.kind, target.value)) {
|
|
486
|
+
await record(name, "refused", { reason: "out of scope", target });
|
|
487
|
+
return refused(`out of scope: ${target.kind}:${target.value} is not in ${cfg.scopeEnv}`);
|
|
488
|
+
}
|
|
489
|
+
let result: McpListResult;
|
|
490
|
+
try {
|
|
491
|
+
result = await handler(args);
|
|
492
|
+
} catch (e) {
|
|
493
|
+
// As a write does: record the failure, and if recording it fails too, the tool's own error
|
|
494
|
+
// still leads, since an audit error alone would read as a prepare that never started.
|
|
495
|
+
try {
|
|
496
|
+
await record(name, "failed", { target, error: messageOf(e) });
|
|
497
|
+
} catch (error_) {
|
|
498
|
+
throw new Error(
|
|
499
|
+
`${name} failed: ${messageOf(e)}. Recording that failure also failed: ${messageOf(error_)}`,
|
|
500
|
+
{ cause: e },
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
throw e;
|
|
504
|
+
}
|
|
505
|
+
await record(name, "prepared", { target });
|
|
506
|
+
return result;
|
|
507
|
+
};
|
|
508
|
+
registerOn(server, name, description, schema, guarded, handles);
|
|
509
|
+
};
|
|
510
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* github-files: read files at a commit through the Git Data API, for the batch read
|
|
3
|
+
* (`github_files_get`), the prepare tool (`github_commit_prepare`) and the commit
|
|
4
|
+
* (`github_file_commit`), so what the owner was shown and what is committed come from one reader.
|
|
5
|
+
*
|
|
6
|
+
* Sources (https://docs.github.com/en/rest/git/{commits,trees,blobs,refs}):
|
|
7
|
+
* - `GET /git/commits/{sha}` returns `tree.sha`.
|
|
8
|
+
* - `GET /git/trees/{sha}` (non-recursive) lists `path, mode, type, sha, size`, with a top-level
|
|
9
|
+
* `truncated` and the advice to "fetch subtrees one at a time": a path is walked one level at a
|
|
10
|
+
* time, and a truncated level is an error, never a guess that the file is absent.
|
|
11
|
+
* - `GET /git/blobs/{sha}` returns base64 `content`.
|
|
12
|
+
* - `PATCH /git/refs/heads/{branch}` with `force` false must be a fast-forward; the documented
|
|
13
|
+
* statuses are 200, 409 and 422.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { decodeBase64Text } from "./commit-files.ts";
|
|
17
|
+
import { ghFetch, ghRepoPath } from "./github-rest.ts";
|
|
18
|
+
|
|
19
|
+
type TreeEntry = { path: string; mode: string; type: string; sha: string; size?: number };
|
|
20
|
+
|
|
21
|
+
export type GithubFileRead =
|
|
22
|
+
| { readonly path: string; readonly exists: false }
|
|
23
|
+
| {
|
|
24
|
+
readonly path: string;
|
|
25
|
+
readonly exists: true;
|
|
26
|
+
readonly kind: "text";
|
|
27
|
+
readonly mode: string;
|
|
28
|
+
readonly sha: string;
|
|
29
|
+
readonly size: number;
|
|
30
|
+
/** Present only when content was asked for. */
|
|
31
|
+
readonly content?: string;
|
|
32
|
+
}
|
|
33
|
+
| {
|
|
34
|
+
readonly path: string;
|
|
35
|
+
readonly exists: true;
|
|
36
|
+
readonly kind: "binary" | "not-regular" | "too-large" | "budget";
|
|
37
|
+
readonly mode: string;
|
|
38
|
+
readonly sha: string;
|
|
39
|
+
readonly size?: number;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function fail(res: { status: number; text: string }): never {
|
|
43
|
+
throw new Error(`GitHub ${String(res.status)}: ${res.text.slice(0, 300)}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function getJson(token: string, path: string): Promise<unknown> {
|
|
47
|
+
const res = await ghFetch(token, path);
|
|
48
|
+
if (!res.ok) fail(res);
|
|
49
|
+
return res.json;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
53
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `tree.sha` of a commit. */
|
|
57
|
+
export async function commitTreeSha(
|
|
58
|
+
token: string,
|
|
59
|
+
owner: string,
|
|
60
|
+
repo: string,
|
|
61
|
+
commitSha: string,
|
|
62
|
+
): Promise<string> {
|
|
63
|
+
const json = await getJson(token, `${ghRepoPath(owner, repo)}/git/commits/${commitSha}`);
|
|
64
|
+
const tree = isRecord(json) && isRecord(json["tree"]) ? json["tree"]["sha"] : undefined;
|
|
65
|
+
if (typeof tree !== "string") throw new Error("GitHub returned no tree for the commit");
|
|
66
|
+
return tree;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseTree(json: unknown): TreeEntry[] {
|
|
70
|
+
if (!isRecord(json)) throw new Error("GitHub returned no tree");
|
|
71
|
+
if (json["truncated"] === true) {
|
|
72
|
+
throw new Error("GitHub truncated a directory listing; refusing to guess a file is absent");
|
|
73
|
+
}
|
|
74
|
+
const entries = Array.isArray(json["tree"]) ? json["tree"] : [];
|
|
75
|
+
const out: TreeEntry[] = [];
|
|
76
|
+
for (const e of entries) {
|
|
77
|
+
if (
|
|
78
|
+
isRecord(e) &&
|
|
79
|
+
typeof e["path"] === "string" &&
|
|
80
|
+
typeof e["mode"] === "string" &&
|
|
81
|
+
typeof e["type"] === "string" &&
|
|
82
|
+
typeof e["sha"] === "string"
|
|
83
|
+
) {
|
|
84
|
+
out.push({
|
|
85
|
+
path: e["path"],
|
|
86
|
+
mode: e["mode"],
|
|
87
|
+
type: e["type"],
|
|
88
|
+
sha: e["sha"],
|
|
89
|
+
...(typeof e["size"] === "number" ? { size: e["size"] } : {}),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The tree entry for `path` under `rootTreeSha`, one directory level at a time; undefined if absent. */
|
|
97
|
+
async function findEntry(
|
|
98
|
+
token: string,
|
|
99
|
+
owner: string,
|
|
100
|
+
repo: string,
|
|
101
|
+
rootTreeSha: string,
|
|
102
|
+
path: string,
|
|
103
|
+
cache: Map<string, TreeEntry[]>,
|
|
104
|
+
): Promise<TreeEntry | undefined> {
|
|
105
|
+
const segments = path.split("/");
|
|
106
|
+
let treeSha = rootTreeSha;
|
|
107
|
+
for (const [i, segment] of segments.entries()) {
|
|
108
|
+
let entries = cache.get(treeSha);
|
|
109
|
+
if (entries === undefined) {
|
|
110
|
+
entries = parseTree(await getJson(token, `${ghRepoPath(owner, repo)}/git/trees/${treeSha}`));
|
|
111
|
+
cache.set(treeSha, entries);
|
|
112
|
+
}
|
|
113
|
+
const entry = entries.find((e) => e.path === segment);
|
|
114
|
+
if (entry === undefined) return undefined;
|
|
115
|
+
if (i === segments.length - 1) return entry;
|
|
116
|
+
if (entry.type !== "tree") {
|
|
117
|
+
// A file sits where a directory is needed: the path cannot exist, and a commit cannot create it.
|
|
118
|
+
throw new Error(`${path}: ${segments.slice(0, i + 1).join("/")} is a file, not a directory`);
|
|
119
|
+
}
|
|
120
|
+
treeSha = entry.sha;
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Read `paths` at `commitSha`, returning the commit's root tree sha beside the files. With `withContent`, each text file's content is fetched (a file over
|
|
127
|
+
* `maxReadBytes` is reported `too-large` without fetching it); without, only the tree entry is read,
|
|
128
|
+
* which is enough for a mode, a blob sha and a size.
|
|
129
|
+
*
|
|
130
|
+
* `totalBudget` caps the content of ALL the files together. The first file that does not fit in what
|
|
131
|
+
* is left, and every file after it, is reported `budget` with its metadata and no content, and its
|
|
132
|
+
* blob is not fetched; the read as a whole is not refused.
|
|
133
|
+
*/
|
|
134
|
+
export async function readGithubFilesAt(
|
|
135
|
+
token: string,
|
|
136
|
+
owner: string,
|
|
137
|
+
repo: string,
|
|
138
|
+
commitSha: string,
|
|
139
|
+
paths: readonly string[],
|
|
140
|
+
opts: {
|
|
141
|
+
readonly withContent: boolean;
|
|
142
|
+
readonly maxReadBytes: number;
|
|
143
|
+
readonly totalBudget?: number;
|
|
144
|
+
},
|
|
145
|
+
): Promise<{ readonly treeSha: string; readonly files: GithubFileRead[] }> {
|
|
146
|
+
const rootTree = await commitTreeSha(token, owner, repo, commitSha);
|
|
147
|
+
const cache = new Map<string, TreeEntry[]>();
|
|
148
|
+
const out: GithubFileRead[] = [];
|
|
149
|
+
let used = 0;
|
|
150
|
+
let exhausted = false;
|
|
151
|
+
for (const path of paths) {
|
|
152
|
+
const entry = await findEntry(token, owner, repo, rootTree, path, cache); // NOSONAR S9382: sequential on purpose; the tree cache makes later paths cheap.
|
|
153
|
+
if (entry === undefined) {
|
|
154
|
+
out.push({ path, exists: false });
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (entry.type !== "blob" || entry.mode === "120000") {
|
|
158
|
+
out.push({ path, exists: true, kind: "not-regular", mode: entry.mode, sha: entry.sha });
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const size = entry.size ?? 0;
|
|
162
|
+
if (size > opts.maxReadBytes) {
|
|
163
|
+
out.push({ path, exists: true, kind: "too-large", mode: entry.mode, sha: entry.sha, size });
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (!opts.withContent) {
|
|
167
|
+
out.push({ path, exists: true, kind: "text", mode: entry.mode, sha: entry.sha, size });
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (opts.totalBudget !== undefined && (exhausted || used + size > opts.totalBudget)) {
|
|
171
|
+
exhausted = true;
|
|
172
|
+
out.push({ path, exists: true, kind: "budget", mode: entry.mode, sha: entry.sha, size });
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
used += size;
|
|
176
|
+
const blob = await getJson(token, `${ghRepoPath(owner, repo)}/git/blobs/${entry.sha}`); // NOSONAR S9382
|
|
177
|
+
const b64 = isRecord(blob) && blob["encoding"] === "base64" ? blob["content"] : undefined;
|
|
178
|
+
if (typeof b64 !== "string") throw new Error(`GitHub returned no base64 content for ${path}`);
|
|
179
|
+
const decoded = decodeBase64Text(b64);
|
|
180
|
+
out.push(
|
|
181
|
+
"binary" in decoded
|
|
182
|
+
? { path, exists: true, kind: "binary", mode: entry.mode, sha: entry.sha, size }
|
|
183
|
+
: {
|
|
184
|
+
path,
|
|
185
|
+
exists: true,
|
|
186
|
+
kind: "text",
|
|
187
|
+
mode: entry.mode,
|
|
188
|
+
sha: entry.sha,
|
|
189
|
+
size,
|
|
190
|
+
content: decoded.text,
|
|
191
|
+
},
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return { treeSha: rootTree, files: out };
|
|
195
|
+
}
|
package/shared/github-rest.ts
CHANGED
|
@@ -172,6 +172,18 @@ function refCandidates(ref: string): { readonly name: string; readonly kind: "br
|
|
|
172
172
|
];
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
/**
|
|
176
|
+
* The names to ask `GET /commits/<name>` for, in order, for a user-supplied `ref`. A full sha is one
|
|
177
|
+
* name; a 7 to 39 hex string is tried as an abbreviated sha first and then as a branch and a tag;
|
|
178
|
+
* anything else is the `heads/` / `tags/` forms of {@link refCandidates}. `ref` must already
|
|
179
|
+
* satisfy {@link isSafeGitRef}.
|
|
180
|
+
*/
|
|
181
|
+
export function commitRefCandidates(ref: string): string[] {
|
|
182
|
+
if (FULL_SHA.test(ref)) return [ref];
|
|
183
|
+
const named = refCandidates(ref).map((c) => c.name);
|
|
184
|
+
return /^[0-9a-f]{7,39}$/i.test(ref) ? [ref, ...named] : named;
|
|
185
|
+
}
|
|
186
|
+
|
|
175
187
|
/**
|
|
176
188
|
* Resolve a branch, tag or commit to a COMMIT sha. `ref` must already satisfy {@link isSafeGitRef}.
|
|
177
189
|
*
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* k8s-deployment-view: the one projection of `kubectl get deployment -o json` that the
|
|
3
|
+
* `kubernetes` and `gcp` before-state reads share, so a rollout restart or a scale is previewed
|
|
4
|
+
* from the same fields whichever connector runs it.
|
|
5
|
+
*
|
|
6
|
+
* Field sources: apps/v1 Deployment (https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/deployment-v1/).
|
|
7
|
+
* `status.readyReplicas` is omitted by the API server when zero (the Go field is `omitempty`), so an
|
|
8
|
+
* absent value is reported as 0; `spec.replicas` is reported as given and is absent when the server
|
|
9
|
+
* did not return one. The restart time is the annotation `kubectl rollout restart` sets.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type DeploymentView = {
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly namespace: string;
|
|
15
|
+
readonly replicas?: number;
|
|
16
|
+
readonly readyReplicas: number;
|
|
17
|
+
readonly generation?: number;
|
|
18
|
+
readonly observedGeneration?: number;
|
|
19
|
+
readonly restartedAt?: string;
|
|
20
|
+
readonly images: readonly string[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const RESTARTED_AT = "kubectl.kubernetes.io/restartedAt";
|
|
24
|
+
|
|
25
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
26
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function num(v: unknown): number | undefined {
|
|
30
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Project a Deployment object; throws when `raw` is not one (no name). */
|
|
34
|
+
export function projectDeployment(raw: unknown): DeploymentView {
|
|
35
|
+
if (!isRecord(raw)) throw new Error("deployment not found");
|
|
36
|
+
const meta = isRecord(raw["metadata"]) ? raw["metadata"] : {};
|
|
37
|
+
const spec = isRecord(raw["spec"]) ? raw["spec"] : {};
|
|
38
|
+
const status = isRecord(raw["status"]) ? raw["status"] : {};
|
|
39
|
+
const name = meta["name"];
|
|
40
|
+
if (typeof name !== "string" || name === "") throw new Error("deployment not found");
|
|
41
|
+
const template = isRecord(spec["template"]) ? spec["template"] : {};
|
|
42
|
+
const tmeta = isRecord(template["metadata"]) ? template["metadata"] : {};
|
|
43
|
+
const annotations = isRecord(tmeta["annotations"]) ? tmeta["annotations"] : {};
|
|
44
|
+
const podSpec = isRecord(template["spec"]) ? template["spec"] : {};
|
|
45
|
+
const containers = Array.isArray(podSpec["containers"]) ? podSpec["containers"] : [];
|
|
46
|
+
const images = containers
|
|
47
|
+
.map((c) => (isRecord(c) && typeof c["image"] === "string" ? c["image"] : undefined))
|
|
48
|
+
.filter((i): i is string => i !== undefined);
|
|
49
|
+
const replicas = num(spec["replicas"]);
|
|
50
|
+
const generation = num(meta["generation"]);
|
|
51
|
+
const observedGeneration = num(status["observedGeneration"]);
|
|
52
|
+
const restartedAt = annotations[RESTARTED_AT];
|
|
53
|
+
return {
|
|
54
|
+
name,
|
|
55
|
+
namespace: typeof meta["namespace"] === "string" ? meta["namespace"] : "",
|
|
56
|
+
...(replicas === undefined ? {} : { replicas }),
|
|
57
|
+
readyReplicas: num(status["readyReplicas"]) ?? 0,
|
|
58
|
+
...(generation === undefined ? {} : { generation }),
|
|
59
|
+
...(observedGeneration === undefined ? {} : { observedGeneration }),
|
|
60
|
+
...(typeof restartedAt === "string" ? { restartedAt } : {}),
|
|
61
|
+
images,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Whether a kubectl failure message says the object does not exist. */
|
|
66
|
+
export function isKubectlNotFound(message: string): boolean {
|
|
67
|
+
return /NotFound|not found/i.test(message);
|
|
68
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The prepare class: tools the gateway calls BEFORE a write's approval prompt to read the state the
|
|
3
|
+
* write is bound to (the branch head a commit was prepared against).
|
|
4
|
+
*
|
|
5
|
+
* This is ONE literal list on purpose. The gateway reads it as text, the way it scans the write
|
|
6
|
+
* manifest, and `createPrepareToolRegistrar` (consent-kit.ts) registers a tool only when its name
|
|
7
|
+
* is here. A naming convention would not do: a suffix rule passes `*_files_get`-style names that
|
|
8
|
+
* the gateway treats as plain reads, and ids of the same class do not share a suffix.
|
|
9
|
+
*
|
|
10
|
+
* Grows per release: this one lists the two commit tools; the IaC, CloudFormation and Kubernetes
|
|
11
|
+
* ones are added by the PR that registers them. `prepare-tool-ids.test.ts` requires every id here
|
|
12
|
+
* to be registered exactly once, through `createPrepareToolRegistrar` and nothing else.
|
|
13
|
+
*/
|
|
14
|
+
export const PREPARE_TOOL_IDS = ["github_commit_prepare", "gitlab_commit_prepare"] as const;
|
|
15
|
+
|
|
16
|
+
export type PrepareToolId = (typeof PREPARE_TOOL_IDS)[number];
|
|
17
|
+
|
|
18
|
+
/** Whether `name` is a listed prepare tool id. */
|
|
19
|
+
export function isPrepareToolId(name: string): name is PrepareToolId {
|
|
20
|
+
return (PREPARE_TOOL_IDS as readonly string[]).includes(name);
|
|
21
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* private-kubeconfig — one kubeconfig file per tool call, never the owner's own.
|
|
3
|
+
*
|
|
4
|
+
* `kubectl` and `gcloud container clusters get-credentials` both read AND write a kubeconfig:
|
|
5
|
+
* gcloud writes `$HOME/.kube/config` unless `KUBECONFIG` is set (first path if it is a list), and
|
|
6
|
+
* kubectl takes the file `KUBECONFIG` names, or merges a list of them. Handed the owner's real
|
|
7
|
+
* file, a tool call could change it, and two concurrent calls would share and race on it. So each
|
|
8
|
+
* call works on a private copy in its own 0700 directory, removed afterwards, success or failure.
|
|
9
|
+
*
|
|
10
|
+
* Sources: https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/
|
|
11
|
+
* (`--kubeconfig` set: only that file; else `KUBECONFIG` is a list, `:` on Linux and macOS, `;` on
|
|
12
|
+
* Windows) and https://docs.cloud.google.com/sdk/gcloud/reference/container/clusters/get-credentials
|
|
13
|
+
* (writes `HOME/.kube/config` unless `KUBECONFIG` is set).
|
|
14
|
+
*
|
|
15
|
+
* The copy is a plain file copy, not `kubectl config view --flatten`. One consequence is stated in
|
|
16
|
+
* the connector READMEs: a kubeconfig whose certificate paths are RELATIVE resolves them against the
|
|
17
|
+
* file's own directory, so a copy of such a file does not find them. Use absolute paths or
|
|
18
|
+
* embedded certificate data.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { constants } from "node:fs";
|
|
22
|
+
import { lstat, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
23
|
+
import { tmpdir } from "node:os";
|
|
24
|
+
import { delimiter, isAbsolute, join } from "node:path";
|
|
25
|
+
|
|
26
|
+
/** Names the directory private kubeconfigs are made in; an absolute path, else `os.tmpdir()`. */
|
|
27
|
+
export const SCRATCH_DIR_ENV = "NIMBUS_CONNECTOR_SCRATCH_DIR";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The one path `raw` (a `KUBECONFIG` value) names. A list of paths throws: a tool that cannot say
|
|
31
|
+
* which file it works on cannot copy it. `separator` is the platform's `path.delimiter`, so a
|
|
32
|
+
* Windows drive path (`C:\Users\me\config`, whose `:` is not a separator there) is one path, and
|
|
33
|
+
* a `;` list is refused there; elsewhere a `:` list is.
|
|
34
|
+
*/
|
|
35
|
+
export function singleKubeconfigPath(
|
|
36
|
+
raw: string | undefined,
|
|
37
|
+
separator: string = delimiter,
|
|
38
|
+
): string {
|
|
39
|
+
const p = raw?.trim();
|
|
40
|
+
if (p === undefined || p === "") {
|
|
41
|
+
throw new Error("KUBECONFIG env must be set to a kubeconfig file path");
|
|
42
|
+
}
|
|
43
|
+
const pieces = p
|
|
44
|
+
.split(separator)
|
|
45
|
+
.map((piece) => piece.trim())
|
|
46
|
+
.filter((piece) => piece !== "");
|
|
47
|
+
if (pieces.length > 1) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`KUBECONFIG names more than one file (separator "${separator}"); set it to ONE kubeconfig path`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
// A value that is only separators names no file at all.
|
|
53
|
+
const only = pieces[0];
|
|
54
|
+
if (only === undefined) throw new Error("KUBECONFIG env must be set to a kubeconfig file path");
|
|
55
|
+
// The one piece that remains, not the raw value: `/a/config:` names `/a/config`, and the copy is
|
|
56
|
+
// made from that path, never from a string with a separator in it.
|
|
57
|
+
return only;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The base directory private kubeconfigs are made under. */
|
|
61
|
+
function scratchBase(): string {
|
|
62
|
+
const configured = process.env[SCRATCH_DIR_ENV]?.trim();
|
|
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
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Refuse a source that is not a plain file: a symlink, a directory, a device. */
|
|
71
|
+
async function assertRegularFile(path: string): Promise<void> {
|
|
72
|
+
const st = await lstat(path).catch(() => undefined);
|
|
73
|
+
if (st === undefined) throw new Error("KUBECONFIG names a file that does not exist");
|
|
74
|
+
if (st.isSymbolicLink() || !st.isFile()) {
|
|
75
|
+
throw new Error("KUBECONFIG must name a regular file (not a symlink or a directory)");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Run `body` with a private kubeconfig.
|
|
81
|
+
*
|
|
82
|
+
* With `source` (a `KUBECONFIG` value, ONE path): the file is copied, mode 0600, and `body` gets
|
|
83
|
+
* `{ KUBECONFIG: <copy> }`. Without it, the copy starts EMPTY, for `get-credentials` to fill.
|
|
84
|
+
* The directory is removed when `body` settles.
|
|
85
|
+
*/
|
|
86
|
+
export async function withPrivateKubeconfig<T>(
|
|
87
|
+
opts: { readonly source?: string | undefined },
|
|
88
|
+
body: (env: { readonly KUBECONFIG: string }) => Promise<T>,
|
|
89
|
+
): Promise<T> {
|
|
90
|
+
// Everything that can refuse does so BEFORE a directory exists.
|
|
91
|
+
let content: Buffer | undefined;
|
|
92
|
+
if (opts.source !== undefined) {
|
|
93
|
+
const sourcePath = singleKubeconfigPath(opts.source);
|
|
94
|
+
await assertRegularFile(sourcePath);
|
|
95
|
+
content = await readFile(sourcePath);
|
|
96
|
+
}
|
|
97
|
+
const base = scratchBase();
|
|
98
|
+
const dir = await mkdtemp(join(base, "nimbus-kubeconfig-"));
|
|
99
|
+
try {
|
|
100
|
+
const file = join(dir, "config");
|
|
101
|
+
// `wx`: the file is new by construction, and an existing one is an error, not a reuse.
|
|
102
|
+
await writeFile(file, content ?? "", {
|
|
103
|
+
mode: 0o600,
|
|
104
|
+
flag: constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY,
|
|
105
|
+
});
|
|
106
|
+
return await body({ KUBECONFIG: file });
|
|
107
|
+
} finally {
|
|
108
|
+
await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
109
|
+
}
|
|
110
|
+
}
|