@nimbus-dev/connectors 0.6.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.
@@ -0,0 +1,138 @@
1
+ /**
2
+ * commit-files: what the multi-file commit tools (`github_file_commit`, `gitlab_commit_create`) and
3
+ * their prepare tools (`github_commit_prepare`, `gitlab_commit_prepare`) agree on, so the preview the
4
+ * owner approves and the commit that runs are bounded by the same rules.
5
+ *
6
+ * - Text files only, written whole: no deletes, no mode changes, no symlinks, no submodules.
7
+ * - At most {@link COMMIT_MAX_FILES} files, and the combined size of the old content that is shown and
8
+ * the new content that is sent at most {@link COMMIT_MAX_COMBINED_BYTES}. The prepare tool REFUSES a
9
+ * bigger commit rather than truncating: the preview never shows less than is sent, and its reply
10
+ * stays under the gateway's 256 KiB cap.
11
+ */
12
+
13
+ import { z } from "zod";
14
+
15
+ export const COMMIT_MAX_FILES = 20;
16
+ export const COMMIT_MAX_COMBINED_BYTES = 192 * 1024;
17
+
18
+ const FULL_SHA = /^[0-9a-f]{40}$/i;
19
+
20
+ /** A full 40-hex commit sha, any case. */
21
+ export const fullShaSchema = z.string().regex(FULL_SHA, "a full 40-hex commit sha");
22
+
23
+ /**
24
+ * A character the approval preview cannot be trusted to show as it is: any Unicode control (Cc, which
25
+ * includes DEL and the C1 range U+0080..U+009F), format character (Cf: bidi overrides, zero-width
26
+ * spaces, the byte-order mark), line or paragraph separator (Zl, Zp), or lone surrogate (Cs).
27
+ */
28
+ const UNSHOWABLE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Cs}]/u;
29
+
30
+ /**
31
+ * Whether a path segment spells `.git` on a filesystem that folds it: NTFS and HFS+ ignore trailing
32
+ * dots and spaces and compare case-insensitively, and NTFS also knows the short name `GIT~1`. A
33
+ * segment that is nothing but dots and spaces is refused too, since it folds to the empty name.
34
+ */
35
+ function isGitSpelling(segment: string): boolean {
36
+ const folded = segment.replace(/[. ]+$/u, "").toLowerCase();
37
+ return folded === "" || folded === ".git" || folded === "git~1";
38
+ }
39
+
40
+ /**
41
+ * Whether `path` is a repository-relative file path the commit tools accept: 1..1024 characters, no
42
+ * leading or trailing `/`, no `//`, no backslash, no unshowable character (see {@link UNSHOWABLE}),
43
+ * and no `.` or `..` segment or spelling of `.git` (see {@link isGitSpelling}). The path reaches a
44
+ * URL path or a JSON body, never a shell.
45
+ */
46
+ export function isSafeCommitPath(path: string): boolean {
47
+ if (path.length < 1 || path.length > 1024) return false;
48
+ if (path.startsWith("/") || path.endsWith("/") || path.includes("//") || path.includes("\\")) {
49
+ return false;
50
+ }
51
+ if (UNSHOWABLE.test(path)) return false;
52
+ return path.split("/").every((seg) => seg !== "." && seg !== ".." && !isGitSpelling(seg));
53
+ }
54
+
55
+ export const commitFilePathSchema = z
56
+ .string()
57
+ .refine(isSafeCommitPath, "not a safe repository file path");
58
+
59
+ /** One file of a commit: its path and its whole new text. */
60
+ export const commitFileSchema = z.object({
61
+ path: commitFilePathSchema,
62
+ content: z.string(),
63
+ });
64
+
65
+ export type CommitFile = z.infer<typeof commitFileSchema>;
66
+
67
+ /** The `files` field: 1..{@link COMMIT_MAX_FILES} entries. Uniqueness is checked by {@link assertCommitFiles}. */
68
+ export const commitFilesSchema = z.array(commitFileSchema).min(1).max(COMMIT_MAX_FILES);
69
+
70
+ /** UTF-8 byte length of `text`. */
71
+ export function utf8Length(text: string): number {
72
+ return new TextEncoder().encode(text).length;
73
+ }
74
+
75
+ /**
76
+ * The checks a schema cannot make (a schema holding them would have to be `.refine()`d, which loses
77
+ * `.shape`): paths unique, and the NEW content alone within the combined cap. Throws a fixed message.
78
+ */
79
+ export function assertCommitFiles(files: readonly CommitFile[]): void {
80
+ if (files.length > COMMIT_MAX_FILES) {
81
+ throw new Error(`a commit may touch at most ${String(COMMIT_MAX_FILES)} files`);
82
+ }
83
+ const seen = new Set<string>();
84
+ for (const f of files) {
85
+ if (seen.has(f.path)) throw new Error(`duplicate file path in commit: ${f.path}`);
86
+ seen.add(f.path);
87
+ }
88
+ // A path and one beneath it (`a` and `a/b`) cannot both be files of one tree.
89
+ for (const path of seen) {
90
+ const segments = path.split("/");
91
+ for (let i = 1; i < segments.length; i++) {
92
+ const parent = segments.slice(0, i).join("/");
93
+ if (seen.has(parent)) {
94
+ throw new Error(
95
+ `path collision in commit: ${parent} is a file and also a directory of ${path}`,
96
+ );
97
+ }
98
+ }
99
+ }
100
+ const bytes = files.reduce((n, f) => n + utf8Length(f.content), 0);
101
+ if (bytes > COMMIT_MAX_COMBINED_BYTES) {
102
+ throw new Error(commitTooLarge());
103
+ }
104
+ }
105
+
106
+ /** The one refusal for a commit over the combined cap. */
107
+ export function commitTooLarge(): string {
108
+ return `commit too large: the old and new content together exceed ${String(COMMIT_MAX_COMBINED_BYTES / 1024)} KiB`;
109
+ }
110
+
111
+ /** Throws when `oldBytes + newBytes` is over the combined cap. */
112
+ export function assertCombinedSize(oldBytes: number, files: readonly CommitFile[]): void {
113
+ const newBytes = files.reduce((n, f) => n + utf8Length(f.content), 0);
114
+ if (oldBytes + newBytes > COMMIT_MAX_COMBINED_BYTES) throw new Error(commitTooLarge());
115
+ }
116
+
117
+ export type DecodedText = { readonly text: string } | { readonly binary: true };
118
+
119
+ /**
120
+ * Decode base64 file bytes as UTF-8. Bytes that are not valid UTF-8, or that hold a NUL, are
121
+ * `binary`: their content is withheld and the commit tools refuse to overwrite them.
122
+ */
123
+ export function decodeBase64Text(base64: string): DecodedText {
124
+ const bytes = Buffer.from(base64.replaceAll(/\s/g, ""), "base64");
125
+ if (bytes.includes(0)) return { binary: true };
126
+ try {
127
+ return { text: new TextDecoder("utf-8", { fatal: true }).decode(bytes) };
128
+ } catch {
129
+ return { binary: true };
130
+ }
131
+ }
132
+
133
+ /** The error a commit tool throws for an existing file it will not overwrite. */
134
+ export function notOverwritable(path: string): Error {
135
+ return new Error(
136
+ `${path} is not a text file the commit tools can overwrite (binary, symlink, submodule, a directory in the way, or too large)`,
137
+ );
138
+ }
@@ -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
- * Build a connector's write-tool registrar.
163
- *
164
- * In `"gateway"` mode the raw handler is registered immediately: `executor.ts` (I2) is the gate
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
- export function createWriteToolRegistrar(
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
+ }
@@ -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
+ }