@nimbus-dev/connectors 0.4.0 → 0.6.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/calendar-tools.ts +53 -9
- package/connectors/confluence/src/tools.ts +43 -3
- package/connectors/flagsmith/nimbus.extension.json +2 -2
- package/connectors/flagsmith/src/server.ts +12 -4
- package/connectors/flagsmith/src/tools.ts +147 -7
- package/connectors/github/src/tools.ts +134 -4
- package/connectors/github-actions/src/tools.ts +3 -2
- package/connectors/gitlab/src/tools.ts +186 -19
- package/connectors/google-drive/src/tools.ts +10 -2
- package/connectors/jira/nimbus.extension.json +1 -1
- package/connectors/jira/src/tools.ts +254 -1
- package/connectors/launchdarkly/nimbus.extension.json +2 -2
- package/connectors/launchdarkly/src/flag-state.ts +178 -0
- package/connectors/launchdarkly/src/server.ts +12 -4
- package/connectors/launchdarkly/src/tools.ts +184 -10
- package/connectors/obsidian/nimbus.extension.json +1 -1
- package/connectors/obsidian/src/tools.ts +158 -10
- package/connectors/pagerduty/src/tools.ts +122 -27
- package/package.json +1 -1
- package/shared/env-json-api.ts +57 -15
- package/shared/github-rest.ts +128 -5
- package/shared/guarded-fetch.ts +135 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import type { ConsentServer } from "../../../shared/consent-kit.ts";
|
|
3
3
|
import { createWriteToolRegistrar } from "../../../shared/consent-kit.ts";
|
|
4
|
+
import { guardedFetch } from "../../../shared/guarded-fetch.ts";
|
|
4
5
|
import {
|
|
5
6
|
createRegisterSimpleTool,
|
|
6
7
|
createZodToolRegistrar,
|
|
@@ -18,7 +19,8 @@ const API = "https://api.pagerduty.com";
|
|
|
18
19
|
async function pdFetch(token: string, path: string, init?: RequestInit): Promise<RestFetchResult> {
|
|
19
20
|
const rel = path.startsWith("/") ? path : `/${path}`;
|
|
20
21
|
const url = path.startsWith("http") ? path : `${API}${rel}`;
|
|
21
|
-
|
|
22
|
+
// guardedFetch: a write answered with a redirect is refused, and a GET follows only same-origin.
|
|
23
|
+
const res = await guardedFetch("PagerDuty", url, {
|
|
22
24
|
...init,
|
|
23
25
|
headers: {
|
|
24
26
|
Accept: "application/vnd.pagerduty+json;version=2",
|
|
@@ -30,11 +32,59 @@ async function pdFetch(token: string, path: string, init?: RequestInit): Promise
|
|
|
30
32
|
return toRestFetchResult(res);
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
/**
|
|
36
|
+
* A linear-time email shape check (no regex, so no backtracking): at most 254 characters, no
|
|
37
|
+
* whitespace or control characters, exactly one `@`, a non-empty local part, and a domain that
|
|
38
|
+
* contains a `.` that is neither its first nor its last character.
|
|
39
|
+
*/
|
|
40
|
+
export function isEmailShape(email: string): boolean {
|
|
41
|
+
if (email.length === 0 || email.length > 254) return false;
|
|
42
|
+
for (let i = 0; i < email.length; i++) {
|
|
43
|
+
const c = email.charCodeAt(i);
|
|
44
|
+
if (c <= 0x20 || (c >= 0x7f && c <= 0xa0) || c === 0x2028 || c === 0x2029 || c === 0xfeff) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const at = email.indexOf("@");
|
|
49
|
+
if (at < 1 || at !== email.lastIndexOf("@")) return false;
|
|
50
|
+
const domain = email.slice(at + 1);
|
|
51
|
+
// A "." strictly inside the domain: neither its first nor its last character.
|
|
52
|
+
return domain.length >= 3 && domain.slice(1, -1).includes(".");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The `From` header PagerDuty requires on an incident update: "The email address of a valid user
|
|
57
|
+
* associated with the account making the request". Writes only. Throws before any request.
|
|
58
|
+
* The gateway injects it from the Vault key `pagerduty.from_email`.
|
|
59
|
+
*/
|
|
60
|
+
function requirePdFromEmail(): string {
|
|
61
|
+
const email = requireProcessEnv("PAGERDUTY_FROM_EMAIL");
|
|
62
|
+
if (!isEmailShape(email)) {
|
|
63
|
+
throw new Error("PAGERDUTY_FROM_EMAIL is not an email address");
|
|
64
|
+
}
|
|
65
|
+
return email;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Incident ids are short alphanumerics; anything else never reaches a URL. */
|
|
69
|
+
const incidentIdSchema = z.string().regex(/^[A-Za-z0-9]{1,64}$/);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* `PUT /incidents/{id}` with `{incident:{type:"incident_reference", status}}` and a required
|
|
73
|
+
* `From` header: the only update endpoint
|
|
74
|
+
* https://docs.pagerduty.com/developer/api/reference/rest/incidents/update-incident documents
|
|
75
|
+
* (retrieved 2026-11-06). There is no `/acknowledge` or `/resolve` sub-endpoint.
|
|
76
|
+
*/
|
|
77
|
+
async function pdIncidentPut(
|
|
78
|
+
actionLabel: string,
|
|
79
|
+
change: { readonly status: "acknowledged" | "resolved" } | { readonly escalation_level: number },
|
|
80
|
+
incidentId: string,
|
|
81
|
+
) {
|
|
82
|
+
const from = requirePdFromEmail();
|
|
34
83
|
const token = requireProcessEnv("PAGERDUTY_API_TOKEN");
|
|
35
|
-
const res = await pdFetch(token, `/incidents/${encodeURIComponent(incidentId)}
|
|
84
|
+
const res = await pdFetch(token, `/incidents/${encodeURIComponent(incidentId)}`, {
|
|
36
85
|
method: "PUT",
|
|
37
|
-
|
|
86
|
+
headers: { From: from },
|
|
87
|
+
body: JSON.stringify({ incident: { type: "incident_reference", ...change } }),
|
|
38
88
|
});
|
|
39
89
|
if (!res.ok) {
|
|
40
90
|
throw new Error(`PagerDuty ${actionLabel} ${String(res.status)}: ${res.text.slice(0, 400)}`);
|
|
@@ -42,6 +92,24 @@ async function pdIncidentPutMutation(actionLabel: string, pathSuffix: string, in
|
|
|
42
92
|
return jsonResult(res.json ?? { ok: true });
|
|
43
93
|
}
|
|
44
94
|
|
|
95
|
+
/** The incident as it stands: status, assignments, escalation level. Audited before a write. */
|
|
96
|
+
async function readIncidentPreState(incidentId: string): Promise<Record<string, unknown>> {
|
|
97
|
+
requirePdFromEmail();
|
|
98
|
+
const token = requireProcessEnv("PAGERDUTY_API_TOKEN");
|
|
99
|
+
const res = await pdFetch(token, `/incidents/${encodeURIComponent(incidentId)}`);
|
|
100
|
+
if (!res.ok) {
|
|
101
|
+
throw new Error(`PagerDuty pre-state ${String(res.status)}: ${res.text.slice(0, 400)}`);
|
|
102
|
+
}
|
|
103
|
+
const inc = (res.json as { incident?: Record<string, unknown> } | null)?.incident ?? {};
|
|
104
|
+
return {
|
|
105
|
+
id: incidentId,
|
|
106
|
+
status: inc["status"],
|
|
107
|
+
assignments: inc["assignments"],
|
|
108
|
+
escalation_level: inc["escalation_level"] ?? null,
|
|
109
|
+
escalation_policy: inc["escalation_policy"],
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
45
113
|
/** Tool names exposed by this connector — for contract/introspection tests. */
|
|
46
114
|
export const PAGERDUTY_TOOL_NAMES = [
|
|
47
115
|
"pd_incident_list",
|
|
@@ -51,13 +119,6 @@ export const PAGERDUTY_TOOL_NAMES = [
|
|
|
51
119
|
"pd_incident_escalate",
|
|
52
120
|
] as const;
|
|
53
121
|
|
|
54
|
-
/** The incident status changes, which differ only in the word they PUT. */
|
|
55
|
-
const INCIDENT_STATUS_ACTIONS = [
|
|
56
|
-
{ action: "acknowledge", description: "Acknowledge an incident." },
|
|
57
|
-
{ action: "resolve", description: "Resolve an incident." },
|
|
58
|
-
{ action: "escalate", description: "Escalate an incident." },
|
|
59
|
-
] as const;
|
|
60
|
-
|
|
61
122
|
export function registerPagerdutyTools(
|
|
62
123
|
server: ConsentServer & { tool: (...args: never) => unknown },
|
|
63
124
|
): void {
|
|
@@ -107,20 +168,54 @@ export function registerPagerdutyTools(
|
|
|
107
168
|
(parsed) => `/incidents/${encodeURIComponent(parsed.incidentId)}`,
|
|
108
169
|
);
|
|
109
170
|
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
171
|
+
// Literal tool ids and `mutates` strings, so a static scan reads them. None can be undone
|
|
172
|
+
// through these tools (there is no unacknowledge, reopen or de-escalate), so each records the
|
|
173
|
+
// incident's prior state in the audit.
|
|
174
|
+
const incidentArgs = z.object({ incidentId: incidentIdSchema });
|
|
175
|
+
const incidentScope = (p: { incidentId: string }) => ({
|
|
176
|
+
kind: "incident",
|
|
177
|
+
value: p.incidentId,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
registerWriteTool(
|
|
181
|
+
"pd_incident_acknowledge",
|
|
182
|
+
{
|
|
183
|
+
mutates: "pagerduty.incident.acknowledge",
|
|
184
|
+
recoverable: false,
|
|
185
|
+
capturePreState: (p) => readIncidentPreState(p.incidentId),
|
|
186
|
+
scopeTargetOf: incidentScope,
|
|
187
|
+
},
|
|
188
|
+
"Acknowledge an incident (`PUT /incidents/{id}` with status acknowledged). Needs PAGERDUTY_FROM_EMAIL, the email of a valid PagerDuty user.",
|
|
189
|
+
incidentArgs,
|
|
190
|
+
async (p) => pdIncidentPut("acknowledge", { status: "acknowledged" }, p.incidentId),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
registerWriteTool(
|
|
194
|
+
"pd_incident_resolve",
|
|
195
|
+
{
|
|
196
|
+
mutates: "pagerduty.incident.resolve",
|
|
197
|
+
recoverable: false,
|
|
198
|
+
capturePreState: (p) => readIncidentPreState(p.incidentId),
|
|
199
|
+
scopeTargetOf: incidentScope,
|
|
200
|
+
},
|
|
201
|
+
"Resolve an incident (`PUT /incidents/{id}` with status resolved); it cannot be reopened through these tools. Needs PAGERDUTY_FROM_EMAIL, the email of a valid PagerDuty user.",
|
|
202
|
+
incidentArgs,
|
|
203
|
+
async (p) => pdIncidentPut("resolve", { status: "resolved" }, p.incidentId),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
registerWriteTool(
|
|
207
|
+
"pd_incident_escalate",
|
|
208
|
+
{
|
|
209
|
+
mutates: "pagerduty.incident.escalate",
|
|
210
|
+
recoverable: false,
|
|
211
|
+
capturePreState: (p) => readIncidentPreState(p.incidentId),
|
|
212
|
+
scopeTargetOf: incidentScope,
|
|
213
|
+
},
|
|
214
|
+
// `escalation_level` ("Escalate the incident to this level in the escalation policy.", an
|
|
215
|
+
// integer) is a property of the `incident` body of PUT /incidents/{id} in PagerDuty's OpenAPI
|
|
216
|
+
// schema (github.com/PagerDuty/api-schema, reference/REST/openapiv3.json, retrieved 2026-11-06).
|
|
217
|
+
"Escalate an incident to the given level of its escalation policy (`PUT /incidents/{id}` with `escalation_level`); it cannot be de-escalated through these tools. Needs PAGERDUTY_FROM_EMAIL, the email of a valid PagerDuty user.",
|
|
218
|
+
incidentArgs.extend({ escalationLevel: z.number().int().min(1).max(20) }),
|
|
219
|
+
async (p) => pdIncidentPut("escalate", { escalation_level: p.escalationLevel }, p.incidentId),
|
|
220
|
+
);
|
|
126
221
|
}
|
package/package.json
CHANGED
package/shared/env-json-api.ts
CHANGED
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
* fail with "X_TOKEN is not set" on the tool call rather than at startup.
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
|
+
import { guardedFetch } from "./guarded-fetch.ts";
|
|
41
42
|
import { stripTrailingSlashes } from "./strip-trailing-slashes.ts";
|
|
42
43
|
|
|
43
44
|
/** Body-snippet length in the thrown error. The value every connector used. */
|
|
@@ -156,7 +157,25 @@ function sendFor(
|
|
|
156
157
|
const doFetch = config.fetch ?? ((url, init) => fetch(url, init));
|
|
157
158
|
return async (path, init, errorPrefix) => {
|
|
158
159
|
const root = typeof base === "string" ? base : base();
|
|
159
|
-
|
|
160
|
+
// A credential must go only where the base names, and a runtime follows a 3xx by default
|
|
161
|
+
// (re-sending a custom header such as LaunchDarkly's bare Authorization or Flagsmith's Token
|
|
162
|
+
// to whatever Location says). So a mutation's 3xx is refused outright, and a GET follows only
|
|
163
|
+
// a same-origin Location or the same host's http -> https upgrade, at most 3 hops, each hop asked for manually.
|
|
164
|
+
const reqInit = init(headers());
|
|
165
|
+
const url = `${root}${path}`;
|
|
166
|
+
const method = (reqInit.method ?? "GET").toUpperCase();
|
|
167
|
+
const res =
|
|
168
|
+
method === "GET" || method === "HEAD"
|
|
169
|
+
? await guardedFetch(errorPrefix, url, reqInit, {
|
|
170
|
+
fetch: doFetch,
|
|
171
|
+
requireSecureBase: false,
|
|
172
|
+
})
|
|
173
|
+
: await doFetch(url, { ...reqInit, redirect: "manual" });
|
|
174
|
+
if (res.status >= 300 && res.status < 400) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`${errorPrefix}: the server answered with a redirect (${String(res.status)}); the write may or may not have been applied`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
160
179
|
const text = await res.text();
|
|
161
180
|
if (!res.ok) {
|
|
162
181
|
throw new Error(`${errorPrefix} ${String(res.status)}: ${text.slice(0, snippetMax)}`);
|
|
@@ -180,30 +199,53 @@ export function createJsonGetter(config: JsonApiConfig): (path: string) => Promi
|
|
|
180
199
|
}
|
|
181
200
|
|
|
182
201
|
/**
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
202
|
+
* A `(method, path, body, extraHeaders?) => Promise<unknown>` JSON client for any mutating verb
|
|
203
|
+
* (PATCH, PUT, DELETE, POST). `Content-Type: application/json` is the default and `extraHeaders`
|
|
204
|
+
* override it key for key (LaunchDarkly's semantic patch needs `application/json;
|
|
205
|
+
* domain-model=launchdarkly.semanticpatch`). Resolves to the parsed response, `{}` when empty.
|
|
187
206
|
*
|
|
188
|
-
* Throws `"<
|
|
189
|
-
*
|
|
190
|
-
*
|
|
207
|
+
* Throws `"<errorPrefix> <status>: <body snippet>"`; the prefix defaults to
|
|
208
|
+
* `"<label> <method> <path>"` and is taken explicitly so {@link createJsonPoster} keeps its own
|
|
209
|
+
* `"<label> <path>"` format. A 3xx is refused, never followed (see `sendFor`).
|
|
191
210
|
*/
|
|
192
|
-
export function
|
|
211
|
+
export function createJsonSender(
|
|
193
212
|
config: JsonApiConfig,
|
|
194
|
-
|
|
195
|
-
|
|
213
|
+
errorPrefix: (method: string, path: string) => string = (method, path) =>
|
|
214
|
+
`${config.label} ${method} ${path}`,
|
|
215
|
+
): (
|
|
216
|
+
method: string,
|
|
217
|
+
path: string,
|
|
218
|
+
body: unknown,
|
|
219
|
+
extraHeaders?: Record<string, string>,
|
|
220
|
+
) => Promise<unknown> {
|
|
196
221
|
const send = sendFor(config);
|
|
197
|
-
return async (path
|
|
222
|
+
return async (method, path, body, extraHeaders) => {
|
|
198
223
|
const text = await send(
|
|
199
224
|
path,
|
|
200
225
|
(headers) => ({
|
|
201
|
-
method
|
|
202
|
-
headers: { ...headers, "Content-Type": "application/json" },
|
|
226
|
+
method,
|
|
227
|
+
headers: { ...headers, "Content-Type": "application/json", ...extraHeaders },
|
|
203
228
|
body: JSON.stringify(body),
|
|
204
229
|
}),
|
|
205
|
-
|
|
230
|
+
errorPrefix(method, path),
|
|
206
231
|
);
|
|
207
232
|
return text === "" ? {} : (JSON.parse(text) as unknown);
|
|
208
233
|
};
|
|
209
234
|
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The mutating twin of {@link createJsonGetter}: a `(path, body) => Promise<unknown>`
|
|
238
|
+
* POST client that sends `body` as JSON (adding `Content-Type: application/json` to
|
|
239
|
+
* the request headers) and resolves to the parsed response — `{}` when the response
|
|
240
|
+
* body is empty, as a mutation's often is.
|
|
241
|
+
*
|
|
242
|
+
* Throws `"<label> <path> <status>: <body snippet>"` on any non-2xx: unlike the
|
|
243
|
+
* getter's, the message names the path, so a failed mutation says which endpoint
|
|
244
|
+
* refused it. The argocd and mlflow write tools each carried a copy of this.
|
|
245
|
+
*/
|
|
246
|
+
export function createJsonPoster(
|
|
247
|
+
config: JsonApiConfig,
|
|
248
|
+
): (path: string, body: unknown) => Promise<unknown> {
|
|
249
|
+
const send = createJsonSender(config, (_method, path) => `${config.label} ${path}`);
|
|
250
|
+
return (path: string, body: unknown): Promise<unknown> => send("POST", path, body);
|
|
251
|
+
}
|
package/shared/github-rest.ts
CHANGED
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
* places, so a bump had to find all three.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { resolveUrlWithBase } from "@nimbus-dev/sdk/connector-kit";
|
|
11
12
|
import { optionalBaseUrl } from "./env-json-api.ts";
|
|
12
|
-
import {
|
|
13
|
+
import { guardedFetch } from "./guarded-fetch.ts";
|
|
14
|
+
import { type RestFetchResult, toRestFetchResult } from "./rest-tool-kit.ts";
|
|
13
15
|
|
|
14
16
|
export const GH_API = "https://api.github.com";
|
|
15
17
|
|
|
@@ -33,13 +35,42 @@ export const GH_HEADERS: Record<string, string> = {
|
|
|
33
35
|
"X-GitHub-Api-Version": GH_API_VERSION,
|
|
34
36
|
};
|
|
35
37
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
/**
|
|
39
|
+
* A Bearer-authenticated JSON request against {@link ghApiBase} (a relative path or a same-origin
|
|
40
|
+
* URL). It goes through {@link guardedFetch}: a write that GitHub answers with a redirect (a renamed
|
|
41
|
+
* or transferred repository) is refused, not replayed as a GET that would report a false success,
|
|
42
|
+
* and a GET follows only a same-origin https redirect, at most 3 hops.
|
|
43
|
+
*/
|
|
44
|
+
export async function ghFetch(
|
|
45
|
+
token: string,
|
|
46
|
+
path: string,
|
|
47
|
+
init?: RequestInit,
|
|
48
|
+
): Promise<RestFetchResult> {
|
|
49
|
+
const url = resolveUrlWithBase(ghApiBase(), path);
|
|
50
|
+
const headers = new Headers({ Authorization: `Bearer ${token}`, ...GH_HEADERS });
|
|
51
|
+
if (init?.headers !== undefined) {
|
|
52
|
+
for (const [k, v] of new Headers(init.headers)) headers.set(k, v);
|
|
53
|
+
}
|
|
54
|
+
const res = await guardedFetch("GitHub", url, { ...init, headers }, { requireSecureBase: false });
|
|
55
|
+
return toRestFetchResult(res);
|
|
39
56
|
}
|
|
40
57
|
|
|
41
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Whether `seg` is a safe GitHub `owner` or `repo`: 1..100 of `[A-Za-z0-9._-]`, and not `.` or `..`
|
|
60
|
+
* (a URL parser collapses those out of the path, turning `/repos/../../pulls/9` into another endpoint).
|
|
61
|
+
*/
|
|
62
|
+
export function isSafeGhSlug(seg: string): boolean {
|
|
63
|
+
return /^[A-Za-z0-9._-]{1,100}$/.test(seg) && seg !== "." && seg !== "..";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `/repos/<owner>/<repo>`, both segments URI-encoded. `.` and `..` are refused: `encodeURIComponent`
|
|
68
|
+
* leaves them alone and a URL parser collapses them (`/repos/../../pulls/9`).
|
|
69
|
+
*/
|
|
42
70
|
export function ghRepoPath(owner: string, repo: string): string {
|
|
71
|
+
for (const seg of [owner, repo]) {
|
|
72
|
+
if (seg === "." || seg === "..") throw new Error("owner and repo cannot be a dot segment");
|
|
73
|
+
}
|
|
43
74
|
return `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
|
|
44
75
|
}
|
|
45
76
|
|
|
@@ -71,3 +102,95 @@ export function ghQueryPath(path: string, setQuery: (params: URLSearchParams) =>
|
|
|
71
102
|
setQuery(u.searchParams);
|
|
72
103
|
return `${u.pathname}${u.search}`;
|
|
73
104
|
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Whether `ref` is safe to place in a GitHub API path and to hand to GitHub as a ref name.
|
|
108
|
+
*
|
|
109
|
+
* A ref reaches a URL path, so a hostile one is a path-traversal vector (`../x` would climb out of
|
|
110
|
+
* `/commits/`). One rule for every ref the model supplies: 1..255 characters of
|
|
111
|
+
* `[A-Za-z0-9._/-]`, no `..`, no leading or trailing `/`, no `//`, no leading `-`, no segment that
|
|
112
|
+
* starts with `.` or ends with `.lock`, and no trailing `.`.
|
|
113
|
+
*/
|
|
114
|
+
export function isSafeGitRef(ref: string): boolean {
|
|
115
|
+
if (ref.length < 1 || ref.length > 255) return false;
|
|
116
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(ref)) return false;
|
|
117
|
+
if (ref.startsWith("/") || ref.endsWith("/") || ref.startsWith("-") || ref.endsWith(".")) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
if (ref.includes("//") || ref.includes("..")) return false;
|
|
121
|
+
return ref.split("/").every((seg) => !seg.startsWith(".") && !seg.endsWith(".lock"));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** What a ref resolved to: the commit SHA, and whether the name was a branch, a tag or a SHA. */
|
|
125
|
+
export interface ResolvedCommit {
|
|
126
|
+
readonly sha: string;
|
|
127
|
+
readonly kind: "branch" | "tag" | "sha";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const FULL_SHA = /^[0-9a-f]{40}$/i;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* One `GET /commits/<ref>` with the SHA media type; `undefined` when the ref does not exist, a throw
|
|
134
|
+
* on anything else. GitHub answers a missing ref with a 422 ("No commit found for SHA: ..."), not a
|
|
135
|
+
* 404, so both count as not found; a 422 with any other message is a real error.
|
|
136
|
+
*/
|
|
137
|
+
async function commitShaFor(
|
|
138
|
+
token: string,
|
|
139
|
+
owner: string,
|
|
140
|
+
repo: string,
|
|
141
|
+
ref: string,
|
|
142
|
+
): Promise<string | undefined> {
|
|
143
|
+
const segments = ref.split("/").map(encodeURIComponent).join("/");
|
|
144
|
+
const res = await ghFetch(token, `${ghRepoPath(owner, repo)}/commits/${segments}`, {
|
|
145
|
+
headers: { Accept: "application/vnd.github.sha" },
|
|
146
|
+
});
|
|
147
|
+
if (res.status === 404) return undefined;
|
|
148
|
+
if (res.status === 422 && /no commit found/i.test(res.text)) return undefined;
|
|
149
|
+
if (!res.ok) {
|
|
150
|
+
throw new Error(`GitHub ${String(res.status)}: ${res.text.slice(0, 300)}`);
|
|
151
|
+
}
|
|
152
|
+
const sha = res.text.trim();
|
|
153
|
+
if (!FULL_SHA.test(sha)) {
|
|
154
|
+
throw new Error(`GitHub returned no commit sha for ref "${ref}"`);
|
|
155
|
+
}
|
|
156
|
+
return sha.toLowerCase();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The names to try for `ref`, in order. A `heads/` or `tags/` prefix (or the full `refs/heads/` /
|
|
161
|
+
* `refs/tags/`) is one candidate; a bare name is both. Always the full `refs/...` form: GitHub's
|
|
162
|
+
* `/commits/tags/<x>` answers 422 for an existing tag, while `/commits/refs/tags/<x>` resolves.
|
|
163
|
+
*/
|
|
164
|
+
function refCandidates(ref: string): { readonly name: string; readonly kind: "branch" | "tag" }[] {
|
|
165
|
+
if (ref.startsWith("refs/heads/")) return [{ name: ref, kind: "branch" }];
|
|
166
|
+
if (ref.startsWith("refs/tags/")) return [{ name: ref, kind: "tag" }];
|
|
167
|
+
if (ref.startsWith("heads/")) return [{ name: `refs/${ref}`, kind: "branch" }];
|
|
168
|
+
if (ref.startsWith("tags/")) return [{ name: `refs/${ref}`, kind: "tag" }];
|
|
169
|
+
return [
|
|
170
|
+
{ name: `refs/heads/${ref}`, kind: "branch" },
|
|
171
|
+
{ name: `refs/tags/${ref}`, kind: "tag" },
|
|
172
|
+
];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Resolve a branch, tag or commit to a COMMIT sha. `ref` must already satisfy {@link isSafeGitRef}.
|
|
177
|
+
*
|
|
178
|
+
* A 40-hex string is returned as is. `heads/*` or `tags/*` is one GET. A bare name tries
|
|
179
|
+
* `refs/heads/<ref>` then `refs/tags/<ref>`, falling through only on a not-found (404, or 422 "No commit found"). It reads `/commits/<ref>` and not
|
|
180
|
+
* `/git/ref/<ref>` so an annotated tag is peeled to a commit (a branch cannot point at a tag object).
|
|
181
|
+
* The read tool and the write tool share this, so what a name means cannot differ between the
|
|
182
|
+
* before-state the owner saw and the write that runs.
|
|
183
|
+
*/
|
|
184
|
+
export async function resolveCommitSha(
|
|
185
|
+
token: string,
|
|
186
|
+
owner: string,
|
|
187
|
+
repo: string,
|
|
188
|
+
ref: string,
|
|
189
|
+
): Promise<ResolvedCommit> {
|
|
190
|
+
if (FULL_SHA.test(ref)) return { sha: ref.toLowerCase(), kind: "sha" };
|
|
191
|
+
for (const c of refCandidates(ref)) {
|
|
192
|
+
const sha = await commitShaFor(token, owner, repo, c.name);
|
|
193
|
+
if (sha !== undefined) return { sha, kind: c.kind };
|
|
194
|
+
}
|
|
195
|
+
throw new Error(`GitHub 404: ref "${ref}" not found`);
|
|
196
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* guarded-fetch — a `fetch` that never lets a redirect carry a credential to another origin.
|
|
3
|
+
*
|
|
4
|
+
* A runtime follows a 3xx by default, and the Fetch spec strips only the `Authorization` header on
|
|
5
|
+
* a cross-origin hop: a custom credential header (GitLab's `PRIVATE-TOKEN`) goes along to whatever
|
|
6
|
+
* `Location` names. So every credentialed request here asks for `redirect: "manual"` and decides
|
|
7
|
+
* itself:
|
|
8
|
+
*
|
|
9
|
+
* - a redirect on a mutation (anything but GET/HEAD) is refused: replaying a write is not ours to
|
|
10
|
+
* decide, and the credential must not follow it;
|
|
11
|
+
* - a redirect on a GET/HEAD is followed (at most {@link MAX_REDIRECT_HOPS} times) only when the
|
|
12
|
+
* `Location` resolves to the SAME origin as the request, or is the same host's http -> https
|
|
13
|
+
* upgrade (same hostname, same explicit port or both default); https -> http and any other
|
|
14
|
+
* target is refused and nothing is sent there. The request headers (credentials included) are
|
|
15
|
+
* re-sent unchanged, and only ever to that host;
|
|
16
|
+
* - a request to a plain-http origin that is not loopback is refused before it is sent, since it
|
|
17
|
+
* would put the credential on the wire in clear.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { fetchWithTimeout } from "@nimbus-dev/sdk/connector-kit";
|
|
21
|
+
|
|
22
|
+
/** Followed redirects allowed on one GET/HEAD; the next one is refused. */
|
|
23
|
+
export const MAX_REDIRECT_HOPS = 3;
|
|
24
|
+
|
|
25
|
+
/** Per-request timeout when the caller supplies no transport; the SDK's default for every connector. */
|
|
26
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
27
|
+
|
|
28
|
+
const REDIRECT_STATUSES: ReadonlySet<number> = new Set([301, 302, 303, 307, 308]);
|
|
29
|
+
|
|
30
|
+
function isLoopbackHost(hostname: string): boolean {
|
|
31
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Throws unless `url` is https, or http to a loopback host. */
|
|
35
|
+
function assertSecureTransport(label: string, url: URL): void {
|
|
36
|
+
if (url.protocol === "https:") return;
|
|
37
|
+
if (url.protocol === "http:" && isLoopbackHost(url.hostname)) return;
|
|
38
|
+
throw new Error(`${label} base URL must be https (or loopback http); refused`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface GuardedFetchOptions {
|
|
42
|
+
/** The transport to use. Defaults to the SDK's `fetchWithTimeout` (15 s per request, per hop). */
|
|
43
|
+
readonly fetch?: (url: string, init: RequestInit) => Promise<Response>;
|
|
44
|
+
/**
|
|
45
|
+
* Refuse a first request to a plain-http, non-loopback origin. Defaults to true. The JSON GET
|
|
46
|
+
* client turns it off: its callers have always been allowed a self-hosted http base, so only
|
|
47
|
+
* what a redirect points at is the new decision.
|
|
48
|
+
*/
|
|
49
|
+
readonly requireSecureBase?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type Transport = (url: string, init: RequestInit) => Promise<Response>;
|
|
53
|
+
|
|
54
|
+
/** Phase 2: send one hop with `redirect: "manual"`, so no runtime follows anything on its own. */
|
|
55
|
+
function sendHop(doFetch: Transport, url: URL, init: RequestInit): Promise<Response> {
|
|
56
|
+
return doFetch(url.toString(), { ...init, redirect: "manual" });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Phase 3a: a redirect on a mutation is never followed, and the credential never goes along. */
|
|
60
|
+
function assertReadRedirect(label: string, method: string, status: number): void {
|
|
61
|
+
if (method === "GET" || method === "HEAD") return;
|
|
62
|
+
throw new Error(
|
|
63
|
+
`${label} answered a ${method} with a redirect (${String(status)}); the write may or may not have been applied`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Phase 3b: the `Location` the redirect names. */
|
|
68
|
+
function requireLocation(label: string, res: Response): string {
|
|
69
|
+
const location = res.headers.get("location");
|
|
70
|
+
if (location === null || location === "") {
|
|
71
|
+
throw new Error(`${label} sent a redirect without a Location; refused`);
|
|
72
|
+
}
|
|
73
|
+
return location;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Phase 3c: at most {@link MAX_REDIRECT_HOPS} followed redirects; `hop` counts those already followed. */
|
|
77
|
+
function assertHopBudget(label: string, hop: number): void {
|
|
78
|
+
if (hop >= MAX_REDIRECT_HOPS) throw new Error(`${label} sent too many redirects; refused`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Phase 3d: resolve `location` against the URL that answered. */
|
|
82
|
+
function resolveNext(label: string, location: string, current: URL): URL {
|
|
83
|
+
try {
|
|
84
|
+
return new URL(location, current);
|
|
85
|
+
} catch {
|
|
86
|
+
throw new Error(`${label} redirected to an invalid URL; refused`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Phase 3e: the target must be the same origin (a plain-http base was only reachable with
|
|
92
|
+
* requireSecureBase off, or is loopback), plus ONE widening: the same host's http -> https upgrade.
|
|
93
|
+
* Never https -> http; anything else is refused and nothing is sent there.
|
|
94
|
+
*/
|
|
95
|
+
function assertSameOriginOrUpgrade(label: string, current: URL, next: URL): void {
|
|
96
|
+
const sameOrigin = next.origin === current.origin;
|
|
97
|
+
const httpsUpgrade =
|
|
98
|
+
current.protocol === "http:" &&
|
|
99
|
+
next.protocol === "https:" &&
|
|
100
|
+
next.hostname === current.hostname &&
|
|
101
|
+
next.port === current.port;
|
|
102
|
+
if (!sameOrigin && !httpsUpgrade) {
|
|
103
|
+
throw new Error(`${label} redirected to another origin; refused`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `fetch(url, init)` with the redirect policy above. `label` names the service in a refusal
|
|
109
|
+
* ("GitLab redirected a POST; refused"). Resolves to the final non-redirect response.
|
|
110
|
+
*
|
|
111
|
+
* The order of the checks is the policy; read it top to bottom in the loop.
|
|
112
|
+
*/
|
|
113
|
+
export async function guardedFetch(
|
|
114
|
+
label: string,
|
|
115
|
+
url: string,
|
|
116
|
+
init: RequestInit = {},
|
|
117
|
+
options: GuardedFetchOptions = {},
|
|
118
|
+
): Promise<Response> {
|
|
119
|
+
const method = (init.method ?? "GET").toUpperCase();
|
|
120
|
+
const doFetch: Transport =
|
|
121
|
+
options.fetch ?? ((u: string, i: RequestInit) => fetchWithTimeout(u, i, DEFAULT_TIMEOUT_MS));
|
|
122
|
+
let current = new URL(url);
|
|
123
|
+
// Phase 1: the base itself.
|
|
124
|
+
if (options.requireSecureBase !== false) assertSecureTransport(label, current);
|
|
125
|
+
for (let hop = 0; ; hop += 1) {
|
|
126
|
+
const res = await sendHop(doFetch, current, init);
|
|
127
|
+
if (!REDIRECT_STATUSES.has(res.status)) return res;
|
|
128
|
+
assertReadRedirect(label, method, res.status);
|
|
129
|
+
const location = requireLocation(label, res);
|
|
130
|
+
assertHopBudget(label, hop);
|
|
131
|
+
const next = resolveNext(label, location, current);
|
|
132
|
+
assertSameOriginOrUpgrade(label, current, next);
|
|
133
|
+
current = next;
|
|
134
|
+
}
|
|
135
|
+
}
|