@zereight/mcp-gitlab 2.1.62 → 2.1.63
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/README.ko.md +1 -1
- package/README.md +5 -1
- package/README.zh-CN.md +5 -1
- package/build/config.js +7 -0
- package/build/index.js +94 -20
- package/build/masking/engine.js +404 -0
- package/build/masking/index.js +2 -0
- package/build/masking/project-scope.js +53 -0
- package/build/schemas.js +4 -0
- package/build/test/get-merge-request-discussion-schema.test.js +30 -0
- package/build/test/response-masking.test.js +412 -0
- package/build/test/test-toolset-filtering.js +1 -1
- package/build/test/utils/masking.test.js +384 -0
- package/build/tools/registry.js +8 -1
- package/build/tools/tool-descriptions.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const BUILTIN_RULES = [
|
|
4
|
+
{
|
|
5
|
+
id: "gitlab-token",
|
|
6
|
+
type: "regex",
|
|
7
|
+
// GitLab token prefixes documented at
|
|
8
|
+
// https://docs.gitlab.com/security/token_overview/#token-prefixes
|
|
9
|
+
pattern: "\\bgl(?:pat|rt|ptt|dt|cbt|soat|oas|agent|ft|imt)-[A-Za-z0-9_-]{20,}\\b",
|
|
10
|
+
flags: "g",
|
|
11
|
+
replacement: "[Token masked]",
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
id: "ipv4",
|
|
15
|
+
type: "regex",
|
|
16
|
+
pattern: "\\b(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)){3}\\b",
|
|
17
|
+
flags: "g",
|
|
18
|
+
replacement: "[IP address]",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: "ipv6",
|
|
22
|
+
type: "regex",
|
|
23
|
+
pattern: "(?<![0-9A-Fa-f:])(?:(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,7}:|(?:[0-9A-Fa-f]{1,4}:){1,6}:[0-9A-Fa-f]{1,4}|(?:[0-9A-Fa-f]{1,4}:){1,5}(?::[0-9A-Fa-f]{1,4}){1,2}|(?:[0-9A-Fa-f]{1,4}:){1,4}(?::[0-9A-Fa-f]{1,4}){1,3}|(?:[0-9A-Fa-f]{1,4}:){1,3}(?::[0-9A-Fa-f]{1,4}){1,4}|(?:[0-9A-Fa-f]{1,4}:){1,2}(?::[0-9A-Fa-f]{1,4}){1,5}|[0-9A-Fa-f]{1,4}:(?:(?::[0-9A-Fa-f]{1,4}){1,6})|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|:))(?![0-9A-Fa-f:])",
|
|
24
|
+
flags: "g",
|
|
25
|
+
replacement: "[IP address]",
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
const ALLOWED_REGEX_FLAGS = new Set(["g", "i", "m", "s", "u"]);
|
|
29
|
+
function assertString(value, field) {
|
|
30
|
+
if (typeof value !== "string")
|
|
31
|
+
throw new Error(`${field} must be a string`);
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
function compileRule(rule, order) {
|
|
35
|
+
if (!rule || typeof rule !== "object")
|
|
36
|
+
throw new Error(`Invalid mask rule at index ${order}`);
|
|
37
|
+
const id = assertString(rule.id, `mask rule ${order}.id`);
|
|
38
|
+
if (!id)
|
|
39
|
+
throw new Error(`mask rule ${order}.id must not be empty`);
|
|
40
|
+
const replacement = assertString(rule.replacement, `mask rule ${id}.replacement`);
|
|
41
|
+
let source;
|
|
42
|
+
let flags;
|
|
43
|
+
if (rule.type === "keyword") {
|
|
44
|
+
const match = assertString(rule.match, `mask rule ${id}.match`);
|
|
45
|
+
if (!match)
|
|
46
|
+
throw new Error(`mask rule ${id}.match must not be empty`);
|
|
47
|
+
source = escapeRegExp(match);
|
|
48
|
+
flags = rule.caseSensitive === false ? "gi" : "g";
|
|
49
|
+
}
|
|
50
|
+
else if (rule.type === "regex") {
|
|
51
|
+
source = assertString(rule.pattern, `mask rule ${id}.pattern`);
|
|
52
|
+
flags = rule.flags ?? "g";
|
|
53
|
+
if (!flags.includes("g"))
|
|
54
|
+
flags += "g";
|
|
55
|
+
if ([...flags].some(flag => !ALLOWED_REGEX_FLAGS.has(flag))) {
|
|
56
|
+
throw new Error(`mask rule ${id}.flags contains an unsupported flag`);
|
|
57
|
+
}
|
|
58
|
+
if (source.length === 0)
|
|
59
|
+
throw new Error(`mask rule ${id}.pattern must not be empty`);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
throw new Error(`mask rule ${id}.type must be "keyword" or "regex"`);
|
|
63
|
+
}
|
|
64
|
+
let expression;
|
|
65
|
+
try {
|
|
66
|
+
expression = new RegExp(source, flags);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
throw new Error(`Invalid regular expression in mask rule ${id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
70
|
+
}
|
|
71
|
+
// Empty matches can make a rule unexpectedly rewrite every character and are
|
|
72
|
+
// particularly easy to create accidentally with a configurable expression.
|
|
73
|
+
if (expression.test(""))
|
|
74
|
+
throw new Error(`mask rule ${id} must not match an empty string`);
|
|
75
|
+
return { id, replacement, expression, order };
|
|
76
|
+
}
|
|
77
|
+
function escapeRegExp(value) {
|
|
78
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
79
|
+
}
|
|
80
|
+
function mergeBuiltinRules(file) {
|
|
81
|
+
const overrides = file?.builtins ?? {};
|
|
82
|
+
return BUILTIN_RULES.flatMap(rule => {
|
|
83
|
+
const override = overrides[rule.id];
|
|
84
|
+
if (override?.enabled === false)
|
|
85
|
+
return [];
|
|
86
|
+
return [
|
|
87
|
+
{
|
|
88
|
+
...rule,
|
|
89
|
+
...(override?.replacement !== undefined ? { replacement: override.replacement } : {}),
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function loadFile(configPath) {
|
|
95
|
+
let parsed;
|
|
96
|
+
try {
|
|
97
|
+
parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
throw new Error(`Unable to load masking config ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
101
|
+
}
|
|
102
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
103
|
+
throw new Error(`Masking config ${configPath} must contain a JSON object`);
|
|
104
|
+
}
|
|
105
|
+
if (isManagedReference(parsed)) {
|
|
106
|
+
const value = parsed;
|
|
107
|
+
if (value.version !== 2 || typeof value.policyGroup !== "string" || !value.policyGroup.trim()) {
|
|
108
|
+
throw new Error(`Managed masking config ${configPath} requires version 2 and a non-empty policyGroup`);
|
|
109
|
+
}
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
validateMaskingFile(parsed, `Masking config ${configPath}`);
|
|
113
|
+
return parsed;
|
|
114
|
+
}
|
|
115
|
+
function loadJsonFile(configPath) {
|
|
116
|
+
try {
|
|
117
|
+
return JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
throw new Error(`Unable to load masking config ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function normalizeGitLabInstance(value) {
|
|
124
|
+
let url;
|
|
125
|
+
try {
|
|
126
|
+
url = new URL(value);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
throw new Error(`Invalid managed masking GitLab instance: ${value}`);
|
|
130
|
+
}
|
|
131
|
+
return `${url.protocol}//${url.host}${url.pathname.replace(/\/api\/v4\/?$/, "").replace(/\/+$/, "")}`;
|
|
132
|
+
}
|
|
133
|
+
function isManagedReference(value) {
|
|
134
|
+
return Boolean(value && typeof value === "object" && value.mode === "managed");
|
|
135
|
+
}
|
|
136
|
+
function loadManagedPolicyFile(configPath) {
|
|
137
|
+
const parsed = loadJsonFile(configPath);
|
|
138
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
139
|
+
throw new Error(`Managed masking policy file ${configPath} must contain a JSON object`);
|
|
140
|
+
}
|
|
141
|
+
const file = parsed;
|
|
142
|
+
if (file.version !== 1)
|
|
143
|
+
throw new Error("Unsupported managed masking policy version");
|
|
144
|
+
if (!file.policyGroups ||
|
|
145
|
+
typeof file.policyGroups !== "object" ||
|
|
146
|
+
Array.isArray(file.policyGroups)) {
|
|
147
|
+
throw new Error("Managed masking policy file policyGroups must be an object");
|
|
148
|
+
}
|
|
149
|
+
if (!Array.isArray(file.bindings))
|
|
150
|
+
throw new Error("Managed masking policy file bindings must be an array");
|
|
151
|
+
if (file.unboundProjectBehavior !== undefined &&
|
|
152
|
+
file.unboundProjectBehavior !== "deny" &&
|
|
153
|
+
file.unboundProjectBehavior !== "builtin") {
|
|
154
|
+
throw new Error('Managed masking policy file unboundProjectBehavior must be "deny" or "builtin"');
|
|
155
|
+
}
|
|
156
|
+
for (const [name, policy] of Object.entries(file.policyGroups)) {
|
|
157
|
+
if (!name.trim())
|
|
158
|
+
throw new Error("Managed masking policy group names must not be empty");
|
|
159
|
+
validateMaskingFile(policy, `managed masking policy group ${name}`);
|
|
160
|
+
}
|
|
161
|
+
for (const binding of file.bindings) {
|
|
162
|
+
if (!binding ||
|
|
163
|
+
typeof binding !== "object" ||
|
|
164
|
+
typeof binding.gitlabInstance !== "string" ||
|
|
165
|
+
typeof binding.policyGroup !== "string" ||
|
|
166
|
+
!Array.isArray(binding.projectIds)) {
|
|
167
|
+
throw new Error("Invalid managed masking policy binding");
|
|
168
|
+
}
|
|
169
|
+
normalizeGitLabInstance(binding.gitlabInstance);
|
|
170
|
+
if (!file.policyGroups[binding.policyGroup]) {
|
|
171
|
+
throw new Error(`Managed masking policy binding references unknown group: ${binding.policyGroup}`);
|
|
172
|
+
}
|
|
173
|
+
if (binding.projectIds.some(id => !Number.isSafeInteger(id) || id <= 0)) {
|
|
174
|
+
throw new Error("Managed masking policy binding projectIds must be positive integers");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return file;
|
|
178
|
+
}
|
|
179
|
+
function validateMaskingFile(value, description) {
|
|
180
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
181
|
+
throw new Error(`${description} must contain a JSON object`);
|
|
182
|
+
}
|
|
183
|
+
const file = value;
|
|
184
|
+
if (file.version !== undefined && file.version !== 1) {
|
|
185
|
+
throw new Error(`Unsupported masking config version: ${String(file.version)}`);
|
|
186
|
+
}
|
|
187
|
+
if (file.rules !== undefined && !Array.isArray(file.rules)) {
|
|
188
|
+
throw new Error(`${description}.rules must be an array`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function maskStrings(value, mask, parentKey) {
|
|
192
|
+
if (typeof value === "string") {
|
|
193
|
+
const masked = mask(value);
|
|
194
|
+
return masked !== value && parentKey === "download_url" ? "[Download URL masked]" : masked;
|
|
195
|
+
}
|
|
196
|
+
if (Array.isArray(value))
|
|
197
|
+
return value.map(item => maskStrings(item, mask, parentKey));
|
|
198
|
+
if (value && typeof value === "object") {
|
|
199
|
+
const output = {};
|
|
200
|
+
for (const [key, item] of Object.entries(value))
|
|
201
|
+
output[key] = maskStrings(item, mask, key);
|
|
202
|
+
return output;
|
|
203
|
+
}
|
|
204
|
+
return value;
|
|
205
|
+
}
|
|
206
|
+
function maskJsonText(text, mask) {
|
|
207
|
+
try {
|
|
208
|
+
const parsed = JSON.parse(text);
|
|
209
|
+
if (!parsed || typeof parsed !== "object")
|
|
210
|
+
return mask(text);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return mask(text);
|
|
214
|
+
}
|
|
215
|
+
// Rewrite only JSON string values. Re-serializing the parsed object would
|
|
216
|
+
// change whitespace and lose precision in numeric values from GitLab.
|
|
217
|
+
let propertyKey;
|
|
218
|
+
return text.replace(/"(?:\\.|[^"\\])*"/g, (literal, offset) => {
|
|
219
|
+
const value = JSON.parse(literal);
|
|
220
|
+
if (/^\s*:/.test(text.slice(offset + literal.length))) {
|
|
221
|
+
propertyKey = value;
|
|
222
|
+
return literal;
|
|
223
|
+
}
|
|
224
|
+
const masked = mask(value);
|
|
225
|
+
if (masked === value)
|
|
226
|
+
return literal;
|
|
227
|
+
const isDownloadUrl = propertyKey === "download_url" && /:\s*$/.test(text.slice(0, offset));
|
|
228
|
+
return JSON.stringify(isDownloadUrl ? "[Download URL masked]" : masked);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
function createMaskingEngineFromFile(file, configPath) {
|
|
232
|
+
const rules = [...(file.rules ?? []), ...mergeBuiltinRules(file)].map(compileRule);
|
|
233
|
+
const maskedErrors = new WeakSet();
|
|
234
|
+
const maskText = (text) => {
|
|
235
|
+
const matches = [];
|
|
236
|
+
for (const rule of rules) {
|
|
237
|
+
rule.expression.lastIndex = 0;
|
|
238
|
+
let match;
|
|
239
|
+
while ((match = rule.expression.exec(text)) !== null) {
|
|
240
|
+
const start = match.index;
|
|
241
|
+
const end = start + match[0].length;
|
|
242
|
+
if (end === start)
|
|
243
|
+
throw new Error(`mask rule ${rule.id} produced an empty match`);
|
|
244
|
+
matches.push({ start, end, replacement: rule.replacement, order: rule.order });
|
|
245
|
+
if (!rule.expression.global)
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (matches.length === 0)
|
|
250
|
+
return text;
|
|
251
|
+
matches.sort((a, b) => a.start - b.start || b.end - b.start - (a.end - a.start) || a.order - b.order);
|
|
252
|
+
const coveredMatches = [];
|
|
253
|
+
for (const match of matches) {
|
|
254
|
+
const previous = coveredMatches[coveredMatches.length - 1];
|
|
255
|
+
if (previous && match.start < previous.end) {
|
|
256
|
+
// Preserve the selected replacement, but cover the entire overlapping
|
|
257
|
+
// interval so a longer secret cannot survive behind a shorter match.
|
|
258
|
+
previous.end = Math.max(previous.end, match.end);
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
coveredMatches.push({ ...match });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
let output = "";
|
|
265
|
+
let cursor = 0;
|
|
266
|
+
for (const match of coveredMatches) {
|
|
267
|
+
output += text.slice(cursor, match.start) + match.replacement;
|
|
268
|
+
cursor = match.end;
|
|
269
|
+
}
|
|
270
|
+
return output + text.slice(cursor);
|
|
271
|
+
};
|
|
272
|
+
return {
|
|
273
|
+
configPath,
|
|
274
|
+
maskText,
|
|
275
|
+
maskValue: (value) => maskStrings(value, maskText),
|
|
276
|
+
maskToolResult: (result, options) => {
|
|
277
|
+
const output = { ...result };
|
|
278
|
+
if (Array.isArray(output.content))
|
|
279
|
+
output.content = output.content.map(item => {
|
|
280
|
+
if (!item || typeof item !== "object")
|
|
281
|
+
return item;
|
|
282
|
+
const block = { ...item };
|
|
283
|
+
if (typeof block.text === "string") {
|
|
284
|
+
block.text =
|
|
285
|
+
options?.textFormat === "plain"
|
|
286
|
+
? maskText(block.text)
|
|
287
|
+
: maskJsonText(block.text, maskText);
|
|
288
|
+
}
|
|
289
|
+
return block;
|
|
290
|
+
});
|
|
291
|
+
if (output.structuredContent !== undefined)
|
|
292
|
+
output.structuredContent = maskStrings(output.structuredContent, maskText);
|
|
293
|
+
return output;
|
|
294
|
+
},
|
|
295
|
+
maskError: (error) => {
|
|
296
|
+
if (error instanceof Error && !maskedErrors.has(error)) {
|
|
297
|
+
error.message = maskText(error.message);
|
|
298
|
+
maskedErrors.add(error);
|
|
299
|
+
}
|
|
300
|
+
return error;
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
export function createMaskingEngine(options) {
|
|
305
|
+
if (!options.enabled)
|
|
306
|
+
return undefined;
|
|
307
|
+
const workspaceDir = path.resolve(options.workspaceDir ?? process.cwd());
|
|
308
|
+
const configPath = options.configPath
|
|
309
|
+
? path.resolve(workspaceDir, options.configPath)
|
|
310
|
+
: path.join(workspaceDir, ".gitlab-mcp-mask.json");
|
|
311
|
+
if (options.configPath && !fs.existsSync(configPath)) {
|
|
312
|
+
throw new Error(`Configured masking config does not exist: ${configPath}`);
|
|
313
|
+
}
|
|
314
|
+
const file = fs.existsSync(configPath) ? loadFile(configPath) : undefined;
|
|
315
|
+
if (isManagedReference(file))
|
|
316
|
+
throw new Error("Managed masking references require a managed policy resolver");
|
|
317
|
+
return createMaskingEngineFromFile(file ?? {}, fs.existsSync(configPath) ? configPath : undefined);
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Selects a compiled policy for each tool request. In managed mode this keeps
|
|
321
|
+
* the secret rules on the server while the workspace carries only a group name.
|
|
322
|
+
*/
|
|
323
|
+
export function createMaskingPolicyResolver(options) {
|
|
324
|
+
if (!options.enabled)
|
|
325
|
+
return undefined;
|
|
326
|
+
const workspaceDir = path.resolve(options.workspaceDir ?? process.cwd());
|
|
327
|
+
const localConfigPath = options.configPath
|
|
328
|
+
? path.resolve(workspaceDir, options.configPath)
|
|
329
|
+
: path.join(workspaceDir, ".gitlab-mcp-mask.json");
|
|
330
|
+
if (options.configPath && !fs.existsSync(localConfigPath)) {
|
|
331
|
+
throw new Error(`Configured masking config does not exist: ${localConfigPath}`);
|
|
332
|
+
}
|
|
333
|
+
const localConfig = fs.existsSync(localConfigPath) ? loadFile(localConfigPath) : undefined;
|
|
334
|
+
const localGroup = isManagedReference(localConfig) ? localConfig.policyGroup : undefined;
|
|
335
|
+
if (!options.policyFilePath) {
|
|
336
|
+
if (localGroup)
|
|
337
|
+
throw new Error("Managed masking config requires GITLAB_MASKING_POLICY_FILE");
|
|
338
|
+
const localEngine = createMaskingEngineFromFile(localConfig ?? {}, localConfigPath);
|
|
339
|
+
return { managed: false, select: () => localEngine };
|
|
340
|
+
}
|
|
341
|
+
const policyPath = path.resolve(workspaceDir, options.policyFilePath);
|
|
342
|
+
const policyFile = loadManagedPolicyFile(policyPath);
|
|
343
|
+
if (localGroup && !policyFile.policyGroups[localGroup]) {
|
|
344
|
+
throw new Error(`Managed masking config references unknown policy group: ${localGroup}`);
|
|
345
|
+
}
|
|
346
|
+
const engines = new Map();
|
|
347
|
+
for (const [name, policy] of Object.entries(policyFile.policyGroups)) {
|
|
348
|
+
engines.set(name, createMaskingEngineFromFile(policy, policyPath));
|
|
349
|
+
}
|
|
350
|
+
const builtinEngine = createMaskingEngineFromFile({}, policyPath);
|
|
351
|
+
const bindings = new Map();
|
|
352
|
+
for (const binding of policyFile.bindings) {
|
|
353
|
+
const instance = normalizeGitLabInstance(binding.gitlabInstance);
|
|
354
|
+
for (const projectId of binding.projectIds) {
|
|
355
|
+
const key = `${instance}#${projectId}`;
|
|
356
|
+
const existing = bindings.get(key);
|
|
357
|
+
if (existing && existing !== binding.policyGroup) {
|
|
358
|
+
throw new Error(`Managed masking policy has conflicting bindings for ${key}`);
|
|
359
|
+
}
|
|
360
|
+
bindings.set(key, binding.policyGroup);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
managed: true,
|
|
365
|
+
select: ({ gitlabInstance, projectIds = [], hasProjectScope = projectIds.length > 0, allowBuiltinForUnscoped = false, }) => {
|
|
366
|
+
const numericProjectIds = projectIds.map(projectId => {
|
|
367
|
+
if (typeof projectId === "number" && Number.isSafeInteger(projectId) && projectId > 0) {
|
|
368
|
+
return projectId;
|
|
369
|
+
}
|
|
370
|
+
if (typeof projectId === "string" && /^\d+$/.test(projectId)) {
|
|
371
|
+
const numericProjectId = Number(projectId);
|
|
372
|
+
if (Number.isSafeInteger(numericProjectId) && numericProjectId > 0)
|
|
373
|
+
return numericProjectId;
|
|
374
|
+
}
|
|
375
|
+
throw new Error("Managed masking requires numeric project IDs");
|
|
376
|
+
});
|
|
377
|
+
if (numericProjectIds.length === 0) {
|
|
378
|
+
if (hasProjectScope)
|
|
379
|
+
throw new Error("Managed masking requires a project ID");
|
|
380
|
+
if (allowBuiltinForUnscoped || policyFile.unboundProjectBehavior === "builtin")
|
|
381
|
+
return builtinEngine;
|
|
382
|
+
throw new Error("Managed masking policy does not cover this project-less tool");
|
|
383
|
+
}
|
|
384
|
+
const groups = new Set();
|
|
385
|
+
for (const projectId of numericProjectIds) {
|
|
386
|
+
const group = bindings.get(`${normalizeGitLabInstance(gitlabInstance)}#${projectId}`);
|
|
387
|
+
if (!group)
|
|
388
|
+
throw new Error("No managed masking policy is bound to this GitLab project");
|
|
389
|
+
groups.add(group);
|
|
390
|
+
}
|
|
391
|
+
if (groups.size !== 1) {
|
|
392
|
+
throw new Error("Managed masking does not allow a tool call to span policy groups");
|
|
393
|
+
}
|
|
394
|
+
const [group] = groups;
|
|
395
|
+
if (localGroup && group !== localGroup) {
|
|
396
|
+
throw new Error("Managed masking policy group does not match the server project binding");
|
|
397
|
+
}
|
|
398
|
+
const engine = engines.get(group);
|
|
399
|
+
if (!engine)
|
|
400
|
+
throw new Error(`Managed masking policy group is unavailable: ${group}`);
|
|
401
|
+
return engine;
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
function isRecord(value) {
|
|
2
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
3
|
+
}
|
|
4
|
+
function collectDeclaredProjectIds(schema, value, projectIds) {
|
|
5
|
+
if (!isRecord(value))
|
|
6
|
+
return;
|
|
7
|
+
for (const [field, fieldSchema] of Object.entries(schema.properties ?? {})) {
|
|
8
|
+
const fieldValue = value[field];
|
|
9
|
+
if (field === "project_id" || field.endsWith("_project_id")) {
|
|
10
|
+
if (fieldValue !== undefined && fieldValue !== null && fieldValue !== "")
|
|
11
|
+
projectIds.push(fieldValue);
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
if (Array.isArray(fieldValue) && fieldSchema.items) {
|
|
15
|
+
for (const item of fieldValue)
|
|
16
|
+
collectDeclaredProjectIds(fieldSchema.items, item, projectIds);
|
|
17
|
+
}
|
|
18
|
+
else if (fieldSchema.properties) {
|
|
19
|
+
collectDeclaredProjectIds(fieldSchema, fieldValue, projectIds);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Return project identifiers declared by the tool schema, including nested
|
|
25
|
+
* request objects that the tool can use to access another project. This avoids
|
|
26
|
+
* both accepting undeclared arguments and overlooking a cross-project action.
|
|
27
|
+
*/
|
|
28
|
+
export function getManagedMaskingProjectIds(inputSchema, argumentsValue, getDefaultProjectId) {
|
|
29
|
+
const properties = inputSchema?.properties;
|
|
30
|
+
if (!properties)
|
|
31
|
+
return { hasProjectScope: false, projectIds: [] };
|
|
32
|
+
const args = isRecord(argumentsValue) ? argumentsValue : {};
|
|
33
|
+
const projectIds = [];
|
|
34
|
+
collectDeclaredProjectIds({ properties }, args, projectIds);
|
|
35
|
+
const hasPrimaryProjectField = Object.hasOwn(properties, "project_id");
|
|
36
|
+
const primaryProjectIsMissing = args.project_id === undefined || args.project_id === null || args.project_id === "";
|
|
37
|
+
// A group-scoped tool may declare project_id as an alternative. Do not turn
|
|
38
|
+
// that absent optional field into the server default when group_id was chosen.
|
|
39
|
+
const hasAlternativeScope = primaryProjectIsMissing &&
|
|
40
|
+
Object.keys(properties).some(field => (field === "group_id" || field.endsWith("_group_id")) && args[field] !== undefined);
|
|
41
|
+
// A project filter on a group query does not establish the scope of all
|
|
42
|
+
// projects contributing to its response.
|
|
43
|
+
if (hasAlternativeScope)
|
|
44
|
+
return { hasProjectScope: false, projectIds: [] };
|
|
45
|
+
if (hasPrimaryProjectField && primaryProjectIsMissing && getDefaultProjectId) {
|
|
46
|
+
projectIds.unshift(getDefaultProjectId());
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
hasProjectScope: projectIds.length > 0 ||
|
|
50
|
+
(inputSchema?.required ?? []).some(field => field === "project_id" || field.endsWith("_project_id")),
|
|
51
|
+
projectIds,
|
|
52
|
+
};
|
|
53
|
+
}
|
package/build/schemas.js
CHANGED
|
@@ -1720,6 +1720,10 @@ export const GetMergeRequestNoteSchema = ProjectParamsSchema.extend({
|
|
|
1720
1720
|
merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
|
|
1721
1721
|
note_id: z.coerce.string().describe("The ID of a thread note"),
|
|
1722
1722
|
});
|
|
1723
|
+
export const GetMergeRequestDiscussionSchema = ProjectParamsSchema.extend({
|
|
1724
|
+
merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
|
|
1725
|
+
discussion_id: z.coerce.string().describe("The ID of a thread"),
|
|
1726
|
+
});
|
|
1723
1727
|
// Input schema for updating merge request notes
|
|
1724
1728
|
export const UpdateMergeRequestNoteSchema = ProjectParamsSchema.extend({
|
|
1725
1729
|
merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { GetMergeRequestDiscussionSchema, GitLabDiscussionSchema } from "../schemas.js";
|
|
4
|
+
test("get discussion input coerces numeric ids and keeps discussion id", () => {
|
|
5
|
+
const parsed = GetMergeRequestDiscussionSchema.parse({
|
|
6
|
+
project_id: 42,
|
|
7
|
+
merge_request_iid: 7,
|
|
8
|
+
discussion_id: "abc123",
|
|
9
|
+
});
|
|
10
|
+
assert.equal(parsed.project_id, "42");
|
|
11
|
+
assert.equal(parsed.merge_request_iid, "7");
|
|
12
|
+
assert.equal(parsed.discussion_id, "abc123");
|
|
13
|
+
});
|
|
14
|
+
test("get discussion input rejects a missing discussion id", () => {
|
|
15
|
+
assert.throws(() => GetMergeRequestDiscussionSchema.parse({
|
|
16
|
+
project_id: "42",
|
|
17
|
+
merge_request_iid: "7",
|
|
18
|
+
}));
|
|
19
|
+
});
|
|
20
|
+
test("single discussion response parses the GitLab discussion item shape", () => {
|
|
21
|
+
const parsed = GitLabDiscussionSchema.parse({
|
|
22
|
+
id: "abc123",
|
|
23
|
+
individual_note: false,
|
|
24
|
+
notes: [{ id: 1, body: "please fix", resolvable: true, resolved: false }],
|
|
25
|
+
});
|
|
26
|
+
assert.equal(parsed.id, "abc123");
|
|
27
|
+
assert.equal(parsed.individual_note, false);
|
|
28
|
+
assert.equal(parsed.notes.length, 1);
|
|
29
|
+
assert.equal(parsed.notes[0].body, "please fix");
|
|
30
|
+
});
|