@reproapp/mcp 0.0.1
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.md +183 -0
- package/package.json +42 -0
- package/src/index.js +3728 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,3728 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const childProcess = require('node:child_process');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const http = require('node:http');
|
|
7
|
+
const os = require('node:os');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
|
|
10
|
+
const SERVER_NAME = 'repro-mcp';
|
|
11
|
+
const SERVER_VERSION = '0.0.1';
|
|
12
|
+
const SUPPORTED_PROTOCOL_VERSION = '2024-11-05';
|
|
13
|
+
const JSON_RPC_VERSION = '2.0';
|
|
14
|
+
const DEFAULT_REPRO_AI_BASE_URL = 'http://10.116.0.4';
|
|
15
|
+
const DEFAULT_REPRO_API_BASE_URL = 'http://10.116.0.5/api';
|
|
16
|
+
const DEFAULT_REPRO_WEB_BASE_URL = 'https://getrepro.io';
|
|
17
|
+
loadEnvironmentFiles(process.env);
|
|
18
|
+
const DEBUG_LOG_PATH = process.env.REPRO_MCP_DEBUG_LOG;
|
|
19
|
+
const DEFAULT_AUTH_TIMEOUT_MS = 2 * 60 * 1000;
|
|
20
|
+
const DEFAULT_AUTH_POLL_INTERVAL_MS = 1500;
|
|
21
|
+
const LOCAL_TOOL_DEFINITIONS = [
|
|
22
|
+
{
|
|
23
|
+
name: 'resolve_scope',
|
|
24
|
+
description:
|
|
25
|
+
'Resolve Repro scope for a high-level request. For app-only privacy questions, returns app policy context and Privacy Studio links without requiring the app to match the authenticated default app. For session-scoped debugging, delegates to the backend resolver.',
|
|
26
|
+
inputSchema: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: {
|
|
29
|
+
tenantId: { type: 'string' },
|
|
30
|
+
appId: { type: 'string' },
|
|
31
|
+
sessionId: { type: 'string' },
|
|
32
|
+
environment: { type: 'string' },
|
|
33
|
+
state: { type: 'string', enum: ['active', 'pending', 'approved'] },
|
|
34
|
+
query: { type: 'string' },
|
|
35
|
+
openUi: { type: 'boolean' },
|
|
36
|
+
},
|
|
37
|
+
additionalProperties: true,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'check_privacy_coverage',
|
|
42
|
+
description:
|
|
43
|
+
'Answer high-level app privacy coverage questions across one or more apps, such as opaque-data coverage, without requiring users to know policy selectors, trace paths, or statement ids. Returns safe/needs-review/not-covered status and Privacy Studio links.',
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: {
|
|
47
|
+
tenantId: { type: 'string' },
|
|
48
|
+
appId: { type: 'string' },
|
|
49
|
+
appIds: {
|
|
50
|
+
type: 'array',
|
|
51
|
+
items: { type: 'string' },
|
|
52
|
+
},
|
|
53
|
+
environment: { type: 'string' },
|
|
54
|
+
state: { type: 'string', enum: ['active', 'pending', 'approved'] },
|
|
55
|
+
topic: { type: 'string' },
|
|
56
|
+
question: { type: 'string' },
|
|
57
|
+
includeTechnical: { type: 'boolean' },
|
|
58
|
+
openUi: { type: 'boolean' },
|
|
59
|
+
},
|
|
60
|
+
additionalProperties: true,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'inspect_privacy_policy',
|
|
65
|
+
description:
|
|
66
|
+
'Inspect pending or approved privacy policy statements for an app/service, answer what policy covers a high-level topic, function, selector, target, code fragment, or all review blockers. Returns user-facing code/policy anchors first, flags real same-leaf contradictions, and returns Privacy Studio URLs for review.',
|
|
67
|
+
inputSchema: {
|
|
68
|
+
type: 'object',
|
|
69
|
+
properties: {
|
|
70
|
+
tenantId: { type: 'string' },
|
|
71
|
+
appId: { type: 'string' },
|
|
72
|
+
environment: { type: 'string' },
|
|
73
|
+
state: { type: 'string', enum: ['active', 'pending', 'approved'] },
|
|
74
|
+
query: { type: 'string' },
|
|
75
|
+
functionName: { type: 'string' },
|
|
76
|
+
selector: { type: 'string' },
|
|
77
|
+
target: { type: 'string' },
|
|
78
|
+
code: { type: 'string' },
|
|
79
|
+
includeTechnical: { type: 'boolean' },
|
|
80
|
+
openUi: { type: 'boolean' },
|
|
81
|
+
},
|
|
82
|
+
additionalProperties: true,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'update_privacy_policy',
|
|
87
|
+
description:
|
|
88
|
+
'Find matching privacy policy statements from a high-level query or statement id, show a policy diff, and optionally stage/apply/approve changes. Prefer keep exact for known non-sensitive values or regex-assisted raw-text hints for sensitive formats; drop unsafe values; use safe partial mask only as a last-resort unknown-shape fallback.',
|
|
89
|
+
inputSchema: {
|
|
90
|
+
type: 'object',
|
|
91
|
+
properties: {
|
|
92
|
+
tenantId: { type: 'string' },
|
|
93
|
+
appId: { type: 'string' },
|
|
94
|
+
environment: { type: 'string' },
|
|
95
|
+
state: { type: 'string', enum: ['active', 'pending', 'approved'] },
|
|
96
|
+
query: { type: 'string' },
|
|
97
|
+
functionName: { type: 'string' },
|
|
98
|
+
selector: { type: 'string' },
|
|
99
|
+
target: { type: 'string' },
|
|
100
|
+
code: { type: 'string' },
|
|
101
|
+
statementSid: { type: 'string' },
|
|
102
|
+
statementSids: {
|
|
103
|
+
type: 'array',
|
|
104
|
+
items: { type: 'string' },
|
|
105
|
+
},
|
|
106
|
+
decision: {
|
|
107
|
+
type: 'string',
|
|
108
|
+
enum: ['SAFE_PARTIAL_MASK', 'KEEP_EXACT', 'DROP', 'REGEX_ASSISTED'],
|
|
109
|
+
},
|
|
110
|
+
rawTextHints: {
|
|
111
|
+
type: 'array',
|
|
112
|
+
items: {
|
|
113
|
+
type: 'object',
|
|
114
|
+
properties: {
|
|
115
|
+
name: { type: 'string' },
|
|
116
|
+
action: { type: 'string', enum: ['TOKENIZE', 'DROP'] },
|
|
117
|
+
regex: { type: 'string' },
|
|
118
|
+
tokenLabel: { type: 'string' },
|
|
119
|
+
},
|
|
120
|
+
additionalProperties: true,
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
apply: { type: 'boolean' },
|
|
124
|
+
approve: { type: 'boolean' },
|
|
125
|
+
allowMultiple: { type: 'boolean' },
|
|
126
|
+
openUi: { type: 'boolean' },
|
|
127
|
+
},
|
|
128
|
+
required: ['decision'],
|
|
129
|
+
additionalProperties: true,
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: 'approve_privacy_policy',
|
|
134
|
+
description:
|
|
135
|
+
'Approve the pending privacy policy for an app/environment through the Repro API validation path and return the updated policy state plus a Privacy Studio link.',
|
|
136
|
+
inputSchema: {
|
|
137
|
+
type: 'object',
|
|
138
|
+
properties: {
|
|
139
|
+
tenantId: { type: 'string' },
|
|
140
|
+
appId: { type: 'string' },
|
|
141
|
+
environment: { type: 'string' },
|
|
142
|
+
openUi: { type: 'boolean' },
|
|
143
|
+
},
|
|
144
|
+
additionalProperties: true,
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: 'privacy_audit_session',
|
|
149
|
+
description:
|
|
150
|
+
'Deterministically scan a session for privacy leaks and over-masked debugging values across requests, DB changes, and optionally traces.',
|
|
151
|
+
inputSchema: {
|
|
152
|
+
type: 'object',
|
|
153
|
+
properties: {
|
|
154
|
+
tenantId: { type: 'string' },
|
|
155
|
+
sessionId: { type: 'string' },
|
|
156
|
+
maxRequests: { type: 'number' },
|
|
157
|
+
maxDbChanges: { type: 'number' },
|
|
158
|
+
includeTraces: { type: 'boolean' },
|
|
159
|
+
},
|
|
160
|
+
required: ['sessionId'],
|
|
161
|
+
additionalProperties: true,
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
function loadConfig(env = process.env) {
|
|
167
|
+
const baseUrl = normalizeBaseUrl(env.REPRO_AI_BASE_URL || DEFAULT_REPRO_AI_BASE_URL);
|
|
168
|
+
const apiBaseUrl = normalizeOptionalBaseUrl(env.REPRO_API_BASE_URL, DEFAULT_REPRO_API_BASE_URL);
|
|
169
|
+
const publicApiBaseUrl = normalizeOptionalBaseUrl(env.REPRO_API_PUBLIC_BASE_URL, apiBaseUrl);
|
|
170
|
+
const webBaseUrl = normalizeOptionalBaseUrl(
|
|
171
|
+
env.REPRO_WEB_BASE_URL,
|
|
172
|
+
typeof env.REPRO_API_BASE_URL === 'string' && env.REPRO_API_BASE_URL.trim()
|
|
173
|
+
? deriveWebBaseUrl(apiBaseUrl)
|
|
174
|
+
: DEFAULT_REPRO_WEB_BASE_URL,
|
|
175
|
+
);
|
|
176
|
+
const envAiToken = typeof env.REPRO_AI_TOKEN === 'string' && env.REPRO_AI_TOKEN.trim()
|
|
177
|
+
? env.REPRO_AI_TOKEN.trim()
|
|
178
|
+
: undefined;
|
|
179
|
+
const envApiToken = typeof env.REPRO_API_TOKEN === 'string' && env.REPRO_API_TOKEN.trim()
|
|
180
|
+
? env.REPRO_API_TOKEN.trim()
|
|
181
|
+
: undefined;
|
|
182
|
+
const authFilePath = resolveAuthFilePath(env.REPRO_MCP_AUTH_FILE);
|
|
183
|
+
const storedAuth = readStoredAuth(authFilePath, apiBaseUrl);
|
|
184
|
+
const token = envAiToken || storedAuth?.accessToken;
|
|
185
|
+
const apiToken = envApiToken || storedAuth?.accessToken;
|
|
186
|
+
const hasEnvDefaultTenant =
|
|
187
|
+
typeof env.REPRO_DEFAULT_TENANT_ID === 'string' && env.REPRO_DEFAULT_TENANT_ID.trim();
|
|
188
|
+
const defaultTenantId =
|
|
189
|
+
hasEnvDefaultTenant
|
|
190
|
+
? env.REPRO_DEFAULT_TENANT_ID.trim()
|
|
191
|
+
: storedAuth?.tenantId
|
|
192
|
+
? storedAuth.tenantId
|
|
193
|
+
: undefined;
|
|
194
|
+
const hasEnvDefaultApp =
|
|
195
|
+
typeof env.REPRO_APP_ID === 'string' && env.REPRO_APP_ID.trim();
|
|
196
|
+
const defaultAppId =
|
|
197
|
+
hasEnvDefaultApp
|
|
198
|
+
? env.REPRO_APP_ID.trim()
|
|
199
|
+
: storedAuth?.appId
|
|
200
|
+
? storedAuth.appId
|
|
201
|
+
: undefined;
|
|
202
|
+
const requestTimeoutMs = parsePositiveInteger(env.REPRO_REQUEST_TIMEOUT_MS, 30000);
|
|
203
|
+
const investigateTimeoutMs = parsePositiveInteger(
|
|
204
|
+
env.REPRO_INVESTIGATE_TIMEOUT_MS,
|
|
205
|
+
Math.max(requestTimeoutMs, 90000),
|
|
206
|
+
);
|
|
207
|
+
const authTimeoutMs = parsePositiveInteger(env.REPRO_MCP_AUTH_TIMEOUT_MS, DEFAULT_AUTH_TIMEOUT_MS);
|
|
208
|
+
const authPollIntervalMs = parsePositiveInteger(
|
|
209
|
+
env.REPRO_MCP_AUTH_POLL_INTERVAL_MS,
|
|
210
|
+
DEFAULT_AUTH_POLL_INTERVAL_MS,
|
|
211
|
+
);
|
|
212
|
+
const autoAuth =
|
|
213
|
+
typeof env.REPRO_MCP_AUTO_AUTH === 'string'
|
|
214
|
+
? !['0', 'false', 'no', 'off'].includes(env.REPRO_MCP_AUTO_AUTH.trim().toLowerCase())
|
|
215
|
+
: true;
|
|
216
|
+
const clientName =
|
|
217
|
+
typeof env.REPRO_MCP_CLIENT_NAME === 'string' && env.REPRO_MCP_CLIENT_NAME.trim()
|
|
218
|
+
? env.REPRO_MCP_CLIENT_NAME.trim()
|
|
219
|
+
: 'Codex';
|
|
220
|
+
const pathMappings = parsePathMappings(env.REPRO_PATH_MAPPINGS);
|
|
221
|
+
const transport = normalizeTransport(env.REPRO_MCP_TRANSPORT, env);
|
|
222
|
+
const authMode = normalizeAuthMode(env.REPRO_MCP_AUTH_MODE, transport);
|
|
223
|
+
const openBrowser = parseBoolean(
|
|
224
|
+
env.REPRO_MCP_OPEN_BROWSER,
|
|
225
|
+
transport !== 'http',
|
|
226
|
+
'REPRO_MCP_OPEN_BROWSER',
|
|
227
|
+
);
|
|
228
|
+
const httpHost =
|
|
229
|
+
typeof env.REPRO_MCP_HTTP_HOST === 'string' && env.REPRO_MCP_HTTP_HOST.trim()
|
|
230
|
+
? env.REPRO_MCP_HTTP_HOST.trim()
|
|
231
|
+
: '0.0.0.0';
|
|
232
|
+
const httpPort = parsePositiveInteger(
|
|
233
|
+
env.REPRO_MCP_HTTP_PORT || env.PORT,
|
|
234
|
+
8080,
|
|
235
|
+
);
|
|
236
|
+
const httpPath = normalizeHttpPath(env.REPRO_MCP_HTTP_PATH);
|
|
237
|
+
const httpMaxBodyBytes = parsePositiveInteger(env.REPRO_MCP_HTTP_MAX_BODY_BYTES, 1024 * 1024);
|
|
238
|
+
const httpKeepAliveMs = parsePositiveInteger(env.REPRO_MCP_HTTP_KEEPALIVE_MS, 30000);
|
|
239
|
+
const corsOrigin =
|
|
240
|
+
typeof env.REPRO_MCP_CORS_ORIGIN === 'string' && env.REPRO_MCP_CORS_ORIGIN.trim()
|
|
241
|
+
? env.REPRO_MCP_CORS_ORIGIN.trim()
|
|
242
|
+
: '*';
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
baseUrl,
|
|
246
|
+
apiBaseUrl,
|
|
247
|
+
publicApiBaseUrl,
|
|
248
|
+
webBaseUrl,
|
|
249
|
+
token,
|
|
250
|
+
tokenSource: envAiToken ? 'env' : storedAuth?.accessToken ? 'file' : 'none',
|
|
251
|
+
apiToken,
|
|
252
|
+
apiTokenSource: envApiToken ? 'env' : storedAuth?.accessToken ? 'file' : 'none',
|
|
253
|
+
defaultTenantId,
|
|
254
|
+
defaultTenantIdSource: hasEnvDefaultTenant ? 'env' : storedAuth?.tenantId ? 'file' : 'none',
|
|
255
|
+
defaultAppId,
|
|
256
|
+
defaultAppIdSource: hasEnvDefaultApp ? 'env' : storedAuth?.appId ? 'file' : 'none',
|
|
257
|
+
requestTimeoutMs,
|
|
258
|
+
investigateTimeoutMs,
|
|
259
|
+
authFilePath,
|
|
260
|
+
authTimeoutMs,
|
|
261
|
+
authPollIntervalMs,
|
|
262
|
+
autoAuth,
|
|
263
|
+
clientName,
|
|
264
|
+
pathMappings,
|
|
265
|
+
transport,
|
|
266
|
+
authMode,
|
|
267
|
+
openBrowser,
|
|
268
|
+
httpHost,
|
|
269
|
+
httpPort,
|
|
270
|
+
httpPath,
|
|
271
|
+
httpMaxBodyBytes,
|
|
272
|
+
httpKeepAliveMs,
|
|
273
|
+
corsOrigin,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function normalizeBaseUrl(value) {
|
|
278
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
279
|
+
throw new Error('REPRO_AI_BASE_URL is required');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const trimmed = value.trim();
|
|
283
|
+
const normalized = trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
|
|
284
|
+
return new URL(normalized).toString();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function normalizeTransport(value, env = process.env) {
|
|
288
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
289
|
+
return typeof env.PORT === 'string' && env.PORT.trim() ? 'http' : 'stdio';
|
|
290
|
+
}
|
|
291
|
+
const normalized = value.trim().toLowerCase();
|
|
292
|
+
if (normalized === 'stdio' || normalized === 'http') {
|
|
293
|
+
return normalized;
|
|
294
|
+
}
|
|
295
|
+
throw new Error(`Unsupported REPRO_MCP_TRANSPORT: ${value}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function normalizeAuthMode(value, transport) {
|
|
299
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
300
|
+
return transport === 'http' ? 'prompt' : 'wait';
|
|
301
|
+
}
|
|
302
|
+
const normalized = value.trim().toLowerCase();
|
|
303
|
+
if (normalized === 'prompt' || normalized === 'wait') {
|
|
304
|
+
return normalized;
|
|
305
|
+
}
|
|
306
|
+
throw new Error(`Unsupported REPRO_MCP_AUTH_MODE: ${value}`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function normalizeHttpPath(value) {
|
|
310
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
311
|
+
return '/mcp';
|
|
312
|
+
}
|
|
313
|
+
const trimmed = value.trim();
|
|
314
|
+
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function loadEnvironmentFiles(env) {
|
|
318
|
+
const packageRoot = path.resolve(__dirname, '..');
|
|
319
|
+
const protectedKeys = new Set(Object.keys(env));
|
|
320
|
+
const explicitEnvFile =
|
|
321
|
+
typeof env.REPRO_MCP_ENV_FILE === 'string' && env.REPRO_MCP_ENV_FILE.trim()
|
|
322
|
+
? path.resolve(env.REPRO_MCP_ENV_FILE.trim())
|
|
323
|
+
: null;
|
|
324
|
+
const envFiles = [
|
|
325
|
+
path.join(packageRoot, '.env'),
|
|
326
|
+
path.join(packageRoot, '.env.local'),
|
|
327
|
+
];
|
|
328
|
+
if (explicitEnvFile) {
|
|
329
|
+
envFiles.push(explicitEnvFile);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
for (const filePath of envFiles) {
|
|
333
|
+
loadEnvironmentFile(env, filePath, {
|
|
334
|
+
override: explicitEnvFile === filePath,
|
|
335
|
+
protectedKeys,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function loadEnvironmentFile(env, filePath, options = {}) {
|
|
341
|
+
let text;
|
|
342
|
+
try {
|
|
343
|
+
text = fs.readFileSync(filePath, 'utf8');
|
|
344
|
+
} catch {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
349
|
+
const line = rawLine.trim();
|
|
350
|
+
if (!line || line.startsWith('#')) {
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
const equalsIndex = line.indexOf('=');
|
|
354
|
+
if (equalsIndex === -1) {
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
const key = line.slice(0, equalsIndex).trim();
|
|
358
|
+
const value = line.slice(equalsIndex + 1).trim();
|
|
359
|
+
if (!key) {
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (
|
|
363
|
+
Object.prototype.hasOwnProperty.call(env, key) &&
|
|
364
|
+
(!options.override || options.protectedKeys?.has(key))
|
|
365
|
+
) {
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
env[key] = stripEnvQuotes(value);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function stripEnvQuotes(value) {
|
|
373
|
+
if (
|
|
374
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
375
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
376
|
+
) {
|
|
377
|
+
return value.slice(1, -1);
|
|
378
|
+
}
|
|
379
|
+
return value;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function normalizeOptionalBaseUrl(value, fallback) {
|
|
383
|
+
if (typeof value === 'string' && value.trim()) {
|
|
384
|
+
return normalizeBaseUrl(value);
|
|
385
|
+
}
|
|
386
|
+
if (typeof fallback === 'string' && fallback.trim()) {
|
|
387
|
+
return normalizeBaseUrl(fallback);
|
|
388
|
+
}
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function deriveWebBaseUrl(apiBaseUrl) {
|
|
393
|
+
if (typeof apiBaseUrl !== 'string' || !apiBaseUrl.trim()) {
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
try {
|
|
397
|
+
const url = new URL(apiBaseUrl);
|
|
398
|
+
url.pathname = url.pathname.replace(/\/api\/?$/, '/') || '/';
|
|
399
|
+
url.search = '';
|
|
400
|
+
url.hash = '';
|
|
401
|
+
return url.toString();
|
|
402
|
+
} catch {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function parsePositiveInteger(value, fallback) {
|
|
408
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
409
|
+
return fallback;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const parsed = Number.parseInt(value, 10);
|
|
413
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
414
|
+
throw new Error(`Invalid positive integer value: ${value}`);
|
|
415
|
+
}
|
|
416
|
+
return parsed;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function parseBoolean(value, fallback, label) {
|
|
420
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
421
|
+
return fallback;
|
|
422
|
+
}
|
|
423
|
+
const normalized = value.trim().toLowerCase();
|
|
424
|
+
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
if (['0', 'false', 'no', 'off'].includes(normalized)) {
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
throw new Error(`Invalid boolean value for ${label}: ${value}`);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function parsePathMappings(value) {
|
|
434
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
435
|
+
return [];
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
let parsed;
|
|
439
|
+
try {
|
|
440
|
+
parsed = JSON.parse(value);
|
|
441
|
+
} catch (error) {
|
|
442
|
+
throw new Error(`REPRO_PATH_MAPPINGS must be valid JSON: ${error.message}`);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (!Array.isArray(parsed)) {
|
|
446
|
+
throw new Error('REPRO_PATH_MAPPINGS must be an array');
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return parsed.map((mapping, index) => {
|
|
450
|
+
if (!mapping || typeof mapping !== 'object') {
|
|
451
|
+
throw new Error(`REPRO_PATH_MAPPINGS[${index}] must be an object`);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const remotePrefix = normalizePrefix(mapping.remotePrefix, `REPRO_PATH_MAPPINGS[${index}].remotePrefix`);
|
|
455
|
+
const localPrefix = normalizePrefix(mapping.localPrefix, `REPRO_PATH_MAPPINGS[${index}].localPrefix`);
|
|
456
|
+
return {
|
|
457
|
+
remotePrefix,
|
|
458
|
+
localPrefix,
|
|
459
|
+
};
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function normalizePrefix(value, label) {
|
|
464
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
465
|
+
throw new Error(`${label} is required`);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const trimmed = value.trim();
|
|
469
|
+
if (trimmed === path.sep) {
|
|
470
|
+
return trimmed;
|
|
471
|
+
}
|
|
472
|
+
return trimmed.replace(/[\\/]+$/, '');
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function createReproAiClient(config, fetchImpl = globalThis.fetch, options = {}) {
|
|
476
|
+
if (typeof fetchImpl !== 'function') {
|
|
477
|
+
throw new Error('Global fetch is not available. Use Node.js 18 or newer.');
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const authClient = createReproAuthClient(config, fetchImpl, options);
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
async getToolCatalog() {
|
|
484
|
+
return requestJson(fetchImpl, config, 'v1/tools/catalog', {
|
|
485
|
+
method: 'GET',
|
|
486
|
+
});
|
|
487
|
+
},
|
|
488
|
+
async executeTool(name, input) {
|
|
489
|
+
if (!config.token && config.autoAuth) {
|
|
490
|
+
await authClient.ensureAuthenticated();
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
return await requestJson(fetchImpl, config, 'v1/tools/execute', {
|
|
495
|
+
method: 'POST',
|
|
496
|
+
timeoutMs: name === 'investigate' ? config.investigateTimeoutMs : undefined,
|
|
497
|
+
body: {
|
|
498
|
+
name,
|
|
499
|
+
arguments: input,
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
} catch (error) {
|
|
503
|
+
if (shouldRetryWithInteractiveAuth(error, config)) {
|
|
504
|
+
clearPersistedAuth(config);
|
|
505
|
+
await authClient.ensureAuthenticated(true);
|
|
506
|
+
return requestJson(fetchImpl, config, 'v1/tools/execute', {
|
|
507
|
+
method: 'POST',
|
|
508
|
+
timeoutMs: name === 'investigate' ? config.investigateTimeoutMs : undefined,
|
|
509
|
+
body: {
|
|
510
|
+
name,
|
|
511
|
+
arguments: input,
|
|
512
|
+
},
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
throw error;
|
|
516
|
+
}
|
|
517
|
+
},
|
|
518
|
+
async requestApi(pathname, options = {}) {
|
|
519
|
+
if (!config.apiBaseUrl) {
|
|
520
|
+
throw new Error('REPRO_API_BASE_URL is required to inspect privacy policies');
|
|
521
|
+
}
|
|
522
|
+
if (!config.apiToken && config.autoAuth) {
|
|
523
|
+
await authClient.ensureAuthenticated();
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
try {
|
|
527
|
+
return await requestJson(fetchImpl, config, pathname, {
|
|
528
|
+
...options,
|
|
529
|
+
baseUrl: config.apiBaseUrl,
|
|
530
|
+
token: config.apiToken,
|
|
531
|
+
});
|
|
532
|
+
} catch (error) {
|
|
533
|
+
if (shouldRetryApiWithInteractiveAuth(error, config)) {
|
|
534
|
+
clearPersistedAuth(config);
|
|
535
|
+
await authClient.ensureAuthenticated(true);
|
|
536
|
+
return requestJson(fetchImpl, config, pathname, {
|
|
537
|
+
...options,
|
|
538
|
+
baseUrl: config.apiBaseUrl,
|
|
539
|
+
token: config.apiToken,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
throw error;
|
|
543
|
+
}
|
|
544
|
+
},
|
|
545
|
+
ensureAuthenticated(force = false) {
|
|
546
|
+
return authClient.ensureAuthenticated(force);
|
|
547
|
+
},
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function requestJson(fetchImpl, config, pathname, options = {}) {
|
|
552
|
+
const baseUrl = normalizeBaseUrl(options.baseUrl || config.baseUrl);
|
|
553
|
+
const url = new URL(pathname, baseUrl);
|
|
554
|
+
const timeoutMs = positiveIntegerOrFallback(options.timeoutMs, config.requestTimeoutMs);
|
|
555
|
+
const headers = {
|
|
556
|
+
accept: 'application/json',
|
|
557
|
+
};
|
|
558
|
+
if (options.headers && typeof options.headers === 'object') {
|
|
559
|
+
for (const [key, value] of Object.entries(options.headers)) {
|
|
560
|
+
if (typeof value === 'string' && value.trim()) {
|
|
561
|
+
headers[key.toLowerCase()] = value.trim();
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
const hasExplicitToken = Object.prototype.hasOwnProperty.call(options, 'token');
|
|
566
|
+
const resolvedToken = hasExplicitToken
|
|
567
|
+
? typeof options.token === 'string' && options.token.trim()
|
|
568
|
+
? options.token.trim()
|
|
569
|
+
: ''
|
|
570
|
+
: config.token;
|
|
571
|
+
if (resolvedToken) {
|
|
572
|
+
headers.authorization = resolvedToken.startsWith('Bearer ')
|
|
573
|
+
? resolvedToken
|
|
574
|
+
: `Bearer ${resolvedToken}`;
|
|
575
|
+
}
|
|
576
|
+
if (typeof options.body !== 'undefined') {
|
|
577
|
+
headers['content-type'] = 'application/json';
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const controller = new AbortController();
|
|
581
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
582
|
+
try {
|
|
583
|
+
let response;
|
|
584
|
+
try {
|
|
585
|
+
response = await fetchImpl(url, {
|
|
586
|
+
method: options.method || 'GET',
|
|
587
|
+
headers,
|
|
588
|
+
body: typeof options.body === 'undefined' ? undefined : JSON.stringify(options.body),
|
|
589
|
+
signal: controller.signal,
|
|
590
|
+
});
|
|
591
|
+
} catch (error) {
|
|
592
|
+
if (controller.signal.aborted || error?.name === 'AbortError') {
|
|
593
|
+
throw new Error(`repro-ai request to ${url.toString()} timed out after ${timeoutMs}ms`);
|
|
594
|
+
}
|
|
595
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
596
|
+
throw new Error(`Failed to reach repro-ai at ${url.toString()}: ${reason}`);
|
|
597
|
+
}
|
|
598
|
+
const text = await response.text();
|
|
599
|
+
let data = null;
|
|
600
|
+
if (text) {
|
|
601
|
+
try {
|
|
602
|
+
data = JSON.parse(text);
|
|
603
|
+
} catch {
|
|
604
|
+
throw new Error(`repro-ai returned non-JSON response (${response.status})`);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (!response.ok) {
|
|
609
|
+
const message =
|
|
610
|
+
data && typeof data.error === 'string'
|
|
611
|
+
? data.error
|
|
612
|
+
: `repro-ai request failed with status ${response.status}`;
|
|
613
|
+
const error = new Error(message);
|
|
614
|
+
error.statusCode = response.status;
|
|
615
|
+
error.details = data;
|
|
616
|
+
throw error;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return data;
|
|
620
|
+
} finally {
|
|
621
|
+
clearTimeout(timeout);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function createReproAuthClient(config, fetchImpl, options = {}) {
|
|
626
|
+
const openUrlImpl = typeof options.openUrlImpl === 'function' ? options.openUrlImpl : openUrl;
|
|
627
|
+
const waitImpl = typeof options.waitImpl === 'function' ? options.waitImpl : wait;
|
|
628
|
+
let authPromise = null;
|
|
629
|
+
let promptSession = null;
|
|
630
|
+
|
|
631
|
+
return {
|
|
632
|
+
async ensureAuthenticated(force = false) {
|
|
633
|
+
if (!config.apiBaseUrl) {
|
|
634
|
+
throw new Error('REPRO_API_BASE_URL is required for interactive browser authentication');
|
|
635
|
+
}
|
|
636
|
+
if (config.apiToken && !force) {
|
|
637
|
+
return config.apiToken;
|
|
638
|
+
}
|
|
639
|
+
if (config.apiTokenSource === 'env' && force) {
|
|
640
|
+
throw new Error('REPRO_API_TOKEN is configured explicitly; fix or replace that token instead of starting interactive auth');
|
|
641
|
+
}
|
|
642
|
+
if (config.authMode === 'prompt') {
|
|
643
|
+
return ensurePromptAuthentication({
|
|
644
|
+
config,
|
|
645
|
+
fetchImpl,
|
|
646
|
+
force,
|
|
647
|
+
getPromptSession: () => promptSession,
|
|
648
|
+
setPromptSession: (value) => {
|
|
649
|
+
promptSession = value;
|
|
650
|
+
},
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
if (authPromise) {
|
|
654
|
+
return authPromise;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
authPromise = (async () => {
|
|
658
|
+
const session = await createBrowserAuthSession(fetchImpl, config);
|
|
659
|
+
|
|
660
|
+
notifyUser(
|
|
661
|
+
`browser authentication required\nOpen: ${session.authorizeUrl}`,
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
if (config.openBrowser !== false) {
|
|
665
|
+
try {
|
|
666
|
+
await openUrlImpl(session.authorizeUrl);
|
|
667
|
+
} catch (error) {
|
|
668
|
+
debugLog(`failed to open browser automatically: ${error instanceof Error ? error.message : String(error)}`);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const pollIntervalMs = positiveIntegerOrFallback(
|
|
673
|
+
session.pollIntervalMs,
|
|
674
|
+
config.authPollIntervalMs,
|
|
675
|
+
);
|
|
676
|
+
const deadline = Date.now() + config.authTimeoutMs;
|
|
677
|
+
|
|
678
|
+
while (Date.now() < deadline) {
|
|
679
|
+
const claim = await claimBrowserAuthSession(fetchImpl, config, session);
|
|
680
|
+
|
|
681
|
+
if (claim && claim.status === 'pending') {
|
|
682
|
+
await waitImpl(pollIntervalMs);
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
if (claim && claim.status === 'approved' && typeof claim.accessToken === 'string') {
|
|
687
|
+
return completeBrowserAuth(config, claim);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (claim && claim.status === 'claimed') {
|
|
691
|
+
throw new Error('The Repro MCP auth session was already claimed');
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (claim && claim.status === 'expired') {
|
|
695
|
+
throw new Error('The Repro MCP browser authentication session expired');
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
throw new Error('Unexpected response while completing Repro MCP authentication');
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
throw new Error('Timed out waiting for Repro browser authentication');
|
|
702
|
+
})().finally(() => {
|
|
703
|
+
authPromise = null;
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
return authPromise;
|
|
707
|
+
},
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
async function ensurePromptAuthentication({
|
|
712
|
+
config,
|
|
713
|
+
fetchImpl,
|
|
714
|
+
force,
|
|
715
|
+
getPromptSession,
|
|
716
|
+
setPromptSession,
|
|
717
|
+
}) {
|
|
718
|
+
if (force) {
|
|
719
|
+
setPromptSession(null);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const existing = getPromptSession();
|
|
723
|
+
if (existing) {
|
|
724
|
+
const claim = await claimBrowserAuthSession(fetchImpl, config, existing);
|
|
725
|
+
if (claim && claim.status === 'approved' && typeof claim.accessToken === 'string') {
|
|
726
|
+
setPromptSession(null);
|
|
727
|
+
return completeBrowserAuth(config, claim);
|
|
728
|
+
}
|
|
729
|
+
if (claim && claim.status === 'pending') {
|
|
730
|
+
throw createBrowserAuthRequiredError(existing);
|
|
731
|
+
}
|
|
732
|
+
setPromptSession(null);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const session = await createBrowserAuthSession(fetchImpl, config);
|
|
736
|
+
setPromptSession(session);
|
|
737
|
+
notifyUser(`browser authentication required\nOpen: ${session.authorizeUrl}`);
|
|
738
|
+
throw createBrowserAuthRequiredError(session);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function createBrowserAuthSession(fetchImpl, config) {
|
|
742
|
+
return requestJson(fetchImpl, config, 'v1/mcp/auth/sessions', {
|
|
743
|
+
baseUrl: config.apiBaseUrl,
|
|
744
|
+
method: 'POST',
|
|
745
|
+
headers: buildBrowserAuthForwardHeaders(config),
|
|
746
|
+
body: {
|
|
747
|
+
clientName: config.clientName,
|
|
748
|
+
},
|
|
749
|
+
token: '',
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function buildBrowserAuthForwardHeaders(config) {
|
|
754
|
+
const baseUrl = config.publicApiBaseUrl || config.apiBaseUrl;
|
|
755
|
+
if (typeof baseUrl !== 'string' || !baseUrl.trim()) {
|
|
756
|
+
return undefined;
|
|
757
|
+
}
|
|
758
|
+
try {
|
|
759
|
+
const url = new URL(baseUrl);
|
|
760
|
+
return {
|
|
761
|
+
'x-forwarded-proto': url.protocol.replace(/:$/, ''),
|
|
762
|
+
'x-forwarded-host': url.host,
|
|
763
|
+
};
|
|
764
|
+
} catch {
|
|
765
|
+
return undefined;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function claimBrowserAuthSession(fetchImpl, config, session) {
|
|
770
|
+
return requestJson(fetchImpl, config, `v1/mcp/auth/sessions/${encodeURIComponent(session.sessionId)}/claim`, {
|
|
771
|
+
baseUrl: config.apiBaseUrl,
|
|
772
|
+
method: 'POST',
|
|
773
|
+
body: {
|
|
774
|
+
pollToken: session.pollToken,
|
|
775
|
+
},
|
|
776
|
+
token: '',
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function completeBrowserAuth(config, claim) {
|
|
781
|
+
storeAuth(config, {
|
|
782
|
+
accessToken: claim.accessToken,
|
|
783
|
+
tenantId: typeof claim.tenantId === 'string' ? claim.tenantId : undefined,
|
|
784
|
+
appId: typeof claim.appId === 'string' ? claim.appId : undefined,
|
|
785
|
+
email: typeof claim.email === 'string' ? claim.email : undefined,
|
|
786
|
+
apiBaseUrl: config.apiBaseUrl,
|
|
787
|
+
});
|
|
788
|
+
notifyUser('browser authentication completed');
|
|
789
|
+
return config.apiToken;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function createBrowserAuthRequiredError(session) {
|
|
793
|
+
const error = new Error(
|
|
794
|
+
`Repro browser authentication required. Open ${session.authorizeUrl}, approve access, then retry the request.`,
|
|
795
|
+
);
|
|
796
|
+
error.rpcCode = -32001;
|
|
797
|
+
error.httpStatusCode = 200;
|
|
798
|
+
error.rpcData = {
|
|
799
|
+
type: 'browser_auth_required',
|
|
800
|
+
authorizeUrl: session.authorizeUrl,
|
|
801
|
+
sessionId: session.sessionId,
|
|
802
|
+
expiresAt: session.expiresAt,
|
|
803
|
+
retry: 'Approve this Repro MCP browser auth request, then retry the same MCP request.',
|
|
804
|
+
};
|
|
805
|
+
return error;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function shouldRetryWithInteractiveAuth(error, config) {
|
|
809
|
+
if (!config.autoAuth || !config.apiBaseUrl || config.tokenSource === 'env') {
|
|
810
|
+
return false;
|
|
811
|
+
}
|
|
812
|
+
const statusCode = error && typeof error === 'object' ? error.statusCode : null;
|
|
813
|
+
return statusCode === 401;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function shouldRetryApiWithInteractiveAuth(error, config) {
|
|
817
|
+
if (!config.autoAuth || !config.apiBaseUrl || config.apiTokenSource === 'env') {
|
|
818
|
+
return false;
|
|
819
|
+
}
|
|
820
|
+
const statusCode = error && typeof error === 'object' ? error.statusCode : null;
|
|
821
|
+
return statusCode === 401 || statusCode === 403;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function resolveAuthFilePath(value) {
|
|
825
|
+
if (typeof value === 'string' && value.trim()) {
|
|
826
|
+
return path.resolve(value.trim());
|
|
827
|
+
}
|
|
828
|
+
return path.join(os.homedir(), '.config', 'repro', 'repro-mcp-auth.json');
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function readStoredAuth(filePath, apiBaseUrl) {
|
|
832
|
+
try {
|
|
833
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
834
|
+
const parsed = JSON.parse(raw);
|
|
835
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
836
|
+
return null;
|
|
837
|
+
}
|
|
838
|
+
if (typeof parsed.accessToken !== 'string' || !parsed.accessToken.trim()) {
|
|
839
|
+
return null;
|
|
840
|
+
}
|
|
841
|
+
if (
|
|
842
|
+
typeof parsed.apiBaseUrl === 'string' &&
|
|
843
|
+
apiBaseUrl &&
|
|
844
|
+
normalizeBaseUrl(parsed.apiBaseUrl) !== normalizeBaseUrl(apiBaseUrl)
|
|
845
|
+
) {
|
|
846
|
+
return null;
|
|
847
|
+
}
|
|
848
|
+
return {
|
|
849
|
+
accessToken: parsed.accessToken.trim(),
|
|
850
|
+
tenantId:
|
|
851
|
+
typeof parsed.tenantId === 'string' && parsed.tenantId.trim()
|
|
852
|
+
? parsed.tenantId.trim()
|
|
853
|
+
: undefined,
|
|
854
|
+
appId:
|
|
855
|
+
typeof parsed.appId === 'string' && parsed.appId.trim()
|
|
856
|
+
? parsed.appId.trim()
|
|
857
|
+
: undefined,
|
|
858
|
+
email:
|
|
859
|
+
typeof parsed.email === 'string' && parsed.email.trim()
|
|
860
|
+
? parsed.email.trim()
|
|
861
|
+
: undefined,
|
|
862
|
+
apiBaseUrl:
|
|
863
|
+
typeof parsed.apiBaseUrl === 'string' && parsed.apiBaseUrl.trim()
|
|
864
|
+
? normalizeBaseUrl(parsed.apiBaseUrl)
|
|
865
|
+
: undefined,
|
|
866
|
+
};
|
|
867
|
+
} catch {
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function storeAuth(config, auth) {
|
|
873
|
+
config.apiToken = auth.accessToken;
|
|
874
|
+
config.apiTokenSource = 'file';
|
|
875
|
+
if (config.tokenSource !== 'env') {
|
|
876
|
+
config.token = auth.accessToken;
|
|
877
|
+
config.tokenSource = 'file';
|
|
878
|
+
}
|
|
879
|
+
if (auth.tenantId && config.defaultTenantIdSource !== 'env') {
|
|
880
|
+
config.defaultTenantId = auth.tenantId;
|
|
881
|
+
config.defaultTenantIdSource = 'file';
|
|
882
|
+
}
|
|
883
|
+
if (auth.appId && config.defaultAppIdSource !== 'env') {
|
|
884
|
+
config.defaultAppId = auth.appId;
|
|
885
|
+
config.defaultAppIdSource = 'file';
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
try {
|
|
889
|
+
fs.mkdirSync(path.dirname(config.authFilePath), { recursive: true });
|
|
890
|
+
fs.writeFileSync(
|
|
891
|
+
config.authFilePath,
|
|
892
|
+
JSON.stringify(
|
|
893
|
+
{
|
|
894
|
+
accessToken: auth.accessToken,
|
|
895
|
+
tenantId: auth.tenantId || null,
|
|
896
|
+
appId: auth.appId || null,
|
|
897
|
+
email: auth.email || null,
|
|
898
|
+
apiBaseUrl: auth.apiBaseUrl || config.apiBaseUrl || null,
|
|
899
|
+
updatedAt: new Date().toISOString(),
|
|
900
|
+
},
|
|
901
|
+
null,
|
|
902
|
+
2,
|
|
903
|
+
),
|
|
904
|
+
{ mode: 0o600 },
|
|
905
|
+
);
|
|
906
|
+
} catch (error) {
|
|
907
|
+
debugLog(`failed to persist auth token: ${error instanceof Error ? error.message : String(error)}`);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function clearPersistedAuth(config) {
|
|
912
|
+
if (config.tokenSource === 'file') {
|
|
913
|
+
config.token = undefined;
|
|
914
|
+
config.tokenSource = 'none';
|
|
915
|
+
}
|
|
916
|
+
if (config.apiTokenSource === 'file') {
|
|
917
|
+
config.apiToken = undefined;
|
|
918
|
+
config.apiTokenSource = 'none';
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
if (config.defaultTenantIdSource === 'file') {
|
|
922
|
+
config.defaultTenantId = undefined;
|
|
923
|
+
config.defaultTenantIdSource = 'none';
|
|
924
|
+
}
|
|
925
|
+
if (config.defaultAppIdSource === 'file') {
|
|
926
|
+
config.defaultAppId = undefined;
|
|
927
|
+
config.defaultAppIdSource = 'none';
|
|
928
|
+
}
|
|
929
|
+
try {
|
|
930
|
+
fs.rmSync(config.authFilePath, { force: true });
|
|
931
|
+
} catch (error) {
|
|
932
|
+
debugLog(`failed to remove stored auth token: ${error instanceof Error ? error.message : String(error)}`);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function wait(ms) {
|
|
937
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
async function openUrl(url) {
|
|
941
|
+
const command = resolveOpenCommand(url);
|
|
942
|
+
if (!command) {
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
await new Promise((resolve, reject) => {
|
|
947
|
+
const child = childProcess.spawn(command[0], command.slice(1), {
|
|
948
|
+
detached: true,
|
|
949
|
+
stdio: 'ignore',
|
|
950
|
+
});
|
|
951
|
+
child.on('error', reject);
|
|
952
|
+
child.unref();
|
|
953
|
+
resolve();
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function resolveOpenCommand(url) {
|
|
958
|
+
if (process.platform === 'darwin') {
|
|
959
|
+
return ['open', url];
|
|
960
|
+
}
|
|
961
|
+
if (process.platform === 'win32') {
|
|
962
|
+
return ['cmd', '/c', 'start', '', url];
|
|
963
|
+
}
|
|
964
|
+
if (process.platform === 'linux') {
|
|
965
|
+
return ['xdg-open', url];
|
|
966
|
+
}
|
|
967
|
+
return null;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function notifyUser(message) {
|
|
971
|
+
process.stderr.write(`[${SERVER_NAME}] ${message}\n`);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function positiveIntegerOrFallback(value, fallback) {
|
|
975
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function createMcpServer(config, options = {}) {
|
|
979
|
+
const client = options.client || createReproAiClient(config, options.fetchImpl, options);
|
|
980
|
+
let cachedTools = Array.isArray(options.initialTools) ? mergeLocalTools(options.initialTools.map(publicToolDefinition)) : null;
|
|
981
|
+
let cachedToolsComplete = Array.isArray(options.initialTools);
|
|
982
|
+
|
|
983
|
+
async function getTools(forceRefresh = false) {
|
|
984
|
+
if (!forceRefresh && cachedTools && cachedToolsComplete) {
|
|
985
|
+
return cachedTools;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
try {
|
|
989
|
+
const catalog = await client.getToolCatalog();
|
|
990
|
+
const tools = mergeLocalTools(normalizeToolCatalog(catalog));
|
|
991
|
+
cachedTools = tools;
|
|
992
|
+
cachedToolsComplete = true;
|
|
993
|
+
return tools;
|
|
994
|
+
} catch (error) {
|
|
995
|
+
if (cachedTools && cachedToolsComplete) {
|
|
996
|
+
debugLog(
|
|
997
|
+
`catalog fetch failed, serving stale tools: ${error instanceof Error ? error.message : String(error)}`,
|
|
998
|
+
);
|
|
999
|
+
return cachedTools;
|
|
1000
|
+
}
|
|
1001
|
+
return mergeLocalTools([]);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
return {
|
|
1006
|
+
async handleMessage(message) {
|
|
1007
|
+
return handleRpcMessage(message, config, client, getTools);
|
|
1008
|
+
},
|
|
1009
|
+
async getTools(forceRefresh = false) {
|
|
1010
|
+
return getTools(forceRefresh);
|
|
1011
|
+
},
|
|
1012
|
+
async primeAuth() {
|
|
1013
|
+
if (
|
|
1014
|
+
!config.autoAuth ||
|
|
1015
|
+
config.apiToken ||
|
|
1016
|
+
!config.apiBaseUrl ||
|
|
1017
|
+
typeof client.ensureAuthenticated !== 'function'
|
|
1018
|
+
) {
|
|
1019
|
+
return null;
|
|
1020
|
+
}
|
|
1021
|
+
return client.ensureAuthenticated(false);
|
|
1022
|
+
},
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
async function handleRpcMessage(message, config, client, getTools) {
|
|
1027
|
+
if (!message || typeof message !== 'object') {
|
|
1028
|
+
throw rpcError(-32600, 'Invalid Request');
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
const method = typeof message.method === 'string' ? message.method : null;
|
|
1032
|
+
if (!method) {
|
|
1033
|
+
throw rpcError(-32600, 'Invalid Request');
|
|
1034
|
+
}
|
|
1035
|
+
debugLog(
|
|
1036
|
+
`recv method=${method} id=${Object.prototype.hasOwnProperty.call(message, 'id') ? String(message.id) : '-'} params=${summarizeForLog(message.params)}`,
|
|
1037
|
+
);
|
|
1038
|
+
|
|
1039
|
+
if (method === 'notifications/initialized') {
|
|
1040
|
+
return null;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
if (method === 'initialize') {
|
|
1044
|
+
const requestedProtocolVersion =
|
|
1045
|
+
message.params &&
|
|
1046
|
+
typeof message.params === 'object' &&
|
|
1047
|
+
typeof message.params.protocolVersion === 'string' &&
|
|
1048
|
+
message.params.protocolVersion.trim()
|
|
1049
|
+
? message.params.protocolVersion.trim()
|
|
1050
|
+
: SUPPORTED_PROTOCOL_VERSION;
|
|
1051
|
+
return {
|
|
1052
|
+
jsonrpc: JSON_RPC_VERSION,
|
|
1053
|
+
id: message.id,
|
|
1054
|
+
result: {
|
|
1055
|
+
protocolVersion: requestedProtocolVersion,
|
|
1056
|
+
capabilities: {
|
|
1057
|
+
tools: {},
|
|
1058
|
+
},
|
|
1059
|
+
serverInfo: {
|
|
1060
|
+
name: SERVER_NAME,
|
|
1061
|
+
version: SERVER_VERSION,
|
|
1062
|
+
},
|
|
1063
|
+
},
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
if (method === 'ping') {
|
|
1068
|
+
return {
|
|
1069
|
+
jsonrpc: JSON_RPC_VERSION,
|
|
1070
|
+
id: message.id,
|
|
1071
|
+
result: {},
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
if (method === 'tools/list') {
|
|
1076
|
+
return {
|
|
1077
|
+
jsonrpc: JSON_RPC_VERSION,
|
|
1078
|
+
id: message.id,
|
|
1079
|
+
result: {
|
|
1080
|
+
tools: await getTools(),
|
|
1081
|
+
},
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
if (method === 'tools/call') {
|
|
1086
|
+
const params = message.params && typeof message.params === 'object' ? message.params : {};
|
|
1087
|
+
const result = await executeTool(params, config, client, getTools);
|
|
1088
|
+
return {
|
|
1089
|
+
jsonrpc: JSON_RPC_VERSION,
|
|
1090
|
+
id: message.id,
|
|
1091
|
+
result,
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
throw rpcError(-32601, `Method not found: ${method}`);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function publicToolDefinition(tool) {
|
|
1099
|
+
return {
|
|
1100
|
+
name: tool.name,
|
|
1101
|
+
description: tool.description,
|
|
1102
|
+
inputSchema: tool.inputSchema,
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function normalizeToolCatalog(catalog) {
|
|
1107
|
+
if (!catalog || typeof catalog !== 'object' || !Array.isArray(catalog.tools)) {
|
|
1108
|
+
throw new Error('repro-ai returned an invalid tool catalog');
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
return catalog.tools
|
|
1112
|
+
.filter((tool) => tool && typeof tool === 'object')
|
|
1113
|
+
.map(publicToolDefinition);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
async function executeTool(params, config, client, getTools) {
|
|
1117
|
+
const name = typeof params.name === 'string' ? params.name : '';
|
|
1118
|
+
const rawArguments = normalizeToolArguments(params.arguments);
|
|
1119
|
+
const input = prepareToolInput(rawArguments, config);
|
|
1120
|
+
|
|
1121
|
+
if (isLocalTool(name)) {
|
|
1122
|
+
const response = await executeLocalTool(name, input, config, client);
|
|
1123
|
+
const augmented = augmentPaths(response, config.pathMappings);
|
|
1124
|
+
const isError =
|
|
1125
|
+
augmented &&
|
|
1126
|
+
typeof augmented === 'object' &&
|
|
1127
|
+
((augmented.status === 'error' || augmented.status === 'not_found') ||
|
|
1128
|
+
Boolean(augmented.error));
|
|
1129
|
+
|
|
1130
|
+
return {
|
|
1131
|
+
content: [
|
|
1132
|
+
{
|
|
1133
|
+
type: 'text',
|
|
1134
|
+
text: formatLocalToolContentText(name, augmented),
|
|
1135
|
+
},
|
|
1136
|
+
],
|
|
1137
|
+
structuredContent: augmented,
|
|
1138
|
+
isError,
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
let tools = await getTools();
|
|
1143
|
+
let tool = tools.find((candidate) => candidate.name === name);
|
|
1144
|
+
if (!tool) {
|
|
1145
|
+
tools = await getTools(true);
|
|
1146
|
+
tool = tools.find((candidate) => candidate.name === name);
|
|
1147
|
+
}
|
|
1148
|
+
if (!tool) {
|
|
1149
|
+
throw rpcError(-32602, `Unknown tool: ${name}`);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
const response = await client.executeTool(name, input);
|
|
1153
|
+
|
|
1154
|
+
const augmented = augmentPaths(response, config.pathMappings);
|
|
1155
|
+
const isError =
|
|
1156
|
+
augmented &&
|
|
1157
|
+
typeof augmented === 'object' &&
|
|
1158
|
+
((augmented.status === 'error' || augmented.status === 'not_found') ||
|
|
1159
|
+
Boolean(augmented.error));
|
|
1160
|
+
|
|
1161
|
+
return {
|
|
1162
|
+
content: [
|
|
1163
|
+
{
|
|
1164
|
+
type: 'text',
|
|
1165
|
+
text: formatLocalToolContentText(name, augmented),
|
|
1166
|
+
},
|
|
1167
|
+
],
|
|
1168
|
+
structuredContent: augmented,
|
|
1169
|
+
isError,
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function formatLocalToolContentText(name, value) {
|
|
1174
|
+
if (name === 'inspect_privacy_policy') {
|
|
1175
|
+
const text = renderPolicyInspectionText(value);
|
|
1176
|
+
if (text) return text;
|
|
1177
|
+
}
|
|
1178
|
+
if (name === 'check_privacy_coverage') {
|
|
1179
|
+
const text = renderPrivacyCoverageText(value);
|
|
1180
|
+
if (text) return text;
|
|
1181
|
+
}
|
|
1182
|
+
return JSON.stringify(value, null, 2);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function mergeLocalTools(remoteTools) {
|
|
1186
|
+
const byName = new Map();
|
|
1187
|
+
for (const tool of LOCAL_TOOL_DEFINITIONS) {
|
|
1188
|
+
byName.set(tool.name, publicToolDefinition(tool));
|
|
1189
|
+
}
|
|
1190
|
+
for (const tool of remoteTools) {
|
|
1191
|
+
if (!byName.has(tool.name)) {
|
|
1192
|
+
byName.set(tool.name, publicToolDefinition(tool));
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
return Array.from(byName.values());
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function isLocalTool(name) {
|
|
1199
|
+
return LOCAL_TOOL_DEFINITIONS.some((tool) => tool.name === name);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
async function executeLocalTool(name, input, config, client) {
|
|
1203
|
+
if (name === 'resolve_scope') {
|
|
1204
|
+
return resolveScope(input, config, client);
|
|
1205
|
+
}
|
|
1206
|
+
if (name === 'check_privacy_coverage') {
|
|
1207
|
+
return checkPrivacyCoverage(input, config, client);
|
|
1208
|
+
}
|
|
1209
|
+
if (name === 'inspect_privacy_policy') {
|
|
1210
|
+
return inspectPrivacyPolicy(input, config, client);
|
|
1211
|
+
}
|
|
1212
|
+
if (name === 'update_privacy_policy') {
|
|
1213
|
+
return updatePrivacyPolicy(input, config, client);
|
|
1214
|
+
}
|
|
1215
|
+
if (name === 'approve_privacy_policy') {
|
|
1216
|
+
return approvePrivacyPolicy(input, config, client);
|
|
1217
|
+
}
|
|
1218
|
+
if (name === 'privacy_audit_session') {
|
|
1219
|
+
return privacyAuditSession(input, config, client);
|
|
1220
|
+
}
|
|
1221
|
+
throw rpcError(-32602, `Unknown local tool: ${name}`);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
async function resolveScope(input, config, client) {
|
|
1225
|
+
if (!client || typeof client.requestApi !== 'function') {
|
|
1226
|
+
throw rpcError(-32603, 'This MCP client cannot resolve app privacy scope');
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
const appId = optionalNonEmptyString(input.appId) || config.defaultAppId;
|
|
1230
|
+
const sessionId = optionalNonEmptyString(input.sessionId);
|
|
1231
|
+
if (appId && !sessionId) {
|
|
1232
|
+
const requestedEnvironment = optionalNonEmptyString(input.environment);
|
|
1233
|
+
const state = normalizePolicyState(input.state);
|
|
1234
|
+
const query = optionalNonEmptyString(input.query) || '';
|
|
1235
|
+
const privacyConfig = await client.requestApi(`v1/apps/${encodeURIComponent(appId)}/privacy`, {
|
|
1236
|
+
method: 'GET',
|
|
1237
|
+
headers: input.tenantId ? { 'x-tenant-id': input.tenantId } : undefined,
|
|
1238
|
+
});
|
|
1239
|
+
const environments = Array.isArray(privacyConfig?.environments) ? privacyConfig.environments : [];
|
|
1240
|
+
const selection = selectPolicyForInspection(privacyConfig, requestedEnvironment, state);
|
|
1241
|
+
const ui = buildPrivacyStudioUi(
|
|
1242
|
+
config,
|
|
1243
|
+
appId,
|
|
1244
|
+
selection?.environmentId ?? requestedEnvironment,
|
|
1245
|
+
query,
|
|
1246
|
+
input.openUi === true,
|
|
1247
|
+
);
|
|
1248
|
+
if (ui.openRequested) {
|
|
1249
|
+
try {
|
|
1250
|
+
await openUrl(ui.url);
|
|
1251
|
+
ui.opened = true;
|
|
1252
|
+
} catch (error) {
|
|
1253
|
+
ui.opened = false;
|
|
1254
|
+
ui.openError = error instanceof Error ? error.message : String(error);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
return {
|
|
1258
|
+
status: selection ? 'ok' : 'not_found',
|
|
1259
|
+
kind: 'app_privacy_scope',
|
|
1260
|
+
appId,
|
|
1261
|
+
tenantId: optionalNonEmptyString(input.tenantId) || config.defaultTenantId || null,
|
|
1262
|
+
environment: selection?.environmentId ?? requestedEnvironment ?? null,
|
|
1263
|
+
state: selection?.state ?? state,
|
|
1264
|
+
summary: selection
|
|
1265
|
+
? 'Resolved app privacy scope. Use inspect_privacy_policy for read-only answers or update_privacy_policy for reviewed policy changes.'
|
|
1266
|
+
: 'App was reachable, but no pending or approved privacy policy exists for the requested environment/state.',
|
|
1267
|
+
ui,
|
|
1268
|
+
environments: environments.map((environment) => ({
|
|
1269
|
+
id: environment?.id,
|
|
1270
|
+
hasPendingPolicy: Boolean(environment?.pendingPolicy?.policy),
|
|
1271
|
+
hasApprovedPolicy: Boolean(environment?.approvedPolicy?.policy),
|
|
1272
|
+
})),
|
|
1273
|
+
tools: {
|
|
1274
|
+
inspect: 'inspect_privacy_policy',
|
|
1275
|
+
update: 'update_privacy_policy',
|
|
1276
|
+
approve: 'approve_privacy_policy',
|
|
1277
|
+
},
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
if (client && typeof client.executeTool === 'function') {
|
|
1282
|
+
return client.executeTool('resolve_scope', input);
|
|
1283
|
+
}
|
|
1284
|
+
throw rpcError(-32602, 'appId or sessionId is required to resolve scope');
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
async function checkPrivacyCoverage(input, config, client) {
|
|
1288
|
+
if (!client || typeof client.requestApi !== 'function') {
|
|
1289
|
+
throw rpcError(-32603, 'This MCP client cannot fetch app privacy policies');
|
|
1290
|
+
}
|
|
1291
|
+
const appIds = normalizeCoverageAppIds(input, config);
|
|
1292
|
+
const state = normalizePolicyState(input.state);
|
|
1293
|
+
const requestedEnvironment = optionalNonEmptyString(input.environment);
|
|
1294
|
+
const topic = normalizeCoverageTopic(input);
|
|
1295
|
+
const query = queryForCoverageTopic(topic);
|
|
1296
|
+
const includeTechnical = input.includeTechnical === true;
|
|
1297
|
+
const results = [];
|
|
1298
|
+
|
|
1299
|
+
for (const appId of appIds) {
|
|
1300
|
+
try {
|
|
1301
|
+
const privacyConfig = await client.requestApi(`v1/apps/${encodeURIComponent(appId)}/privacy`, {
|
|
1302
|
+
method: 'GET',
|
|
1303
|
+
headers: input.tenantId ? { 'x-tenant-id': input.tenantId } : undefined,
|
|
1304
|
+
});
|
|
1305
|
+
const selection = selectPolicyForInspection(privacyConfig, requestedEnvironment, state);
|
|
1306
|
+
if (!selection) {
|
|
1307
|
+
results.push({
|
|
1308
|
+
appId,
|
|
1309
|
+
status: 'not_covered',
|
|
1310
|
+
environment: requestedEnvironment ?? null,
|
|
1311
|
+
state,
|
|
1312
|
+
summary: 'No pending or approved privacy policy was found for this app/environment.',
|
|
1313
|
+
ui: buildPrivacyStudioUi(config, appId, requestedEnvironment, query, false),
|
|
1314
|
+
matches: [],
|
|
1315
|
+
needsReviewReasons: ['No policy found.'],
|
|
1316
|
+
});
|
|
1317
|
+
continue;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
const statements = flattenPrivacyStatements(selection.policy);
|
|
1321
|
+
const blockerQuery = isPolicyBlockerQuery(query);
|
|
1322
|
+
const matches = (blockerQuery
|
|
1323
|
+
? selectPolicyInspectionMatches(statements, { query }, query)
|
|
1324
|
+
: rankPolicyStatementMatches(statements, { query }, query)
|
|
1325
|
+
).slice(0, blockerQuery ? 100 : 20);
|
|
1326
|
+
const matchedStatements = matches.map((match) => match.statement);
|
|
1327
|
+
const matchedLeaves = new Set(matches.flatMap((match) => statementTargetLeaves(match.statement)));
|
|
1328
|
+
const contradictions = findSameLeafPolicyContradictions(statements, matchedLeaves);
|
|
1329
|
+
const assessment = blockerQuery
|
|
1330
|
+
? assessPolicyBlockerCoverage(matchedStatements, contradictions)
|
|
1331
|
+
: assessCoverage(matchedStatements, contradictions);
|
|
1332
|
+
const evidence = buildCoverageEvidence(matchedStatements, contradictions, { includeTechnical });
|
|
1333
|
+
const ui = buildPrivacyStudioUi(config, appId, selection.environmentId, query, input.openUi === true);
|
|
1334
|
+
const policyBlockers = buildPolicyBlockerSummaries(
|
|
1335
|
+
blockerQuery ? matchedStatements : matchedStatements.filter(isReviewBlockerStatement),
|
|
1336
|
+
config,
|
|
1337
|
+
appId,
|
|
1338
|
+
selection.environmentId,
|
|
1339
|
+
{ includeTechnical },
|
|
1340
|
+
);
|
|
1341
|
+
if (ui.openRequested) {
|
|
1342
|
+
try {
|
|
1343
|
+
await openUrl(ui.url);
|
|
1344
|
+
ui.opened = true;
|
|
1345
|
+
} catch (error) {
|
|
1346
|
+
ui.opened = false;
|
|
1347
|
+
ui.openError = error instanceof Error ? error.message : String(error);
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
results.push({
|
|
1352
|
+
appId,
|
|
1353
|
+
environment: selection.environmentId,
|
|
1354
|
+
state: selection.state,
|
|
1355
|
+
inspectedPolicy: {
|
|
1356
|
+
environment: selection.environmentId,
|
|
1357
|
+
state: selection.state,
|
|
1358
|
+
generatedAt: selection.record?.policy?.generatedAt ?? selection.record?.generatedAt ?? null,
|
|
1359
|
+
totalStatementCount: statements.length,
|
|
1360
|
+
matchedStatementCount: matchedStatements.length,
|
|
1361
|
+
},
|
|
1362
|
+
status: assessment.status,
|
|
1363
|
+
summary: assessment.summary,
|
|
1364
|
+
recommendation: coverageRecommendation(assessment.status, evidence),
|
|
1365
|
+
ui,
|
|
1366
|
+
protectedStatementCount: assessment.protectedStatementCount,
|
|
1367
|
+
exactStatementCount: assessment.exactStatementCount,
|
|
1368
|
+
unresolvedReviewCount: assessment.unresolvedReviewCount,
|
|
1369
|
+
coverage: evidence,
|
|
1370
|
+
contradictions,
|
|
1371
|
+
policyBlockers,
|
|
1372
|
+
matches: matches.map((match) => formatPolicyMatch(match.statement, match.score, { includeTechnical })),
|
|
1373
|
+
needsReviewReasons: assessment.reasons,
|
|
1374
|
+
});
|
|
1375
|
+
} catch (error) {
|
|
1376
|
+
results.push({
|
|
1377
|
+
appId,
|
|
1378
|
+
status: 'error',
|
|
1379
|
+
environment: requestedEnvironment ?? null,
|
|
1380
|
+
state,
|
|
1381
|
+
summary: error instanceof Error ? error.message : String(error),
|
|
1382
|
+
ui: buildPrivacyStudioUi(config, appId, requestedEnvironment, query, false),
|
|
1383
|
+
matches: [],
|
|
1384
|
+
needsReviewReasons: ['Could not inspect policy because the API request failed.'],
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
const safe = results.filter((item) => item.status === 'safe').map((item) => item.appId);
|
|
1390
|
+
const needsReview = results.filter((item) => item.status === 'needs_review').map((item) => item.appId);
|
|
1391
|
+
const notCovered = results.filter((item) => item.status === 'not_covered').map((item) => item.appId);
|
|
1392
|
+
const errors = results.filter((item) => item.status === 'error').map((item) => item.appId);
|
|
1393
|
+
|
|
1394
|
+
return {
|
|
1395
|
+
status: errors.length ? 'warning' : needsReview.length || notCovered.length ? 'needs_review' : 'ok',
|
|
1396
|
+
topic,
|
|
1397
|
+
query,
|
|
1398
|
+
inspected: {
|
|
1399
|
+
requestedEnvironment: requestedEnvironment ?? null,
|
|
1400
|
+
requestedState: state,
|
|
1401
|
+
appCount: appIds.length,
|
|
1402
|
+
},
|
|
1403
|
+
summary: summarizeCoverageResults({ safe, needsReview, notCovered, errors }),
|
|
1404
|
+
answerGuide: buildCoverageAnswerGuide(results),
|
|
1405
|
+
safe,
|
|
1406
|
+
needsReview,
|
|
1407
|
+
notCovered,
|
|
1408
|
+
errors,
|
|
1409
|
+
results,
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
function normalizeCoverageAppIds(input, config) {
|
|
1414
|
+
const appIds = [];
|
|
1415
|
+
if (Array.isArray(input.appIds)) {
|
|
1416
|
+
for (const appId of input.appIds) {
|
|
1417
|
+
const normalized = optionalNonEmptyString(appId);
|
|
1418
|
+
if (normalized) {
|
|
1419
|
+
appIds.push(normalized);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
const single = optionalNonEmptyString(input.appId);
|
|
1424
|
+
if (single) {
|
|
1425
|
+
appIds.push(single);
|
|
1426
|
+
}
|
|
1427
|
+
if (!appIds.length && config.defaultAppId) {
|
|
1428
|
+
appIds.push(config.defaultAppId);
|
|
1429
|
+
}
|
|
1430
|
+
const unique = Array.from(new Set(appIds));
|
|
1431
|
+
if (!unique.length) {
|
|
1432
|
+
throw rpcError(-32602, 'appIds or appId is required unless REPRO_APP_ID or MCP browser auth provides a default app');
|
|
1433
|
+
}
|
|
1434
|
+
return unique;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
function normalizeCoverageTopic(input) {
|
|
1438
|
+
return optionalNonEmptyString(input.topic) || optionalNonEmptyString(input.question) || 'opaque data';
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
function queryForCoverageTopic(topic) {
|
|
1442
|
+
if (/\bopaque\b|opaqueDiagnostic|opaqueTraceId|ZXQ/i.test(topic)) {
|
|
1443
|
+
return 'opaque';
|
|
1444
|
+
}
|
|
1445
|
+
return topic;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
function assessCoverage(statements, contradictions) {
|
|
1449
|
+
const protectedStatements = statements.filter(isProtectivePolicyStatement);
|
|
1450
|
+
const exactStatements = statements.filter(isExactPolicyStatement);
|
|
1451
|
+
const unresolvedReviews = statements.filter((statement) => statement?.meta?.review?.status === 'REQUIRES_DECISION');
|
|
1452
|
+
const reasons = [];
|
|
1453
|
+
|
|
1454
|
+
if (exactStatements.length > 0) {
|
|
1455
|
+
reasons.push(`${exactStatements.length} matching statement(s) keep opaque-related data exact.`);
|
|
1456
|
+
}
|
|
1457
|
+
if (unresolvedReviews.length > 0) {
|
|
1458
|
+
reasons.push(`${unresolvedReviews.length} matching raw-text review blocker(s) are unresolved.`);
|
|
1459
|
+
}
|
|
1460
|
+
if (contradictions.length > 0) {
|
|
1461
|
+
reasons.push(`${contradictions.length} same-leaf contradiction(s) were found.`);
|
|
1462
|
+
}
|
|
1463
|
+
if (statements.length === 0) {
|
|
1464
|
+
return {
|
|
1465
|
+
status: 'not_covered',
|
|
1466
|
+
summary: 'No opaque-related policy statements were found.',
|
|
1467
|
+
protectedStatementCount: 0,
|
|
1468
|
+
exactStatementCount: 0,
|
|
1469
|
+
unresolvedReviewCount: 0,
|
|
1470
|
+
reasons: ['No matching policy statements found.'],
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
if (reasons.length > 0 || protectedStatements.length === 0) {
|
|
1474
|
+
if (protectedStatements.length === 0) {
|
|
1475
|
+
reasons.push('No protective TOKENIZE, DROP, or regex-assisted raw-text policy matched.');
|
|
1476
|
+
}
|
|
1477
|
+
return {
|
|
1478
|
+
status: 'needs_review',
|
|
1479
|
+
summary: 'Opaque-related policy coverage needs review.',
|
|
1480
|
+
protectedStatementCount: protectedStatements.length,
|
|
1481
|
+
exactStatementCount: exactStatements.length,
|
|
1482
|
+
unresolvedReviewCount: unresolvedReviews.length,
|
|
1483
|
+
reasons,
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
return {
|
|
1488
|
+
status: 'safe',
|
|
1489
|
+
summary: 'Opaque-related policy coverage is protected by matching policy statements and has no exact-policy conflict.',
|
|
1490
|
+
protectedStatementCount: protectedStatements.length,
|
|
1491
|
+
exactStatementCount: 0,
|
|
1492
|
+
unresolvedReviewCount: 0,
|
|
1493
|
+
reasons,
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
function assessPolicyBlockerCoverage(statements, contradictions) {
|
|
1498
|
+
const unresolvedReviews = statements.filter(isReviewBlockerStatement);
|
|
1499
|
+
const reasons = [];
|
|
1500
|
+
if (unresolvedReviews.length > 0) {
|
|
1501
|
+
reasons.push(`${unresolvedReviews.length} unresolved privacy policy review blocker(s).`);
|
|
1502
|
+
}
|
|
1503
|
+
if (contradictions.length > 0) {
|
|
1504
|
+
reasons.push(`${contradictions.length} same-leaf contradiction(s) were found.`);
|
|
1505
|
+
}
|
|
1506
|
+
if (!unresolvedReviews.length && !contradictions.length) {
|
|
1507
|
+
return {
|
|
1508
|
+
status: 'safe',
|
|
1509
|
+
summary: 'No unresolved privacy policy review blockers were found.',
|
|
1510
|
+
protectedStatementCount: statements.filter(isProtectivePolicyStatement).length,
|
|
1511
|
+
exactStatementCount: statements.filter(isExactPolicyStatement).length,
|
|
1512
|
+
unresolvedReviewCount: 0,
|
|
1513
|
+
reasons: [],
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
return {
|
|
1517
|
+
status: 'needs_review',
|
|
1518
|
+
summary: 'Privacy policy has unresolved review blockers.',
|
|
1519
|
+
protectedStatementCount: statements.filter(isProtectivePolicyStatement).length,
|
|
1520
|
+
exactStatementCount: statements.filter(isExactPolicyStatement).length,
|
|
1521
|
+
unresolvedReviewCount: unresolvedReviews.length,
|
|
1522
|
+
reasons,
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
function buildCoverageEvidence(statements, contradictions, options = {}) {
|
|
1527
|
+
const protectedStatements = statements.filter(isProtectivePolicyStatement);
|
|
1528
|
+
const exactStatements = statements.filter(isExactPolicyStatement);
|
|
1529
|
+
const unresolvedReviews = statements.filter((statement) => statement?.meta?.review?.status === 'REQUIRES_DECISION');
|
|
1530
|
+
const relatedLeaves = Array.from(new Set(statements.flatMap(statementTargetLeaves))).sort();
|
|
1531
|
+
const actions = {};
|
|
1532
|
+
for (const statement of statements) {
|
|
1533
|
+
const action = statement?.apply?.action ?? 'UNKNOWN';
|
|
1534
|
+
actions[action] = (actions[action] ?? 0) + 1;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
return {
|
|
1538
|
+
relatedLeaves,
|
|
1539
|
+
actionCounts: actions,
|
|
1540
|
+
protectedBy: protectedStatements.slice(0, 8).map((statement) => formatCoverageStatement(statement, options)),
|
|
1541
|
+
keptExact: exactStatements.slice(0, 8).map((statement) => formatCoverageStatement(statement, options)),
|
|
1542
|
+
reviewBlockers: unresolvedReviews.slice(0, 8).map((statement) => formatCoverageStatement(statement, options)),
|
|
1543
|
+
contradictionCount: contradictions.length,
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
function formatCoverageStatement(statement, options = {}) {
|
|
1548
|
+
const descriptor = describePolicyStatement(statement);
|
|
1549
|
+
const summary = {
|
|
1550
|
+
label: descriptor.label,
|
|
1551
|
+
codeAnchor: descriptor.codeAnchor,
|
|
1552
|
+
kind: descriptor.kind,
|
|
1553
|
+
selector: statement?.when?.selector,
|
|
1554
|
+
action: statement?.apply?.action,
|
|
1555
|
+
rawTextMode: statement?.apply?.rawTextMode,
|
|
1556
|
+
descendantRawTextMode: statement?.apply?.descendantRawTextMode,
|
|
1557
|
+
reviewStatus: statement?.meta?.review?.status,
|
|
1558
|
+
reviewDecision: statement?.meta?.review?.decision,
|
|
1559
|
+
targetSummary: descriptor.targetSummary,
|
|
1560
|
+
reasoning: statement?.meta?.reasoning,
|
|
1561
|
+
};
|
|
1562
|
+
if (options.includeTechnical === true) {
|
|
1563
|
+
summary.technical = {
|
|
1564
|
+
statementSid: statement?.sid,
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
return summary;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
function coverageRecommendation(status, evidence) {
|
|
1571
|
+
if (status === 'safe') {
|
|
1572
|
+
const leaves = evidence.relatedLeaves.length ? evidence.relatedLeaves.join(', ') : 'the requested opaque data';
|
|
1573
|
+
return `Safe to approve for opaque coverage: ${leaves} is covered by protective policy and no matching exact-policy conflict was found.`;
|
|
1574
|
+
}
|
|
1575
|
+
if (status === 'not_covered') {
|
|
1576
|
+
return 'Add a protective policy for opaque data before approval. Prefer TOKENIZE for direct opaque identifier leaves.';
|
|
1577
|
+
}
|
|
1578
|
+
if (evidence.keptExact.length > 0) {
|
|
1579
|
+
return 'Review before approval: matching opaque-related policy keeps data exact. Change direct opaque leaves to TOKENIZE, unless this value is intentionally non-sensitive operational data.';
|
|
1580
|
+
}
|
|
1581
|
+
if (evidence.reviewBlockers.length > 0) {
|
|
1582
|
+
return 'Resolve the matching raw-text review blockers before approval. Use regex-assisted handling only for raw/freeform text, not structured direct leaves.';
|
|
1583
|
+
}
|
|
1584
|
+
return 'Review before approval: matching opaque coverage is incomplete or contradictory.';
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
function buildCoverageAnswerGuide(results) {
|
|
1588
|
+
const byStatus = (status) => results.filter((item) => item.status === status);
|
|
1589
|
+
const formatLine = (item) => `${item.appId}: ${item.summary} UI: ${item.ui?.url ?? 'unavailable'}`;
|
|
1590
|
+
const blockerLines = results.flatMap((item) =>
|
|
1591
|
+
Array.isArray(item.policyBlockers)
|
|
1592
|
+
? item.policyBlockers.map((blocker) => {
|
|
1593
|
+
const mode = blocker.currentPolicy.rawTextMode || blocker.currentPolicy.descendantRawTextMode || blocker.currentPolicy.action || 'UNKNOWN';
|
|
1594
|
+
return `${item.appId} ${blocker.label}: current ${blocker.currentPolicy.action}${mode && mode !== blocker.currentPolicy.action ? ` / ${mode}` : ''}; recommendation ${blocker.recommendation || 'review'}; UI ${blocker.ui.url}`;
|
|
1595
|
+
})
|
|
1596
|
+
: [],
|
|
1597
|
+
);
|
|
1598
|
+
return {
|
|
1599
|
+
lead: blockerLines.length
|
|
1600
|
+
? 'Report the inspected policy state/environment first, then list policy blockers by user-facing label/codeAnchor. Do not lead with field-* or function-* statement ids.'
|
|
1601
|
+
: 'Report the inspected policy state/environment first, then group apps by Safe, Needs Review, Not Covered, and Errors. Include each app UI link.',
|
|
1602
|
+
blockerLines,
|
|
1603
|
+
safe: byStatus('safe').map(formatLine),
|
|
1604
|
+
needsReview: byStatus('needs_review').map((item) =>
|
|
1605
|
+
`${formatLine(item)} Reasons: ${(item.needsReviewReasons ?? []).join(' ') || 'Needs review.'}`,
|
|
1606
|
+
),
|
|
1607
|
+
notCovered: byStatus('not_covered').map(formatLine),
|
|
1608
|
+
errors: byStatus('error').map(formatLine),
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
function renderPolicyInspectionText(value) {
|
|
1613
|
+
if (!value || typeof value !== 'object') return '';
|
|
1614
|
+
const lines = [
|
|
1615
|
+
`App: ${value.appId ?? 'unknown'}`,
|
|
1616
|
+
`Environment: ${value.environment ?? 'unknown'}`,
|
|
1617
|
+
`Policy state: ${value.state ?? 'unknown'}`,
|
|
1618
|
+
];
|
|
1619
|
+
if (value.generatedAt) {
|
|
1620
|
+
lines.push(`Generated: ${value.generatedAt}`);
|
|
1621
|
+
}
|
|
1622
|
+
if (value.summary) {
|
|
1623
|
+
lines.push(`Summary: ${value.summary}`);
|
|
1624
|
+
}
|
|
1625
|
+
lines.push(`Privacy Studio: ${value.ui?.url ?? 'unavailable'}`);
|
|
1626
|
+
const blockers = Array.isArray(value.policyBlockers) ? value.policyBlockers : [];
|
|
1627
|
+
if (blockers.length > 0) {
|
|
1628
|
+
lines.push('', `Policy Blockers (${blockers.length})`);
|
|
1629
|
+
for (const blocker of blockers) {
|
|
1630
|
+
lines.push(...renderPolicyBlockerTextLines(blocker));
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
if (Array.isArray(value.contradictions) && value.contradictions.length > 0) {
|
|
1634
|
+
lines.push('', `Contradictions (${value.contradictions.length})`);
|
|
1635
|
+
for (const contradiction of value.contradictions) {
|
|
1636
|
+
lines.push(`- ${contradiction.message}`);
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
if (!blockers.length && Array.isArray(value.matches) && value.matches.length > 0) {
|
|
1640
|
+
lines.push('', 'Matching Policies');
|
|
1641
|
+
for (const match of value.matches.slice(0, 12)) {
|
|
1642
|
+
lines.push(`- ${match.label || match.selector || 'policy'}: ${match.action || match.policy?.action || 'UNKNOWN'}${match.rawTextMode ? ` / ${match.rawTextMode}` : ''}`);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
return lines.join('\n');
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
function renderPrivacyCoverageText(value) {
|
|
1649
|
+
if (!value || typeof value !== 'object' || !Array.isArray(value.results)) return '';
|
|
1650
|
+
const lines = [
|
|
1651
|
+
`Summary: ${value.summary ?? 'privacy coverage inspected'}`,
|
|
1652
|
+
];
|
|
1653
|
+
for (const result of value.results) {
|
|
1654
|
+
lines.push(
|
|
1655
|
+
'',
|
|
1656
|
+
`App: ${result.appId ?? 'unknown'}`,
|
|
1657
|
+
`Environment: ${result.environment ?? 'unknown'}`,
|
|
1658
|
+
`Policy state: ${result.state ?? 'unknown'}`,
|
|
1659
|
+
`Status: ${result.status ?? 'unknown'}`,
|
|
1660
|
+
);
|
|
1661
|
+
if (result.inspectedPolicy?.generatedAt) {
|
|
1662
|
+
lines.push(`Generated: ${result.inspectedPolicy.generatedAt}`);
|
|
1663
|
+
}
|
|
1664
|
+
lines.push(`Privacy Studio: ${result.ui?.url ?? 'unavailable'}`);
|
|
1665
|
+
const blockers = Array.isArray(result.policyBlockers) ? result.policyBlockers : [];
|
|
1666
|
+
if (blockers.length > 0) {
|
|
1667
|
+
lines.push('', `Policy Blockers (${blockers.length})`);
|
|
1668
|
+
for (const blocker of blockers) {
|
|
1669
|
+
lines.push(...renderPolicyBlockerTextLines(blocker));
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
if (Array.isArray(result.needsReviewReasons) && result.needsReviewReasons.length > 0 && blockers.length === 0) {
|
|
1673
|
+
lines.push('', 'Needs Review Reasons');
|
|
1674
|
+
for (const reason of result.needsReviewReasons) {
|
|
1675
|
+
lines.push(`- ${reason}`);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
return lines.join('\n');
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
function renderPolicyBlockerTextLines(blocker) {
|
|
1683
|
+
const action = blocker?.currentPolicy?.action ?? 'UNKNOWN';
|
|
1684
|
+
const mode = blocker?.currentPolicy?.rawTextMode || blocker?.currentPolicy?.descendantRawTextMode;
|
|
1685
|
+
const choices = Array.isArray(blocker?.choices) && blocker.choices.length ? blocker.choices.join(', ') : 'none';
|
|
1686
|
+
const surfaces = Array.isArray(blocker?.targetSummary?.surfaces) && blocker.targetSummary.surfaces.length
|
|
1687
|
+
? blocker.targetSummary.surfaces.join(', ')
|
|
1688
|
+
: 'unknown surface';
|
|
1689
|
+
return [
|
|
1690
|
+
`- ${blocker?.label ?? 'policy blocker'}`,
|
|
1691
|
+
` codeAnchor: ${blocker?.codeAnchor ?? blocker?.technical?.selector ?? 'n/a'}`,
|
|
1692
|
+
` currentPolicy: ${action}${mode ? ` / ${mode}` : ''}`,
|
|
1693
|
+
` recommendation: ${blocker?.recommendation ?? 'review'}`,
|
|
1694
|
+
` choices: ${choices}`,
|
|
1695
|
+
` surfaces: ${surfaces}`,
|
|
1696
|
+
` reason: ${blocker?.reason ?? blocker?.reviewReason ?? 'n/a'}`,
|
|
1697
|
+
` UI: ${blocker?.ui?.url ?? 'unavailable'}`,
|
|
1698
|
+
];
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
function summarizeCoverageResults({ safe, needsReview, notCovered, errors }) {
|
|
1702
|
+
return [
|
|
1703
|
+
`${safe.length} safe`,
|
|
1704
|
+
`${needsReview.length} need review`,
|
|
1705
|
+
`${notCovered.length} not covered`,
|
|
1706
|
+
`${errors.length} errors`,
|
|
1707
|
+
].join(', ');
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
async function inspectPrivacyPolicy(input, config, client) {
|
|
1711
|
+
const appId = optionalNonEmptyString(input.appId) || config.defaultAppId;
|
|
1712
|
+
if (!appId) {
|
|
1713
|
+
throw rpcError(-32602, 'appId is required unless REPRO_APP_ID or MCP browser auth provides a default app');
|
|
1714
|
+
}
|
|
1715
|
+
if (!client || typeof client.requestApi !== 'function') {
|
|
1716
|
+
throw rpcError(-32603, 'This MCP client cannot fetch app privacy policies');
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
const requestedEnvironment = optionalNonEmptyString(input.environment);
|
|
1720
|
+
const state = normalizePolicyState(input.state);
|
|
1721
|
+
const privacyConfig = await client.requestApi(`v1/apps/${encodeURIComponent(appId)}/privacy`, {
|
|
1722
|
+
method: 'GET',
|
|
1723
|
+
headers: input.tenantId ? { 'x-tenant-id': input.tenantId } : undefined,
|
|
1724
|
+
});
|
|
1725
|
+
const selection = selectPolicyForInspection(privacyConfig, requestedEnvironment, state);
|
|
1726
|
+
if (!selection) {
|
|
1727
|
+
return {
|
|
1728
|
+
status: 'not_found',
|
|
1729
|
+
appId,
|
|
1730
|
+
environment: requestedEnvironment ?? null,
|
|
1731
|
+
state,
|
|
1732
|
+
message: 'No pending or approved privacy policy exists for the requested app/environment.',
|
|
1733
|
+
ui: buildPrivacyStudioUi(config, appId, requestedEnvironment, policyInspectionQuery(input), input.openUi === true),
|
|
1734
|
+
};
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
const statements = flattenPrivacyStatements(selection.policy);
|
|
1738
|
+
const query = policyInspectionQuery(input);
|
|
1739
|
+
const includeTechnical = input.includeTechnical === true;
|
|
1740
|
+
const blockerQuery = isPolicyBlockerQuery(query);
|
|
1741
|
+
const matches = selectPolicyInspectionMatches(statements, input, query).slice(0, blockerQuery ? 100 : 12);
|
|
1742
|
+
const matchedLeaves = new Set(matches.flatMap((match) => statementTargetLeaves(match.statement)));
|
|
1743
|
+
const contradictions = findSameLeafPolicyContradictions(statements, matchedLeaves);
|
|
1744
|
+
const uiQuery = query || contradictions[0]?.leaf || '';
|
|
1745
|
+
const ui = buildPrivacyStudioUi(config, appId, selection.environmentId, uiQuery, input.openUi === true);
|
|
1746
|
+
const policyBlockers = buildPolicyBlockerSummaries(
|
|
1747
|
+
blockerQuery ? matches.map((match) => match.statement) : matches.map((match) => match.statement).filter(isReviewBlockerStatement),
|
|
1748
|
+
config,
|
|
1749
|
+
appId,
|
|
1750
|
+
selection.environmentId,
|
|
1751
|
+
{ includeTechnical },
|
|
1752
|
+
);
|
|
1753
|
+
if (ui.openRequested) {
|
|
1754
|
+
try {
|
|
1755
|
+
await openUrl(ui.url);
|
|
1756
|
+
ui.opened = true;
|
|
1757
|
+
} catch (error) {
|
|
1758
|
+
ui.opened = false;
|
|
1759
|
+
ui.openError = error instanceof Error ? error.message : String(error);
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
return {
|
|
1764
|
+
status: 'ok',
|
|
1765
|
+
appId,
|
|
1766
|
+
environment: selection.environmentId,
|
|
1767
|
+
state: selection.state,
|
|
1768
|
+
generatedAt: selection.record?.policy?.generatedAt ?? selection.record?.generatedAt ?? null,
|
|
1769
|
+
summary: summarizePolicyInspection(matches, contradictions, query, blockerQuery),
|
|
1770
|
+
ui,
|
|
1771
|
+
query: query || null,
|
|
1772
|
+
answerGuide: buildPolicyInspectionAnswerGuide({
|
|
1773
|
+
query,
|
|
1774
|
+
blockerQuery,
|
|
1775
|
+
policyBlockers,
|
|
1776
|
+
contradictions,
|
|
1777
|
+
ui,
|
|
1778
|
+
}),
|
|
1779
|
+
policyBlockers,
|
|
1780
|
+
matches: matches.map((match) => formatPolicyMatch(match.statement, match.score, { includeTechnical })),
|
|
1781
|
+
contradictions,
|
|
1782
|
+
rawTextHints: summarizeRawTextHints(selection.policy, query),
|
|
1783
|
+
};
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
async function updatePrivacyPolicy(input, config, client) {
|
|
1787
|
+
const appId = optionalNonEmptyString(input.appId) || config.defaultAppId;
|
|
1788
|
+
if (!appId) {
|
|
1789
|
+
throw rpcError(-32602, 'appId is required unless REPRO_APP_ID or MCP browser auth provides a default app');
|
|
1790
|
+
}
|
|
1791
|
+
if (!client || typeof client.requestApi !== 'function') {
|
|
1792
|
+
throw rpcError(-32603, 'This MCP client cannot fetch or update app privacy policies');
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
const requestedEnvironment = optionalNonEmptyString(input.environment);
|
|
1796
|
+
const state = normalizePolicyState(input.state);
|
|
1797
|
+
const decision = normalizePolicyUpdateDecision(input.decision);
|
|
1798
|
+
const apply = input.apply === true;
|
|
1799
|
+
const approve = input.approve === true;
|
|
1800
|
+
const privacyConfig = await client.requestApi(`v1/apps/${encodeURIComponent(appId)}/privacy`, {
|
|
1801
|
+
method: 'GET',
|
|
1802
|
+
headers: input.tenantId ? { 'x-tenant-id': input.tenantId } : undefined,
|
|
1803
|
+
});
|
|
1804
|
+
const selection = selectPolicyForInspection(privacyConfig, requestedEnvironment, state);
|
|
1805
|
+
if (!selection) {
|
|
1806
|
+
return {
|
|
1807
|
+
status: 'not_found',
|
|
1808
|
+
appId,
|
|
1809
|
+
environment: requestedEnvironment ?? null,
|
|
1810
|
+
state,
|
|
1811
|
+
summary: 'No pending or approved privacy policy exists for the requested app/environment.',
|
|
1812
|
+
ui: buildPrivacyStudioUi(config, appId, requestedEnvironment, policyInspectionQuery(input), input.openUi === true),
|
|
1813
|
+
};
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
const query = policyInspectionQuery(input);
|
|
1817
|
+
const statements = flattenPrivacyStatements(selection.policy);
|
|
1818
|
+
const selectedStatements = selectStatementsForPolicyUpdate(statements, input, query);
|
|
1819
|
+
const explicitSelection = updateInputHasExplicitStatementSelection(input);
|
|
1820
|
+
if (!selectedStatements.length) {
|
|
1821
|
+
return {
|
|
1822
|
+
status: 'not_found',
|
|
1823
|
+
appId,
|
|
1824
|
+
environment: selection.environmentId,
|
|
1825
|
+
state: selection.state,
|
|
1826
|
+
summary: 'No matching policy statements were found for the requested update.',
|
|
1827
|
+
ui: buildPrivacyStudioUi(config, appId, selection.environmentId, query, input.openUi === true),
|
|
1828
|
+
matches: rankPolicyStatementMatches(statements, input, query).slice(0, 8).map((match) => formatPolicyMatch(match.statement, match.score, { includeTechnical: true })),
|
|
1829
|
+
};
|
|
1830
|
+
}
|
|
1831
|
+
if (apply && !explicitSelection && selectedStatements.length !== 1 && input.allowMultiple !== true) {
|
|
1832
|
+
return {
|
|
1833
|
+
status: 'needs_confirmation',
|
|
1834
|
+
appId,
|
|
1835
|
+
environment: selection.environmentId,
|
|
1836
|
+
state: selection.state,
|
|
1837
|
+
summary: `The query matched ${selectedStatements.length} statements. Pass statementSids or allowMultiple=true before applying.`,
|
|
1838
|
+
ui: buildPrivacyStudioUi(config, appId, selection.environmentId, query, input.openUi === true),
|
|
1839
|
+
matches: selectedStatements.map((statement) => formatPolicyMatch(statement, 0, { includeTechnical: true })),
|
|
1840
|
+
proposed: buildPolicyUpdateDiff(selection.policy, selectedStatements, decision, input.rawTextHints),
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
const mutation = mutatePrivacyPolicy(selection.policy, selectedStatements, decision, input.rawTextHints);
|
|
1845
|
+
const ui = buildPrivacyStudioUi(
|
|
1846
|
+
config,
|
|
1847
|
+
appId,
|
|
1848
|
+
selection.environmentId,
|
|
1849
|
+
query || selectedStatements[0]?.when?.selector || '',
|
|
1850
|
+
input.openUi === true,
|
|
1851
|
+
);
|
|
1852
|
+
if (ui.openRequested) {
|
|
1853
|
+
try {
|
|
1854
|
+
await openUrl(ui.url);
|
|
1855
|
+
ui.opened = true;
|
|
1856
|
+
} catch (error) {
|
|
1857
|
+
ui.opened = false;
|
|
1858
|
+
ui.openError = error instanceof Error ? error.message : String(error);
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
if (!apply) {
|
|
1863
|
+
return {
|
|
1864
|
+
status: 'dry_run',
|
|
1865
|
+
appId,
|
|
1866
|
+
environment: selection.environmentId,
|
|
1867
|
+
state: selection.state,
|
|
1868
|
+
summary: `Prepared privacy policy update for ${selectedStatements.length} statement(s). No API write was made because apply=true was not set.`,
|
|
1869
|
+
ui,
|
|
1870
|
+
matches: selectedStatements.map((statement) => formatPolicyMatch(statement, 0, { includeTechnical: true })),
|
|
1871
|
+
diff: mutation.diff,
|
|
1872
|
+
nextStep: approve
|
|
1873
|
+
? 'Run again with apply=true to stage the update and approve=true to approve it after API validation.'
|
|
1874
|
+
: 'Run again with apply=true to stage the pending policy update.',
|
|
1875
|
+
};
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
const policyTarget = privacyPolicyTargetForApp(appId, selection.record, selection.policy);
|
|
1879
|
+
const pendingConfig = await client.requestApi(
|
|
1880
|
+
`v1/apps/${encodeURIComponent(appId)}/privacy/environments/${encodeURIComponent(selection.environmentId)}/policy`,
|
|
1881
|
+
{
|
|
1882
|
+
method: 'PUT',
|
|
1883
|
+
headers: input.tenantId ? { 'x-tenant-id': input.tenantId } : undefined,
|
|
1884
|
+
body: {
|
|
1885
|
+
state: 'pending',
|
|
1886
|
+
proposal: {
|
|
1887
|
+
...(selection.record?.proposal ?? {}),
|
|
1888
|
+
target: policyTarget,
|
|
1889
|
+
},
|
|
1890
|
+
policy: {
|
|
1891
|
+
...mutation.policy,
|
|
1892
|
+
sourceProposal: {
|
|
1893
|
+
...(mutation.policy?.sourceProposal ?? {}),
|
|
1894
|
+
target: policyTarget,
|
|
1895
|
+
},
|
|
1896
|
+
},
|
|
1897
|
+
policyContext: policyTarget,
|
|
1898
|
+
branchPatterns: selection.environment?.branchPatterns,
|
|
1899
|
+
deployOnMerge: selection.environment?.deployOnMerge,
|
|
1900
|
+
source: selection.record?.source,
|
|
1901
|
+
},
|
|
1902
|
+
},
|
|
1903
|
+
);
|
|
1904
|
+
|
|
1905
|
+
let approvedConfig = null;
|
|
1906
|
+
if (approve) {
|
|
1907
|
+
approvedConfig = await client.requestApi(
|
|
1908
|
+
`v1/apps/${encodeURIComponent(appId)}/privacy/environments/${encodeURIComponent(selection.environmentId)}/pending/approve`,
|
|
1909
|
+
{
|
|
1910
|
+
method: 'POST',
|
|
1911
|
+
headers: input.tenantId ? { 'x-tenant-id': input.tenantId } : undefined,
|
|
1912
|
+
},
|
|
1913
|
+
);
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
return {
|
|
1917
|
+
status: approve ? 'approved' : 'pending',
|
|
1918
|
+
appId,
|
|
1919
|
+
environment: selection.environmentId,
|
|
1920
|
+
previousState: selection.state,
|
|
1921
|
+
summary: approve
|
|
1922
|
+
? `Updated and approved privacy policy for ${selectedStatements.length} statement(s).`
|
|
1923
|
+
: `Staged pending privacy policy update for ${selectedStatements.length} statement(s).`,
|
|
1924
|
+
ui,
|
|
1925
|
+
diff: mutation.diff,
|
|
1926
|
+
api: {
|
|
1927
|
+
staged: true,
|
|
1928
|
+
approved: approve,
|
|
1929
|
+
pendingEnvironmentCount: Array.isArray(pendingConfig?.environments) ? pendingConfig.environments.length : undefined,
|
|
1930
|
+
approvedEnvironmentCount: Array.isArray(approvedConfig?.environments) ? approvedConfig.environments.length : undefined,
|
|
1931
|
+
},
|
|
1932
|
+
restartRequired: true,
|
|
1933
|
+
};
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
async function approvePrivacyPolicy(input, config, client) {
|
|
1937
|
+
const appId = optionalNonEmptyString(input.appId) || config.defaultAppId;
|
|
1938
|
+
if (!appId) {
|
|
1939
|
+
throw rpcError(-32602, 'appId is required unless REPRO_APP_ID or MCP browser auth provides a default app');
|
|
1940
|
+
}
|
|
1941
|
+
if (!client || typeof client.requestApi !== 'function') {
|
|
1942
|
+
throw rpcError(-32603, 'This MCP client cannot approve app privacy policies');
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
const requestedEnvironment = optionalNonEmptyString(input.environment);
|
|
1946
|
+
const privacyConfig = await client.requestApi(`v1/apps/${encodeURIComponent(appId)}/privacy`, {
|
|
1947
|
+
method: 'GET',
|
|
1948
|
+
headers: input.tenantId ? { 'x-tenant-id': input.tenantId } : undefined,
|
|
1949
|
+
});
|
|
1950
|
+
const selection = selectPolicyForInspection(privacyConfig, requestedEnvironment, 'pending');
|
|
1951
|
+
if (!selection) {
|
|
1952
|
+
return {
|
|
1953
|
+
status: 'not_found',
|
|
1954
|
+
appId,
|
|
1955
|
+
environment: requestedEnvironment ?? null,
|
|
1956
|
+
summary: 'No pending privacy policy exists for the requested app/environment.',
|
|
1957
|
+
ui: buildPrivacyStudioUi(config, appId, requestedEnvironment, '', input.openUi === true),
|
|
1958
|
+
};
|
|
1959
|
+
}
|
|
1960
|
+
const approvedConfig = await client.requestApi(
|
|
1961
|
+
`v1/apps/${encodeURIComponent(appId)}/privacy/environments/${encodeURIComponent(selection.environmentId)}/pending/approve`,
|
|
1962
|
+
{
|
|
1963
|
+
method: 'POST',
|
|
1964
|
+
headers: input.tenantId ? { 'x-tenant-id': input.tenantId } : undefined,
|
|
1965
|
+
},
|
|
1966
|
+
);
|
|
1967
|
+
return {
|
|
1968
|
+
status: 'approved',
|
|
1969
|
+
appId,
|
|
1970
|
+
environment: selection.environmentId,
|
|
1971
|
+
summary: 'Approved pending privacy policy through the Repro API validation path.',
|
|
1972
|
+
ui: buildPrivacyStudioUi(config, appId, selection.environmentId, '', input.openUi === true),
|
|
1973
|
+
api: {
|
|
1974
|
+
approvedEnvironmentCount: Array.isArray(approvedConfig?.environments) ? approvedConfig.environments.length : undefined,
|
|
1975
|
+
},
|
|
1976
|
+
restartRequired: true,
|
|
1977
|
+
};
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
function normalizePolicyState(value) {
|
|
1981
|
+
const normalized = optionalNonEmptyString(value)?.toLowerCase();
|
|
1982
|
+
if (normalized === 'pending' || normalized === 'approved') {
|
|
1983
|
+
return normalized;
|
|
1984
|
+
}
|
|
1985
|
+
return 'active';
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
function selectPolicyForInspection(privacyConfig, requestedEnvironment, state) {
|
|
1989
|
+
const environments = Array.isArray(privacyConfig?.environments) ? privacyConfig.environments : [];
|
|
1990
|
+
const environment = requestedEnvironment
|
|
1991
|
+
? environments.find((candidate) => candidate?.id === requestedEnvironment)
|
|
1992
|
+
: environments.find((candidate) => candidate?.pendingPolicy?.policy || candidate?.approvedPolicy?.policy);
|
|
1993
|
+
if (!environment) {
|
|
1994
|
+
return null;
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
let selectedState = state;
|
|
1998
|
+
let record = null;
|
|
1999
|
+
if (state === 'pending') {
|
|
2000
|
+
record = environment.pendingPolicy;
|
|
2001
|
+
} else if (state === 'approved') {
|
|
2002
|
+
record = environment.approvedPolicy;
|
|
2003
|
+
} else {
|
|
2004
|
+
record = environment.pendingPolicy?.policy ? environment.pendingPolicy : environment.approvedPolicy;
|
|
2005
|
+
selectedState = environment.pendingPolicy?.policy ? 'pending' : 'approved';
|
|
2006
|
+
}
|
|
2007
|
+
if (!record?.policy) {
|
|
2008
|
+
return null;
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
return {
|
|
2012
|
+
environmentId: environment.id,
|
|
2013
|
+
environment,
|
|
2014
|
+
state: selectedState,
|
|
2015
|
+
record,
|
|
2016
|
+
policy: record.policy,
|
|
2017
|
+
};
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
function normalizePolicyUpdateDecision(value) {
|
|
2021
|
+
const normalized = optionalNonEmptyString(value)?.toUpperCase();
|
|
2022
|
+
if (
|
|
2023
|
+
normalized === 'SAFE_PARTIAL_MASK' ||
|
|
2024
|
+
normalized === 'KEEP_EXACT' ||
|
|
2025
|
+
normalized === 'DROP' ||
|
|
2026
|
+
normalized === 'REGEX_ASSISTED'
|
|
2027
|
+
) {
|
|
2028
|
+
return normalized;
|
|
2029
|
+
}
|
|
2030
|
+
throw rpcError(-32602, 'decision must be KEEP_EXACT, REGEX_ASSISTED, DROP, or SAFE_PARTIAL_MASK. SAFE_PARTIAL_MASK is a last-resort unknown-shape fallback.');
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
function updateInputHasExplicitStatementSelection(input) {
|
|
2034
|
+
return Boolean(
|
|
2035
|
+
optionalNonEmptyString(input.statementSid) ||
|
|
2036
|
+
(Array.isArray(input.statementSids) && input.statementSids.some((sid) => optionalNonEmptyString(sid))),
|
|
2037
|
+
);
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
function selectStatementsForPolicyUpdate(statements, input, query) {
|
|
2041
|
+
const explicitSids = new Set();
|
|
2042
|
+
const single = optionalNonEmptyString(input.statementSid);
|
|
2043
|
+
if (single) {
|
|
2044
|
+
explicitSids.add(single);
|
|
2045
|
+
}
|
|
2046
|
+
if (Array.isArray(input.statementSids)) {
|
|
2047
|
+
for (const sid of input.statementSids) {
|
|
2048
|
+
const normalized = optionalNonEmptyString(sid);
|
|
2049
|
+
if (normalized) {
|
|
2050
|
+
explicitSids.add(normalized);
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
if (explicitSids.size) {
|
|
2055
|
+
return statements.filter((statement) => explicitSids.has(String(statement?.sid ?? '')));
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
return rankPolicyStatementMatches(statements, input, query)
|
|
2059
|
+
.slice(0, positiveIntegerOrFallback(input.maxMatches, input.allowMultiple === true ? 20 : 8))
|
|
2060
|
+
.map((match) => match.statement);
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
function buildPolicyUpdateDiff(policy, statements, decision, rawTextHintsInput) {
|
|
2064
|
+
return mutatePrivacyPolicy(policy, statements, decision, rawTextHintsInput).diff;
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
function mutatePrivacyPolicy(policy, selectedStatements, decision, rawTextHintsInput) {
|
|
2068
|
+
const clonedPolicy = cloneJson(policy) ?? {};
|
|
2069
|
+
const selectedSids = new Set(selectedStatements.map((statement) => String(statement?.sid ?? '')).filter(Boolean));
|
|
2070
|
+
const existingHints = Array.isArray(clonedPolicy.rawTextHints) ? clonedPolicy.rawTextHints : [];
|
|
2071
|
+
const hintPlan = normalizePolicyUpdateRawTextHints(rawTextHintsInput);
|
|
2072
|
+
const hintMutation = decision === 'REGEX_ASSISTED'
|
|
2073
|
+
? mergePolicyRawTextHints(existingHints, hintPlan, selectedStatements)
|
|
2074
|
+
: { hints: existingHints, namesByStatementSid: new Map(), changes: [] };
|
|
2075
|
+
clonedPolicy.rawTextHints = hintMutation.hints;
|
|
2076
|
+
|
|
2077
|
+
const beforeBySid = new Map(selectedStatements.map((statement) => [String(statement?.sid ?? ''), formatPolicyStatementSummary(statement)]));
|
|
2078
|
+
const afterBySid = new Map();
|
|
2079
|
+
replacePolicyStatements(clonedPolicy, (statement) => {
|
|
2080
|
+
if (!selectedSids.has(String(statement?.sid ?? ''))) {
|
|
2081
|
+
return statement;
|
|
2082
|
+
}
|
|
2083
|
+
const hintNames = hintMutation.namesByStatementSid.get(String(statement?.sid ?? '')) ?? [];
|
|
2084
|
+
const next = updatePolicyStatementForDecision(statement, decision, hintNames);
|
|
2085
|
+
afterBySid.set(String(statement?.sid ?? ''), formatPolicyStatementSummary(next));
|
|
2086
|
+
return next;
|
|
2087
|
+
});
|
|
2088
|
+
|
|
2089
|
+
return {
|
|
2090
|
+
policy: clonedPolicy,
|
|
2091
|
+
diff: {
|
|
2092
|
+
decision,
|
|
2093
|
+
affectedStatementCount: selectedSids.size,
|
|
2094
|
+
statements: Array.from(selectedSids).map((sid) => ({
|
|
2095
|
+
sid,
|
|
2096
|
+
before: beforeBySid.get(sid),
|
|
2097
|
+
after: afterBySid.get(sid),
|
|
2098
|
+
})),
|
|
2099
|
+
rawTextHints: hintMutation.changes,
|
|
2100
|
+
},
|
|
2101
|
+
};
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
function replacePolicyStatements(policy, updater) {
|
|
2105
|
+
if (Array.isArray(policy.groups)) {
|
|
2106
|
+
policy.groups = policy.groups.map((group) => ({
|
|
2107
|
+
...group,
|
|
2108
|
+
statements: Array.isArray(group?.statements)
|
|
2109
|
+
? group.statements.map((statement) => updater(statement))
|
|
2110
|
+
: group?.statements,
|
|
2111
|
+
}));
|
|
2112
|
+
}
|
|
2113
|
+
if (Array.isArray(policy.statements)) {
|
|
2114
|
+
policy.statements = policy.statements.map((statement) => updater(statement));
|
|
2115
|
+
}
|
|
2116
|
+
return policy;
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
function updatePolicyStatementForDecision(statement, decision, rawTextHintNames) {
|
|
2120
|
+
const next = cloneJson(statement) ?? {};
|
|
2121
|
+
next.apply = { ...(next.apply ?? {}) };
|
|
2122
|
+
const structured = decisionAppliesToStructuredContainer(next);
|
|
2123
|
+
delete next.apply.rawTextMode;
|
|
2124
|
+
delete next.apply.descendantRawTextMode;
|
|
2125
|
+
delete next.apply.textPolicy;
|
|
2126
|
+
|
|
2127
|
+
if (decision === 'KEEP_EXACT') {
|
|
2128
|
+
next.apply.action = 'KEEP_EXACT';
|
|
2129
|
+
next.apply.synthetic = false;
|
|
2130
|
+
next.meta = updatePolicyStatementReview(next.meta, 'KEEP_RAW', undefined);
|
|
2131
|
+
return next;
|
|
2132
|
+
}
|
|
2133
|
+
if (decision === 'DROP') {
|
|
2134
|
+
next.apply.action = 'DROP';
|
|
2135
|
+
next.apply.synthetic = true;
|
|
2136
|
+
next.meta = updatePolicyStatementReview(next.meta, 'SAFE_DROP', undefined);
|
|
2137
|
+
return next;
|
|
2138
|
+
}
|
|
2139
|
+
if (decision === 'SAFE_PARTIAL_MASK') {
|
|
2140
|
+
if (structured) {
|
|
2141
|
+
next.apply.action = 'KEEP_EXACT';
|
|
2142
|
+
next.apply.synthetic = true;
|
|
2143
|
+
next.apply.descendantRawTextMode = 'SAFE_PARTIAL_MASK';
|
|
2144
|
+
} else {
|
|
2145
|
+
next.apply.action = 'SUMMARIZE';
|
|
2146
|
+
next.apply.synthetic = true;
|
|
2147
|
+
next.apply.rawTextMode = 'SAFE_PARTIAL_MASK';
|
|
2148
|
+
}
|
|
2149
|
+
next.meta = updatePolicyStatementReview(next.meta, 'SAFE_PARTIAL_MASK', undefined);
|
|
2150
|
+
return next;
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
if (!rawTextHintNames.length) {
|
|
2154
|
+
throw rpcError(-32602, 'REGEX_ASSISTED updates require at least one rawTextHints entry or an existing bound hint');
|
|
2155
|
+
}
|
|
2156
|
+
if (structured) {
|
|
2157
|
+
next.apply.action = 'KEEP_EXACT';
|
|
2158
|
+
next.apply.synthetic = true;
|
|
2159
|
+
next.apply.descendantRawTextMode = 'REGEX_ASSISTED_EXACT';
|
|
2160
|
+
} else {
|
|
2161
|
+
next.apply.action = 'SUMMARIZE';
|
|
2162
|
+
next.apply.synthetic = true;
|
|
2163
|
+
next.apply.rawTextMode = 'REGEX_ASSISTED_EXACT';
|
|
2164
|
+
}
|
|
2165
|
+
next.meta = updatePolicyStatementReview(next.meta, 'REGEX_ASSISTED', rawTextHintNames);
|
|
2166
|
+
return next;
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
function decisionAppliesToStructuredContainer(statement) {
|
|
2170
|
+
const reviewChoices = Array.isArray(statement?.meta?.review?.choices)
|
|
2171
|
+
? statement.meta.review.choices
|
|
2172
|
+
: [];
|
|
2173
|
+
if (reviewChoices.includes('SAFE_DROP')) {
|
|
2174
|
+
return false;
|
|
2175
|
+
}
|
|
2176
|
+
if (reviewChoices.length) {
|
|
2177
|
+
return true;
|
|
2178
|
+
}
|
|
2179
|
+
if (statement?.apply?.descendantRawTextMode) {
|
|
2180
|
+
return true;
|
|
2181
|
+
}
|
|
2182
|
+
if (statement?.apply?.rawTextMode) {
|
|
2183
|
+
return false;
|
|
2184
|
+
}
|
|
2185
|
+
const targetParts = splitPolicyTargetParts(statement?.when?.target);
|
|
2186
|
+
if (targetParts.length) {
|
|
2187
|
+
return targetParts.some((part) =>
|
|
2188
|
+
/(?:^|\.)(?:metadata|payload|providerPayload|rawPayload|data(?:\[\])?|doc|docs\[\]|result|results\[\]|items\[\]|records\[\]|body|headers|query|params|args)$/.test(part),
|
|
2189
|
+
);
|
|
2190
|
+
}
|
|
2191
|
+
const selector = String(statement?.when?.selector ?? '').toLowerCase();
|
|
2192
|
+
return /\b(?:payload|body|data|doc|args|providerresponse|envelope|messages|records|items|result|results|metadata)\b/.test(selector);
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
function updatePolicyStatementReview(meta, decision, rawTextHintNames) {
|
|
2196
|
+
const nextMeta = { ...(meta ?? {}) };
|
|
2197
|
+
const previousReview = nextMeta.review && typeof nextMeta.review === 'object' ? nextMeta.review : null;
|
|
2198
|
+
nextMeta.review = {
|
|
2199
|
+
kind: 'UNSTRUCTURED_DATA',
|
|
2200
|
+
status: 'RESOLVED',
|
|
2201
|
+
recommendation: 'REGEX_ASSISTED',
|
|
2202
|
+
reasoning:
|
|
2203
|
+
previousReview?.reasoning ??
|
|
2204
|
+
'Updated through Repro MCP policy review.',
|
|
2205
|
+
choices: Array.isArray(previousReview?.choices) && previousReview.choices.length
|
|
2206
|
+
? previousReview.choices
|
|
2207
|
+
: ['REGEX_ASSISTED', 'KEEP_RAW', 'SAFE_DROP', 'SAFE_PARTIAL_MASK'],
|
|
2208
|
+
...previousReview,
|
|
2209
|
+
status: 'RESOLVED',
|
|
2210
|
+
decision,
|
|
2211
|
+
rawTextHintNames: decision === 'REGEX_ASSISTED' && rawTextHintNames?.length
|
|
2212
|
+
? mergeUniqueStrings(rawTextHintNames)
|
|
2213
|
+
: undefined,
|
|
2214
|
+
};
|
|
2215
|
+
return nextMeta;
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
function normalizePolicyUpdateRawTextHints(input) {
|
|
2219
|
+
if (typeof input === 'undefined' || input === null) {
|
|
2220
|
+
return [];
|
|
2221
|
+
}
|
|
2222
|
+
if (!Array.isArray(input)) {
|
|
2223
|
+
throw rpcError(-32602, 'rawTextHints must be an array when provided');
|
|
2224
|
+
}
|
|
2225
|
+
return input.map((hint, index) => normalizePolicyUpdateRawTextHint(hint, index));
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
function normalizePolicyUpdateRawTextHint(input, index) {
|
|
2229
|
+
if (!input || typeof input !== 'object') {
|
|
2230
|
+
throw rpcError(-32602, `rawTextHints[${index}] must be an object`);
|
|
2231
|
+
}
|
|
2232
|
+
const regex = requireNonEmptyString(input.regex, `rawTextHints[${index}].regex`);
|
|
2233
|
+
try {
|
|
2234
|
+
new RegExp(regex);
|
|
2235
|
+
} catch (error) {
|
|
2236
|
+
throw rpcError(-32602, `rawTextHints[${index}].regex is invalid: ${error instanceof Error ? error.message : String(error)}`);
|
|
2237
|
+
}
|
|
2238
|
+
const action = optionalNonEmptyString(input.action)?.toUpperCase() || 'TOKENIZE';
|
|
2239
|
+
if (action !== 'TOKENIZE' && action !== 'DROP') {
|
|
2240
|
+
throw rpcError(-32602, `rawTextHints[${index}].action must be TOKENIZE or DROP`);
|
|
2241
|
+
}
|
|
2242
|
+
return {
|
|
2243
|
+
name: optionalNonEmptyString(input.name),
|
|
2244
|
+
scope: 'RAW_TEXT_ONLY',
|
|
2245
|
+
action,
|
|
2246
|
+
regex,
|
|
2247
|
+
tokenLabel: optionalNonEmptyString(input.tokenLabel),
|
|
2248
|
+
};
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
function mergePolicyRawTextHints(existingHints, hintPlan, selectedStatements) {
|
|
2252
|
+
const nextHints = existingHints.map((hint) => ({ ...hint }));
|
|
2253
|
+
const changes = [];
|
|
2254
|
+
const namesByStatementSid = new Map();
|
|
2255
|
+
const existingNamesByStatement = new Map(
|
|
2256
|
+
selectedStatements.map((statement) => [
|
|
2257
|
+
String(statement?.sid ?? ''),
|
|
2258
|
+
mergeUniqueStrings(Array.isArray(statement?.meta?.review?.rawTextHintNames) ? statement.meta.review.rawTextHintNames : []),
|
|
2259
|
+
]),
|
|
2260
|
+
);
|
|
2261
|
+
|
|
2262
|
+
for (const statement of selectedStatements) {
|
|
2263
|
+
const sid = String(statement?.sid ?? '');
|
|
2264
|
+
const names = [...(existingNamesByStatement.get(sid) ?? [])];
|
|
2265
|
+
for (const planned of hintPlan) {
|
|
2266
|
+
const hint = {
|
|
2267
|
+
...planned,
|
|
2268
|
+
name: uniqueRawTextHintName(
|
|
2269
|
+
planned.name || defaultRawTextHintName(statement, planned),
|
|
2270
|
+
planned,
|
|
2271
|
+
nextHints,
|
|
2272
|
+
),
|
|
2273
|
+
};
|
|
2274
|
+
const existingIndex = nextHints.findIndex((candidate) => candidate?.name === hint.name);
|
|
2275
|
+
if (existingIndex >= 0 && rawTextHintEquivalent(nextHints[existingIndex], hint)) {
|
|
2276
|
+
changes.push({ type: 'reused', name: hint.name, action: hint.action, tokenLabel: hint.tokenLabel, regex: hint.regex });
|
|
2277
|
+
} else if (existingIndex >= 0) {
|
|
2278
|
+
nextHints[existingIndex] = hint;
|
|
2279
|
+
changes.push({ type: 'updated', name: hint.name, action: hint.action, tokenLabel: hint.tokenLabel, regex: hint.regex });
|
|
2280
|
+
} else {
|
|
2281
|
+
nextHints.push(hint);
|
|
2282
|
+
changes.push({ type: 'added', name: hint.name, action: hint.action, tokenLabel: hint.tokenLabel, regex: hint.regex });
|
|
2283
|
+
}
|
|
2284
|
+
names.push(hint.name);
|
|
2285
|
+
}
|
|
2286
|
+
namesByStatementSid.set(sid, mergeUniqueStrings(names));
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
for (const statement of selectedStatements) {
|
|
2290
|
+
const sid = String(statement?.sid ?? '');
|
|
2291
|
+
if (!namesByStatementSid.has(sid)) {
|
|
2292
|
+
namesByStatementSid.set(sid, mergeUniqueStrings(existingNamesByStatement.get(sid) ?? []));
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
return {
|
|
2296
|
+
hints: nextHints,
|
|
2297
|
+
namesByStatementSid,
|
|
2298
|
+
changes,
|
|
2299
|
+
};
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
function uniqueRawTextHintName(baseName, hint, existingHints) {
|
|
2303
|
+
const normalizedBase = optionalNonEmptyString(baseName) || 'Raw text detector';
|
|
2304
|
+
const sameName = existingHints.find((candidate) => candidate?.name === normalizedBase);
|
|
2305
|
+
if (!sameName || rawTextHintEquivalent(sameName, { ...hint, name: normalizedBase })) {
|
|
2306
|
+
return normalizedBase;
|
|
2307
|
+
}
|
|
2308
|
+
const actionSuffix = hint.action === 'DROP' ? 'drop' : 'tokenize';
|
|
2309
|
+
const labelSuffix = optionalNonEmptyString(hint.tokenLabel) || 'match';
|
|
2310
|
+
let index = 2;
|
|
2311
|
+
let candidateName = `${normalizedBase} ${labelSuffix} ${actionSuffix}`;
|
|
2312
|
+
while (existingHints.some((candidate) => candidate?.name === candidateName && !rawTextHintEquivalent(candidate, { ...hint, name: candidateName }))) {
|
|
2313
|
+
candidateName = `${normalizedBase} ${labelSuffix} ${actionSuffix} ${index}`;
|
|
2314
|
+
index += 1;
|
|
2315
|
+
}
|
|
2316
|
+
return candidateName;
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
function rawTextHintEquivalent(left, right) {
|
|
2320
|
+
return (
|
|
2321
|
+
left?.scope === 'RAW_TEXT_ONLY' &&
|
|
2322
|
+
right?.scope === 'RAW_TEXT_ONLY' &&
|
|
2323
|
+
left?.action === right?.action &&
|
|
2324
|
+
String(left?.regex ?? '') === String(right?.regex ?? '') &&
|
|
2325
|
+
String(left?.tokenLabel ?? '') === String(right?.tokenLabel ?? '')
|
|
2326
|
+
);
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
function defaultRawTextHintName(statement, hint) {
|
|
2330
|
+
const selector = optionalNonEmptyString(statement?.when?.selector) || 'raw text';
|
|
2331
|
+
const leaf = selector.split('|')[0].split('.').filter(Boolean).pop() || 'raw text';
|
|
2332
|
+
const label = humanizePolicyLabel(optionalNonEmptyString(hint.tokenLabel) || leaf);
|
|
2333
|
+
return `${humanizePolicyLabel(leaf)} ${label} detector`;
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
function humanizePolicyLabel(value) {
|
|
2337
|
+
return String(value ?? '')
|
|
2338
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
2339
|
+
.replace(/[_\-.]+/g, ' ')
|
|
2340
|
+
.replace(/\s+/g, ' ')
|
|
2341
|
+
.trim()
|
|
2342
|
+
.replace(/\b\w/g, (letter) => letter.toUpperCase()) || 'Raw Text';
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
function mergeUniqueStrings(values) {
|
|
2346
|
+
const output = [];
|
|
2347
|
+
const seen = new Set();
|
|
2348
|
+
for (const value of values ?? []) {
|
|
2349
|
+
const normalized = optionalNonEmptyString(value);
|
|
2350
|
+
if (!normalized || seen.has(normalized)) {
|
|
2351
|
+
continue;
|
|
2352
|
+
}
|
|
2353
|
+
seen.add(normalized);
|
|
2354
|
+
output.push(normalized);
|
|
2355
|
+
}
|
|
2356
|
+
return output;
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
function privacyPolicyTargetForApp(appId, record, policy) {
|
|
2360
|
+
const target =
|
|
2361
|
+
record?.proposal?.target ??
|
|
2362
|
+
policy?.sourceProposal?.target ??
|
|
2363
|
+
{};
|
|
2364
|
+
return {
|
|
2365
|
+
...target,
|
|
2366
|
+
appId,
|
|
2367
|
+
};
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
function cloneJson(value) {
|
|
2371
|
+
if (typeof value === 'undefined') {
|
|
2372
|
+
return undefined;
|
|
2373
|
+
}
|
|
2374
|
+
return JSON.parse(JSON.stringify(value));
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
function policyInspectionQuery(input) {
|
|
2378
|
+
return [
|
|
2379
|
+
optionalNonEmptyString(input.query),
|
|
2380
|
+
optionalNonEmptyString(input.functionName),
|
|
2381
|
+
optionalNonEmptyString(input.selector),
|
|
2382
|
+
optionalNonEmptyString(input.target),
|
|
2383
|
+
].find(Boolean) || codeInspectionQuery(input.code) || '';
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
function codeInspectionQuery(code) {
|
|
2387
|
+
const value = optionalNonEmptyString(code);
|
|
2388
|
+
if (!value) {
|
|
2389
|
+
return '';
|
|
2390
|
+
}
|
|
2391
|
+
const functionMatch = value.match(/\b(?:async\s+)?(?:function\s+)?([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/);
|
|
2392
|
+
if (functionMatch?.[1]) {
|
|
2393
|
+
return functionMatch[1];
|
|
2394
|
+
}
|
|
2395
|
+
const methodMatch = value.match(/\b([A-Za-z_$][A-Za-z0-9_$]*)\s*[:=]\s*(?:async\s*)?\(/);
|
|
2396
|
+
if (methodMatch?.[1]) {
|
|
2397
|
+
return methodMatch[1];
|
|
2398
|
+
}
|
|
2399
|
+
return value.slice(0, 120);
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
function flattenPrivacyStatements(policy) {
|
|
2403
|
+
if (!policy || typeof policy !== 'object') {
|
|
2404
|
+
return [];
|
|
2405
|
+
}
|
|
2406
|
+
if (Array.isArray(policy.statements)) {
|
|
2407
|
+
return policy.statements.filter(Boolean);
|
|
2408
|
+
}
|
|
2409
|
+
if (!Array.isArray(policy.groups)) {
|
|
2410
|
+
return [];
|
|
2411
|
+
}
|
|
2412
|
+
return policy.groups.flatMap((group) => Array.isArray(group?.statements) ? group.statements.filter(Boolean) : []);
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
function rankPolicyStatementMatches(statements, input, query) {
|
|
2416
|
+
const queryTokens = tokenizePolicySearch(query);
|
|
2417
|
+
const codeTokens = tokenizePolicySearch(optionalNonEmptyString(input.code) || '').slice(0, 20);
|
|
2418
|
+
const selector = optionalNonEmptyString(input.selector)?.toLowerCase();
|
|
2419
|
+
const target = optionalNonEmptyString(input.target)?.toLowerCase();
|
|
2420
|
+
const functionName = optionalNonEmptyString(input.functionName)?.toLowerCase();
|
|
2421
|
+
|
|
2422
|
+
return statements
|
|
2423
|
+
.map((statement) => {
|
|
2424
|
+
const haystack = policyStatementHaystack(statement);
|
|
2425
|
+
let score = 0;
|
|
2426
|
+
if (!queryTokens.length && !selector && !target && !functionName && !codeTokens.length) {
|
|
2427
|
+
score = 1;
|
|
2428
|
+
}
|
|
2429
|
+
if (query && haystack.includes(query.toLowerCase())) score += 5;
|
|
2430
|
+
if (selector && haystack.includes(selector)) score += 6;
|
|
2431
|
+
if (target && haystack.includes(target)) score += 6;
|
|
2432
|
+
if (functionName && haystack.includes(functionName)) score += 8;
|
|
2433
|
+
for (const token of queryTokens) {
|
|
2434
|
+
if (haystack.includes(token)) score += 2;
|
|
2435
|
+
}
|
|
2436
|
+
let codeHits = 0;
|
|
2437
|
+
for (const token of codeTokens) {
|
|
2438
|
+
if (haystack.includes(token)) codeHits += 1;
|
|
2439
|
+
}
|
|
2440
|
+
score += Math.min(codeHits, 5);
|
|
2441
|
+
return { statement, score };
|
|
2442
|
+
})
|
|
2443
|
+
.filter((match) => match.score > 0)
|
|
2444
|
+
.sort((left, right) => right.score - left.score || statementSortKey(left.statement).localeCompare(statementSortKey(right.statement)));
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
function selectPolicyInspectionMatches(statements, input, query) {
|
|
2448
|
+
if (!isPolicyBlockerQuery(query)) {
|
|
2449
|
+
return rankPolicyStatementMatches(statements, input, query);
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
const blockers = statements.filter(isReviewBlockerStatement);
|
|
2453
|
+
if (isAllPolicyBlockersQuery(query)) {
|
|
2454
|
+
return blockers.map((statement, index) => ({ statement, score: 1000 - index }));
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
const rankedBlockers = rankPolicyStatementMatches(blockers, input, query);
|
|
2458
|
+
if (rankedBlockers.length > 0) {
|
|
2459
|
+
return rankedBlockers;
|
|
2460
|
+
}
|
|
2461
|
+
return blockers.map((statement, index) => ({ statement, score: 1000 - index }));
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
function isPolicyBlockerQuery(query) {
|
|
2465
|
+
const normalized = typeof query === 'string' ? query.toLowerCase() : '';
|
|
2466
|
+
return /\b(blocker|blockers|blocking|needs?\s+review|review\s+blockers?|review\s+decisions?|approval\s+blockers?|unresolved|requires?\s+decision)\b/i.test(normalized);
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
function isAllPolicyBlockersQuery(query) {
|
|
2470
|
+
const normalized = typeof query === 'string' ? query.toLowerCase() : '';
|
|
2471
|
+
return /\b(all|every|list|return|show)\b/.test(normalized) && /\b(blocker|blockers|blocking|review\s+blockers?|approval\s+blockers?)\b/.test(normalized);
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
function isReviewBlockerStatement(statement) {
|
|
2475
|
+
return statement?.meta?.review?.status === 'REQUIRES_DECISION';
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
function policyStatementHaystack(statement) {
|
|
2479
|
+
return [
|
|
2480
|
+
statement?.sid,
|
|
2481
|
+
statement?.when?.scope,
|
|
2482
|
+
statement?.when?.selector,
|
|
2483
|
+
statement?.when?.target,
|
|
2484
|
+
statement?.when?.pattern,
|
|
2485
|
+
statement?.apply?.action,
|
|
2486
|
+
statement?.apply?.rawTextMode,
|
|
2487
|
+
statement?.apply?.descendantRawTextMode,
|
|
2488
|
+
statement?.meta?.reasoning,
|
|
2489
|
+
statement?.meta?.review?.status,
|
|
2490
|
+
statement?.meta?.review?.decision,
|
|
2491
|
+
Array.isArray(statement?.meta?.review?.rawTextHintNames)
|
|
2492
|
+
? statement.meta.review.rawTextHintNames.join(' ')
|
|
2493
|
+
: '',
|
|
2494
|
+
].filter(Boolean).join(' ').toLowerCase();
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
function tokenizePolicySearch(value) {
|
|
2498
|
+
const text = typeof value === 'string' ? value : '';
|
|
2499
|
+
return Array.from(new Set(
|
|
2500
|
+
text
|
|
2501
|
+
.toLowerCase()
|
|
2502
|
+
.split(/[^a-z0-9_]+/)
|
|
2503
|
+
.map((token) => token.trim())
|
|
2504
|
+
.filter((token) => token.length >= 3),
|
|
2505
|
+
));
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
function findSameLeafPolicyContradictions(statements, matchedLeaves) {
|
|
2509
|
+
const groups = new Map();
|
|
2510
|
+
for (const statement of statements) {
|
|
2511
|
+
if (statement?.when?.scope !== 'FIELD') {
|
|
2512
|
+
continue;
|
|
2513
|
+
}
|
|
2514
|
+
for (const leaf of statementTargetLeaves(statement)) {
|
|
2515
|
+
if (isStructuralPolicyLeaf(leaf)) {
|
|
2516
|
+
continue;
|
|
2517
|
+
}
|
|
2518
|
+
if (isSafeOperationalPolicyLeaf(leaf)) {
|
|
2519
|
+
continue;
|
|
2520
|
+
}
|
|
2521
|
+
if (matchedLeaves.size > 0 && !matchedLeaves.has(leaf)) {
|
|
2522
|
+
continue;
|
|
2523
|
+
}
|
|
2524
|
+
const group = groups.get(leaf) ?? { leaf, protective: [], exact: [] };
|
|
2525
|
+
if (isProtectivePolicyStatement(statement)) {
|
|
2526
|
+
group.protective.push(statement);
|
|
2527
|
+
}
|
|
2528
|
+
if (isExactPolicyStatement(statement)) {
|
|
2529
|
+
group.exact.push(statement);
|
|
2530
|
+
}
|
|
2531
|
+
groups.set(leaf, group);
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
return Array.from(groups.values())
|
|
2536
|
+
.filter((group) => group.protective.length > 0 && group.exact.length > 0)
|
|
2537
|
+
.map((group) => ({
|
|
2538
|
+
leaf: group.leaf,
|
|
2539
|
+
severity: 'warning',
|
|
2540
|
+
message: `Same leaf ${group.leaf} has both protective and KEEP_EXACT policies. The exact copy can bypass raw-text hints on copied runtime surfaces.`,
|
|
2541
|
+
protective: group.protective.slice(0, 5).map(formatPolicyStatementSummary),
|
|
2542
|
+
exact: group.exact.slice(0, 5).map(formatPolicyStatementSummary),
|
|
2543
|
+
}));
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
function statementTargetLeaves(statement) {
|
|
2547
|
+
return splitPolicyTargetParts(statement?.when?.target)
|
|
2548
|
+
.map(policyTargetLeaf)
|
|
2549
|
+
.filter(Boolean);
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
function splitPolicyTargetParts(target) {
|
|
2553
|
+
if (typeof target !== 'string') {
|
|
2554
|
+
return [];
|
|
2555
|
+
}
|
|
2556
|
+
return target
|
|
2557
|
+
.split(/[|,]/)
|
|
2558
|
+
.map((part) => part.trim())
|
|
2559
|
+
.filter(Boolean);
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
function policyTargetLeaf(target) {
|
|
2563
|
+
const parts = target.split('.').map((part) => part.trim()).filter(Boolean);
|
|
2564
|
+
if (!parts.length) {
|
|
2565
|
+
return '';
|
|
2566
|
+
}
|
|
2567
|
+
return parts[parts.length - 1]
|
|
2568
|
+
.replace(/\[[^\]]*\]/g, '')
|
|
2569
|
+
.replace(/[^A-Za-z0-9_]+/g, '')
|
|
2570
|
+
.trim()
|
|
2571
|
+
.toLowerCase();
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
function isProtectivePolicyStatement(statement) {
|
|
2575
|
+
const action = statement?.apply?.action;
|
|
2576
|
+
return (
|
|
2577
|
+
action === 'DROP' ||
|
|
2578
|
+
action === 'TOKENIZE' ||
|
|
2579
|
+
(action === 'SUMMARIZE' && typeof statement?.apply?.rawTextMode === 'string')
|
|
2580
|
+
);
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
function isExactPolicyStatement(statement) {
|
|
2584
|
+
return (
|
|
2585
|
+
statement?.apply?.action === 'KEEP_EXACT' &&
|
|
2586
|
+
!statement?.apply?.rawTextMode &&
|
|
2587
|
+
!statement?.apply?.descendantRawTextMode &&
|
|
2588
|
+
!statement?.apply?.textPolicy
|
|
2589
|
+
);
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
function isSafeOperationalPolicyLeaf(leaf) {
|
|
2593
|
+
return (
|
|
2594
|
+
/^(?:id|.*id|orderid|invoiceid|invoicenumber|shipmentid|taskid|tasktitle|paymentid|paymentreference|reference|sku|code|status|success|scenario|source|service|channel|direction|topic|partition|offset|priority|amount|currency|method|messagekey|cachekey|keyprefix|rediskey|duedate|createdat|updatedat|eventid|eventtype|retrycount|count|total|queue|flag)$/i.test(leaf) &&
|
|
2595
|
+
!/(email|phone|name|address|customer|user|account|person|profile|contact|owner|operator|reviewer|requester|approver|assignee|analyst|agent|payer|payee|token|secret|password|cookie|session|apikey|connection|card|cvv|pan)/i.test(leaf)
|
|
2596
|
+
);
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
function isStructuralPolicyLeaf(leaf) {
|
|
2600
|
+
return /^(?:args|arg|returnvalue|data|payload|body|doc|docs|result|resultmeta|messages|message|value|headers|query|before|after|error)$/i.test(leaf);
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
function buildPolicyBlockerSummaries(statements, config, appId, environment, options = {}) {
|
|
2604
|
+
return statements
|
|
2605
|
+
.filter(isReviewBlockerStatement)
|
|
2606
|
+
.map((statement) => {
|
|
2607
|
+
const descriptor = describePolicyStatement(statement);
|
|
2608
|
+
const review = statement?.meta?.review ?? {};
|
|
2609
|
+
const query = descriptor.codeAnchor || descriptor.label || statement?.when?.selector || '';
|
|
2610
|
+
const ui = buildPrivacyStudioUi(config, appId, environment, query, false);
|
|
2611
|
+
const blocker = {
|
|
2612
|
+
label: descriptor.label,
|
|
2613
|
+
codeAnchor: descriptor.codeAnchor,
|
|
2614
|
+
kind: descriptor.kind,
|
|
2615
|
+
reviewStatus: review.status,
|
|
2616
|
+
recommendation: review.recommendation,
|
|
2617
|
+
choices: Array.isArray(review.choices) ? prioritizeReviewChoices(review.choices) : [],
|
|
2618
|
+
currentPolicy: {
|
|
2619
|
+
action: statement?.apply?.action,
|
|
2620
|
+
rawTextMode: statement?.apply?.rawTextMode,
|
|
2621
|
+
descendantRawTextMode: statement?.apply?.descendantRawTextMode,
|
|
2622
|
+
},
|
|
2623
|
+
targetSummary: descriptor.targetSummary,
|
|
2624
|
+
reason: statement?.meta?.reasoning || review.reasoning || null,
|
|
2625
|
+
reviewReason: review.reasoning || null,
|
|
2626
|
+
suggestedNextStep: suggestedPolicyBlockerNextStep(statement),
|
|
2627
|
+
ui,
|
|
2628
|
+
};
|
|
2629
|
+
if (options.includeTechnical === true) {
|
|
2630
|
+
blocker.technical = {
|
|
2631
|
+
statementSid: statement?.sid,
|
|
2632
|
+
selector: statement?.when?.selector,
|
|
2633
|
+
target: statement?.when?.target,
|
|
2634
|
+
};
|
|
2635
|
+
}
|
|
2636
|
+
return blocker;
|
|
2637
|
+
});
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
function prioritizeReviewChoices(choices) {
|
|
2641
|
+
const priority = ['REGEX_ASSISTED', 'KEEP_RAW', 'SAFE_DROP', 'SAFE_PARTIAL_MASK'];
|
|
2642
|
+
const unique = Array.from(new Set(choices.filter((choice) => typeof choice === 'string' && choice.trim())));
|
|
2643
|
+
return unique.sort((left, right) => {
|
|
2644
|
+
const leftIndex = priority.indexOf(left);
|
|
2645
|
+
const rightIndex = priority.indexOf(right);
|
|
2646
|
+
return (leftIndex === -1 ? priority.length : leftIndex) - (rightIndex === -1 ? priority.length : rightIndex);
|
|
2647
|
+
});
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
function suggestedPolicyBlockerNextStep(statement) {
|
|
2651
|
+
const review = statement?.meta?.review ?? {};
|
|
2652
|
+
const action = statement?.apply?.action;
|
|
2653
|
+
const rawTextMode = statement?.apply?.rawTextMode;
|
|
2654
|
+
if (review.recommendation === 'REGEX_ASSISTED') {
|
|
2655
|
+
if (rawTextMode) {
|
|
2656
|
+
return 'Review the expected raw text. Use REGEX_ASSISTED with approved detectors for sensitive substrings, KEEP_EXACT only if the text is non-sensitive, and SAFE_PARTIAL_MASK only as a last-resort unknown-shape fallback.';
|
|
2657
|
+
}
|
|
2658
|
+
return 'Review the expected structured container. Keep the container exact when child field policies cover known leaves, and add regex-assisted descendant detectors only for sensitive unknown strings.';
|
|
2659
|
+
}
|
|
2660
|
+
if (action === 'SUMMARIZE') {
|
|
2661
|
+
return 'Decide whether this raw/freeform value should be exact, dropped, or regex-assisted based on expected contents.';
|
|
2662
|
+
}
|
|
2663
|
+
return 'Resolve this review decision before approval.';
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
function buildPolicyInspectionAnswerGuide({ query, blockerQuery, policyBlockers, contradictions, ui }) {
|
|
2667
|
+
const blockerLines = policyBlockers.map((blocker) => {
|
|
2668
|
+
const mode = blocker.currentPolicy.rawTextMode || blocker.currentPolicy.descendantRawTextMode || blocker.currentPolicy.action || 'UNKNOWN';
|
|
2669
|
+
return `${blocker.label}: current ${blocker.currentPolicy.action}${mode && mode !== blocker.currentPolicy.action ? ` / ${mode}` : ''}; recommendation ${blocker.recommendation || 'review'}; UI ${blocker.ui.url}`;
|
|
2670
|
+
});
|
|
2671
|
+
return {
|
|
2672
|
+
lead: blockerQuery
|
|
2673
|
+
? `Answer with the ${policyBlockers.length} user-facing policy blocker(s), using label/codeAnchor names instead of field-* statement ids.`
|
|
2674
|
+
: `Answer with user-facing code anchors and policy names for ${query || 'the selected policy'}.`,
|
|
2675
|
+
presentationRules: [
|
|
2676
|
+
'Do not lead with field-* or function-* statement ids; those are technical update handles only.',
|
|
2677
|
+
'Use codeAnchor for function policies, such as fn:Service.method, and use the selector/runtime surface for field policies.',
|
|
2678
|
+
'Include the Privacy Studio URL for the overall query and per-blocker URLs when blockers are present.',
|
|
2679
|
+
'Mention SAFE_PARTIAL_MASK only as an existing policy mode or last-resort option, not as the default recommendation.',
|
|
2680
|
+
],
|
|
2681
|
+
blockerLines,
|
|
2682
|
+
contradictionLines: contradictions.map((contradiction) => contradiction.message),
|
|
2683
|
+
privacyStudioUrl: ui?.url ?? null,
|
|
2684
|
+
};
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
function describePolicyStatement(statement) {
|
|
2688
|
+
const scope = statement?.when?.scope || 'UNKNOWN';
|
|
2689
|
+
const selector = statement?.when?.selector || '';
|
|
2690
|
+
const target = statement?.when?.target || '';
|
|
2691
|
+
const targetSummary = summarizePolicyTargets(target);
|
|
2692
|
+
if (scope === 'FUNCTION' || selector.startsWith('fn:')) {
|
|
2693
|
+
const targetLabel = targetSummary.primaryPath ? ` ${humanizeFunctionTarget(targetSummary.primaryPath)}` : '';
|
|
2694
|
+
return {
|
|
2695
|
+
label: `${selector || 'function policy'}${targetLabel}`,
|
|
2696
|
+
codeAnchor: selector || null,
|
|
2697
|
+
kind: 'function',
|
|
2698
|
+
targetSummary,
|
|
2699
|
+
};
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
const selectorLabel = selector || targetSummary.primaryLeaf || 'policy value';
|
|
2703
|
+
return {
|
|
2704
|
+
label: `${selectorLabel}${isRawTextReviewStatement(statement) ? ' raw text' : ''}`,
|
|
2705
|
+
codeAnchor: selector || targetSummary.primaryPath || null,
|
|
2706
|
+
kind: inferPolicyStatementKind(statement, targetSummary),
|
|
2707
|
+
targetSummary,
|
|
2708
|
+
};
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
function isRawTextReviewStatement(statement) {
|
|
2712
|
+
return (
|
|
2713
|
+
statement?.meta?.review?.kind === 'UNSTRUCTURED_DATA' ||
|
|
2714
|
+
statement?.apply?.rawTextMode ||
|
|
2715
|
+
statement?.apply?.descendantRawTextMode
|
|
2716
|
+
);
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
function inferPolicyStatementKind(statement, targetSummary) {
|
|
2720
|
+
if (statement?.when?.scope === 'PATTERN') return 'pattern';
|
|
2721
|
+
if (targetSummary.surfaces.some((surface) => /Kafka|event/.test(surface))) return 'event_payload';
|
|
2722
|
+
if (targetSummary.surfaces.some((surface) => /HTTP request/.test(surface))) return 'request_field';
|
|
2723
|
+
if (targetSummary.surfaces.some((surface) => /HTTP response/.test(surface))) return 'response_field';
|
|
2724
|
+
if (targetSummary.surfaces.some((surface) => /Function/.test(surface))) return 'function_copy';
|
|
2725
|
+
if (targetSummary.surfaces.some((surface) => /DB/.test(surface))) return 'db_copy';
|
|
2726
|
+
return 'field';
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
function summarizePolicyTargets(target) {
|
|
2730
|
+
const parts = splitPolicyTargetParts(target);
|
|
2731
|
+
const surfaces = [];
|
|
2732
|
+
const addSurface = (label) => {
|
|
2733
|
+
if (!surfaces.includes(label)) surfaces.push(label);
|
|
2734
|
+
};
|
|
2735
|
+
for (const part of parts) {
|
|
2736
|
+
if (/^request\.headers\./.test(part)) addSurface('HTTP request headers');
|
|
2737
|
+
else if (/^request\.body\./.test(part) || part === 'request.body') addSurface('HTTP request body');
|
|
2738
|
+
else if (/^request\.query\./.test(part) || part === 'request.query') addSurface('HTTP request query');
|
|
2739
|
+
else if (/^response\.body/.test(part)) addSurface('HTTP response body');
|
|
2740
|
+
else if (/^trace\.args\[0\]\.messages\[\]/.test(part) || /^trace\.args\[0\]\.messages\[0\]\.value/.test(part)) addSurface('Kafka/event payload copy');
|
|
2741
|
+
else if (/^trace\.args/.test(part)) addSurface('Function arguments');
|
|
2742
|
+
else if (/^trace\.returnValue/.test(part)) addSurface('Function return value');
|
|
2743
|
+
else if (/^trace\.error/.test(part)) addSurface('Function error');
|
|
2744
|
+
else if (/^db\./.test(part)) addSurface('DB/query/document copy');
|
|
2745
|
+
else if (/\.payload\./.test(part) || /\.payload$/.test(part)) addSurface('Nested payload copy');
|
|
2746
|
+
else addSurface('Runtime capture path');
|
|
2747
|
+
}
|
|
2748
|
+
return {
|
|
2749
|
+
pathCount: parts.length,
|
|
2750
|
+
surfaces,
|
|
2751
|
+
primaryPath: parts[0] || null,
|
|
2752
|
+
primaryLeaf: parts[0] ? policyTargetLeaf(parts[0]) : null,
|
|
2753
|
+
examples: parts.slice(0, 6),
|
|
2754
|
+
};
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
function humanizeFunctionTarget(target) {
|
|
2758
|
+
if (/^trace\.args/.test(target)) return 'arguments';
|
|
2759
|
+
if (/^trace\.returnValue/.test(target)) return 'return value';
|
|
2760
|
+
if (/^trace\.error/.test(target)) return 'error';
|
|
2761
|
+
return target;
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
function formatPolicyMatch(statement, score, options = {}) {
|
|
2765
|
+
return {
|
|
2766
|
+
score,
|
|
2767
|
+
...formatPolicyStatementSummary(statement, options),
|
|
2768
|
+
review: statement?.meta?.review
|
|
2769
|
+
? {
|
|
2770
|
+
status: statement.meta.review.status,
|
|
2771
|
+
decision: statement.meta.review.decision,
|
|
2772
|
+
rawTextHintNames: statement.meta.review.rawTextHintNames,
|
|
2773
|
+
}
|
|
2774
|
+
: undefined,
|
|
2775
|
+
reasoning: statement?.meta?.reasoning,
|
|
2776
|
+
};
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
function formatPolicyStatementSummary(statement, options = {}) {
|
|
2780
|
+
const descriptor = describePolicyStatement(statement);
|
|
2781
|
+
const summary = {
|
|
2782
|
+
label: descriptor.label,
|
|
2783
|
+
codeAnchor: descriptor.codeAnchor,
|
|
2784
|
+
kind: descriptor.kind,
|
|
2785
|
+
scope: statement?.when?.scope,
|
|
2786
|
+
selector: statement?.when?.selector,
|
|
2787
|
+
targetSummary: descriptor.targetSummary,
|
|
2788
|
+
pattern: statement?.when?.pattern,
|
|
2789
|
+
action: statement?.apply?.action,
|
|
2790
|
+
rawTextMode: statement?.apply?.rawTextMode,
|
|
2791
|
+
descendantRawTextMode: statement?.apply?.descendantRawTextMode,
|
|
2792
|
+
policy: {
|
|
2793
|
+
action: statement?.apply?.action,
|
|
2794
|
+
rawTextMode: statement?.apply?.rawTextMode,
|
|
2795
|
+
descendantRawTextMode: statement?.apply?.descendantRawTextMode,
|
|
2796
|
+
},
|
|
2797
|
+
};
|
|
2798
|
+
if (options.includeTechnical === true) {
|
|
2799
|
+
summary.technical = {
|
|
2800
|
+
statementSid: statement?.sid,
|
|
2801
|
+
target: statement?.when?.target,
|
|
2802
|
+
};
|
|
2803
|
+
}
|
|
2804
|
+
return summary;
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
function statementSortKey(statement) {
|
|
2808
|
+
return `${statement?.when?.scope ?? ''}|${statement?.when?.selector ?? ''}|${statement?.sid ?? ''}`;
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
function summarizePolicyInspection(matches, contradictions, query, blockerQuery = false) {
|
|
2812
|
+
if (blockerQuery) {
|
|
2813
|
+
return `Found ${matches.length} unresolved privacy policy review blocker(s) for ${query || 'the selected policy'}.`;
|
|
2814
|
+
}
|
|
2815
|
+
if (contradictions.length > 0) {
|
|
2816
|
+
return `Found ${matches.length} matching statement(s) and ${contradictions.length} same-leaf contradiction(s) for ${query || 'the selected policy'}.`;
|
|
2817
|
+
}
|
|
2818
|
+
if (matches.length > 0) {
|
|
2819
|
+
return `Found ${matches.length} matching privacy policy statement(s) for ${query || 'the selected policy'}.`;
|
|
2820
|
+
}
|
|
2821
|
+
return `No matching privacy policy statements found for ${query || 'the selected query'}.`;
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
function summarizeRawTextHints(policy, query) {
|
|
2825
|
+
const hints = Array.isArray(policy?.rawTextHints) ? policy.rawTextHints : [];
|
|
2826
|
+
const normalizedQuery = typeof query === 'string' ? query.toLowerCase() : '';
|
|
2827
|
+
return hints
|
|
2828
|
+
.filter((hint) => {
|
|
2829
|
+
if (!normalizedQuery) {
|
|
2830
|
+
return true;
|
|
2831
|
+
}
|
|
2832
|
+
return [
|
|
2833
|
+
hint?.name,
|
|
2834
|
+
hint?.tokenLabel,
|
|
2835
|
+
hint?.regex,
|
|
2836
|
+
hint?.action,
|
|
2837
|
+
].filter(Boolean).join(' ').toLowerCase().includes(normalizedQuery);
|
|
2838
|
+
})
|
|
2839
|
+
.slice(0, 12)
|
|
2840
|
+
.map((hint) => ({
|
|
2841
|
+
name: hint?.name,
|
|
2842
|
+
scope: hint?.scope,
|
|
2843
|
+
action: hint?.action,
|
|
2844
|
+
regex: hint?.regex,
|
|
2845
|
+
tokenLabel: hint?.tokenLabel,
|
|
2846
|
+
}));
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
function buildPrivacyStudioUi(config, appId, environment, query, openRequested) {
|
|
2850
|
+
const baseUrl = config.webBaseUrl || deriveWebBaseUrl(config.apiBaseUrl);
|
|
2851
|
+
if (!baseUrl) {
|
|
2852
|
+
return {
|
|
2853
|
+
url: null,
|
|
2854
|
+
path: '/admin/privacy',
|
|
2855
|
+
openRequested,
|
|
2856
|
+
opened: false,
|
|
2857
|
+
};
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
const url = new URL('/admin/privacy', baseUrl);
|
|
2861
|
+
url.searchParams.set('service', appId);
|
|
2862
|
+
if (environment) {
|
|
2863
|
+
url.searchParams.set('env', environment);
|
|
2864
|
+
}
|
|
2865
|
+
if (query) {
|
|
2866
|
+
url.searchParams.set('q', query);
|
|
2867
|
+
}
|
|
2868
|
+
return {
|
|
2869
|
+
url: url.toString(),
|
|
2870
|
+
path: `${url.pathname}${url.search}`,
|
|
2871
|
+
openRequested,
|
|
2872
|
+
opened: false,
|
|
2873
|
+
};
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
async function privacyAuditSession(input, config, client) {
|
|
2877
|
+
const tenantId = requireNonEmptyString(input.tenantId, 'tenantId');
|
|
2878
|
+
const sessionId = requireNonEmptyString(input.sessionId, 'sessionId');
|
|
2879
|
+
const maxRequests = clampPositiveInteger(input.maxRequests, 30, 1, 100);
|
|
2880
|
+
const maxDbChanges = clampPositiveInteger(input.maxDbChanges, 200, 1, 500);
|
|
2881
|
+
const includeTraces = input.includeTraces === true;
|
|
2882
|
+
const sources = [];
|
|
2883
|
+
const errors = [];
|
|
2884
|
+
|
|
2885
|
+
const requests = await callBackendTool(client, 'list_requests', {
|
|
2886
|
+
tenantId,
|
|
2887
|
+
sessionId,
|
|
2888
|
+
limit: maxRequests,
|
|
2889
|
+
}, errors);
|
|
2890
|
+
const requestItems = extractItems(requests).slice(0, maxRequests);
|
|
2891
|
+
|
|
2892
|
+
for (const request of requestItems) {
|
|
2893
|
+
const rid = typeof request?.rid === 'string' ? request.rid : undefined;
|
|
2894
|
+
if (!rid) continue;
|
|
2895
|
+
const detail = await callBackendTool(client, 'get_request', {
|
|
2896
|
+
tenantId,
|
|
2897
|
+
sessionId,
|
|
2898
|
+
requestRid: rid,
|
|
2899
|
+
includeBody: true,
|
|
2900
|
+
includeResponseBody: true,
|
|
2901
|
+
}, errors);
|
|
2902
|
+
sources.push({
|
|
2903
|
+
kind: 'request',
|
|
2904
|
+
id: rid,
|
|
2905
|
+
label: `${request.method ?? 'REQUEST'} ${request.url ?? rid}`,
|
|
2906
|
+
value: detail,
|
|
2907
|
+
});
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
const dbChanges = await callBackendTool(client, 'get_db_changes', {
|
|
2911
|
+
tenantId,
|
|
2912
|
+
sessionId,
|
|
2913
|
+
limit: maxDbChanges,
|
|
2914
|
+
includeQueryDetails: true,
|
|
2915
|
+
}, errors);
|
|
2916
|
+
sources.push({
|
|
2917
|
+
kind: 'db_changes',
|
|
2918
|
+
id: sessionId,
|
|
2919
|
+
label: `DB changes for ${sessionId}`,
|
|
2920
|
+
value: dbChanges,
|
|
2921
|
+
});
|
|
2922
|
+
|
|
2923
|
+
if (includeTraces) {
|
|
2924
|
+
for (const request of requestItems) {
|
|
2925
|
+
const rid = typeof request?.rid === 'string' ? request.rid : undefined;
|
|
2926
|
+
if (!rid) continue;
|
|
2927
|
+
const trace = await callBackendTool(client, 'get_trace', {
|
|
2928
|
+
tenantId,
|
|
2929
|
+
sessionId,
|
|
2930
|
+
requestRid: rid,
|
|
2931
|
+
includeEvents: true,
|
|
2932
|
+
}, errors);
|
|
2933
|
+
sources.push({
|
|
2934
|
+
kind: 'trace',
|
|
2935
|
+
id: rid,
|
|
2936
|
+
label: `trace ${rid}`,
|
|
2937
|
+
value: trace,
|
|
2938
|
+
});
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
const findings = [];
|
|
2943
|
+
for (const source of sources) {
|
|
2944
|
+
scanPrivacyValue(source.value, [], source, findings);
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
const leaks = findings.filter((finding) => finding.severity === 'leak');
|
|
2948
|
+
const warnings = findings.filter((finding) => finding.severity === 'warning');
|
|
2949
|
+
return {
|
|
2950
|
+
status: leaks.length ? 'failed' : warnings.length ? 'warning' : 'ok',
|
|
2951
|
+
data: {
|
|
2952
|
+
sessionId,
|
|
2953
|
+
scanned: {
|
|
2954
|
+
requests: requestItems.length,
|
|
2955
|
+
dbChanges: extractItems(dbChanges).length,
|
|
2956
|
+
traces: includeTraces ? requestItems.length : 0,
|
|
2957
|
+
},
|
|
2958
|
+
leaks,
|
|
2959
|
+
warnings,
|
|
2960
|
+
errors,
|
|
2961
|
+
},
|
|
2962
|
+
};
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2965
|
+
async function callBackendTool(client, name, args, errors) {
|
|
2966
|
+
try {
|
|
2967
|
+
return await client.executeTool(name, args);
|
|
2968
|
+
} catch (error) {
|
|
2969
|
+
errors.push({
|
|
2970
|
+
tool: name,
|
|
2971
|
+
message: error instanceof Error ? error.message : String(error),
|
|
2972
|
+
});
|
|
2973
|
+
return null;
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
function extractItems(payload) {
|
|
2978
|
+
if (Array.isArray(payload)) return payload;
|
|
2979
|
+
if (Array.isArray(payload?.items)) return payload.items;
|
|
2980
|
+
if (Array.isArray(payload?.data?.items)) return payload.data.items;
|
|
2981
|
+
if (Array.isArray(payload?.data?.requests)) return payload.data.requests;
|
|
2982
|
+
return [];
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
function scanPrivacyValue(value, pathParts, source, findings) {
|
|
2986
|
+
if (Array.isArray(value)) {
|
|
2987
|
+
value.forEach((item, index) => scanPrivacyValue(item, pathParts.concat(String(index)), source, findings));
|
|
2988
|
+
return;
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2991
|
+
if (!value || typeof value !== 'object') {
|
|
2992
|
+
if (typeof value === 'string') {
|
|
2993
|
+
scanPrivacyString(value, pathParts, source, findings);
|
|
2994
|
+
}
|
|
2995
|
+
return;
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
for (const [key, child] of Object.entries(value)) {
|
|
2999
|
+
scanPrivacyValue(child, pathParts.concat(key), source, findings);
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
|
|
3003
|
+
function scanPrivacyString(value, pathParts, source, findings) {
|
|
3004
|
+
const pathLabel = pathParts.join('.');
|
|
3005
|
+
const normalizedPath = pathLabel.toLowerCase().replace(/[^a-z0-9.]+/g, '');
|
|
3006
|
+
const add = (severity, kind, match, message) => {
|
|
3007
|
+
findings.push({
|
|
3008
|
+
severity,
|
|
3009
|
+
kind,
|
|
3010
|
+
source: {
|
|
3011
|
+
kind: source.kind,
|
|
3012
|
+
id: source.id,
|
|
3013
|
+
label: source.label,
|
|
3014
|
+
},
|
|
3015
|
+
path: pathLabel || '$',
|
|
3016
|
+
sample: sampleText(match),
|
|
3017
|
+
message,
|
|
3018
|
+
});
|
|
3019
|
+
};
|
|
3020
|
+
|
|
3021
|
+
collectRegexMatchEntries(value, /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi)
|
|
3022
|
+
.filter((entry) => !/^email_tok_[a-f0-9]{8,}$/i.test(entry.match))
|
|
3023
|
+
.filter((entry) => !isEmailInsideUrlCredential(value, entry.start))
|
|
3024
|
+
.forEach((entry) => add('leak', 'raw_email', entry.match, 'Raw email was captured instead of a deterministic email token.'));
|
|
3025
|
+
|
|
3026
|
+
for (const pattern of RAW_SECRET_PATTERNS) {
|
|
3027
|
+
collectRegexMatches(value, pattern.pattern).forEach((match) =>
|
|
3028
|
+
add('leak', pattern.kind, match, pattern.message),
|
|
3029
|
+
);
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
if (/(^|\.)(cvv|cvc|securitycode)$/.test(normalizedPath) && /^\d{3,4}$/.test(value.trim())) {
|
|
3033
|
+
add('leak', 'card_verification_code', value, 'Raw card verification code was captured.');
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
collectRegexMatches(value, /\b(?:\d[ -]*?){13,19}\b/g)
|
|
3037
|
+
.filter((match) => !shouldIgnoreNumericLeakCandidate(match))
|
|
3038
|
+
.forEach((match) => add('leak', 'card_number', match, 'Raw card number-like value was captured.'));
|
|
3039
|
+
|
|
3040
|
+
if (/\[object Object\]/.test(value)) {
|
|
3041
|
+
add('warning', 'collapsed_object', value, 'A rich object was coerced into [object Object].');
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
if (/\[partially masked object\]/.test(value)) {
|
|
3045
|
+
add('warning', 'over_masked_object', value, 'A structured object was replaced by a generic fallback marker.');
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
if (isAuthPath(normalizedPath) && value !== '[dropped]') {
|
|
3049
|
+
add('warning', 'auth_field_not_dropped', value, 'Auth-bearing field was not fully dropped.');
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
if (isSafeOperationalPath(normalizedPath) && /^[A-Za-z0-9]{2,4}\.\.\.[A-Za-z0-9]{0,4}$/.test(value.trim())) {
|
|
3053
|
+
add('warning', 'over_masked_operational_value', value, 'Safe operational field appears fallback-masked.');
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
const RAW_SECRET_PATTERNS = [
|
|
3058
|
+
{
|
|
3059
|
+
kind: 'jwt',
|
|
3060
|
+
pattern: /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g,
|
|
3061
|
+
message: 'Raw JWT was captured.',
|
|
3062
|
+
},
|
|
3063
|
+
{
|
|
3064
|
+
kind: 'private_key',
|
|
3065
|
+
pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
3066
|
+
message: 'Raw private key material was captured.',
|
|
3067
|
+
},
|
|
3068
|
+
{
|
|
3069
|
+
kind: 'connection_string',
|
|
3070
|
+
pattern: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^\s"']+/gi,
|
|
3071
|
+
message: 'Raw connection string was captured.',
|
|
3072
|
+
},
|
|
3073
|
+
{
|
|
3074
|
+
kind: 'bearer_token',
|
|
3075
|
+
pattern: /\bBearer\s+(?!\[(?:dropped|secret-token|auth-token)\])[A-Za-z0-9._\-+/=]{8,}\b/gi,
|
|
3076
|
+
message: 'Raw bearer token was captured.',
|
|
3077
|
+
},
|
|
3078
|
+
];
|
|
3079
|
+
|
|
3080
|
+
function collectRegexMatches(value, pattern) {
|
|
3081
|
+
return collectRegexMatchEntries(value, pattern).map((entry) => entry.match);
|
|
3082
|
+
}
|
|
3083
|
+
|
|
3084
|
+
function collectRegexMatchEntries(value, pattern) {
|
|
3085
|
+
const matches = [];
|
|
3086
|
+
const globalPattern = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`);
|
|
3087
|
+
let match;
|
|
3088
|
+
while ((match = globalPattern.exec(value))) {
|
|
3089
|
+
if (match[0]) {
|
|
3090
|
+
matches.push({
|
|
3091
|
+
match: match[0],
|
|
3092
|
+
start: match.index,
|
|
3093
|
+
end: match.index + match[0].length,
|
|
3094
|
+
});
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
return matches;
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
function isEmailInsideUrlCredential(value, start) {
|
|
3101
|
+
const prefix = value.slice(Math.max(0, start - 80), start);
|
|
3102
|
+
const protocolIndex = prefix.lastIndexOf('://');
|
|
3103
|
+
if (protocolIndex < 0) {
|
|
3104
|
+
return false;
|
|
3105
|
+
}
|
|
3106
|
+
const afterProtocol = prefix.slice(protocolIndex + 3);
|
|
3107
|
+
return !/\s/.test(afterProtocol) && afterProtocol.includes(':');
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
function shouldIgnoreNumericLeakCandidate(value) {
|
|
3111
|
+
const digits = value.replace(/\D/g, '');
|
|
3112
|
+
if (digits.length < 13 || digits.length > 19) {
|
|
3113
|
+
return true;
|
|
3114
|
+
}
|
|
3115
|
+
if (/^\d{10,14}$/.test(digits)) {
|
|
3116
|
+
return true;
|
|
3117
|
+
}
|
|
3118
|
+
return false;
|
|
3119
|
+
}
|
|
3120
|
+
|
|
3121
|
+
function isAuthPath(normalizedPath) {
|
|
3122
|
+
return /(^|\.)(authorization|headerauthorization|auth|token|secret|password|cookie|sessioncookie|apikey|xapikey|xapikey|xwebhooksecret|webhooksecret)$/.test(normalizedPath);
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
function isSafeOperationalPath(normalizedPath) {
|
|
3126
|
+
return /(^|\.)(status|taskstatus|rawgatewaystatus|priority|scenario|success|classification|currency|method|queue|safequeue|region|saferegion|stage|workflowstage|featureflag|safefeatureflag|reasoncode|shard|reference|paymentreference|messagekey|eventtype)$/.test(normalizedPath);
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
function sampleText(value) {
|
|
3130
|
+
const text = String(value);
|
|
3131
|
+
return text.length > 180 ? `${text.slice(0, 177)}...` : text;
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
function requireNonEmptyString(value, label) {
|
|
3135
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
3136
|
+
throw rpcError(-32602, `${label} is required`);
|
|
3137
|
+
}
|
|
3138
|
+
return value.trim();
|
|
3139
|
+
}
|
|
3140
|
+
|
|
3141
|
+
function optionalNonEmptyString(value) {
|
|
3142
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
3143
|
+
}
|
|
3144
|
+
|
|
3145
|
+
function clampPositiveInteger(value, fallback, min, max) {
|
|
3146
|
+
const parsed = Number.parseInt(String(value ?? ''), 10);
|
|
3147
|
+
if (!Number.isFinite(parsed)) {
|
|
3148
|
+
return fallback;
|
|
3149
|
+
}
|
|
3150
|
+
return Math.max(min, Math.min(max, parsed));
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
function normalizeToolArguments(value) {
|
|
3154
|
+
if (value == null) {
|
|
3155
|
+
return {};
|
|
3156
|
+
}
|
|
3157
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
3158
|
+
throw rpcError(-32602, 'Tool arguments must be an object');
|
|
3159
|
+
}
|
|
3160
|
+
return value;
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
function prepareToolInput(input, config) {
|
|
3164
|
+
const normalized = { ...input };
|
|
3165
|
+
if (!normalized.tenantId) {
|
|
3166
|
+
if (!config.defaultTenantId) {
|
|
3167
|
+
throw rpcError(
|
|
3168
|
+
-32602,
|
|
3169
|
+
'tenantId is required unless REPRO_DEFAULT_TENANT_ID is configured',
|
|
3170
|
+
);
|
|
3171
|
+
}
|
|
3172
|
+
normalized.tenantId = config.defaultTenantId;
|
|
3173
|
+
}
|
|
3174
|
+
return normalized;
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
function augmentPaths(value, pathMappings) {
|
|
3178
|
+
if (Array.isArray(value)) {
|
|
3179
|
+
return value.map((item) => augmentPaths(item, pathMappings));
|
|
3180
|
+
}
|
|
3181
|
+
|
|
3182
|
+
if (!value || typeof value !== 'object') {
|
|
3183
|
+
return value;
|
|
3184
|
+
}
|
|
3185
|
+
|
|
3186
|
+
const result = {};
|
|
3187
|
+
for (const [key, nestedValue] of Object.entries(value)) {
|
|
3188
|
+
result[key] = augmentPaths(nestedValue, pathMappings);
|
|
3189
|
+
}
|
|
3190
|
+
|
|
3191
|
+
const deployedPath =
|
|
3192
|
+
typeof result.deployedPath === 'string'
|
|
3193
|
+
? result.deployedPath
|
|
3194
|
+
: typeof result.file === 'string'
|
|
3195
|
+
? result.file
|
|
3196
|
+
: typeof result.filePath === 'string'
|
|
3197
|
+
? result.filePath
|
|
3198
|
+
: null;
|
|
3199
|
+
|
|
3200
|
+
if (deployedPath && result.deployedPath == null) {
|
|
3201
|
+
result.deployedPath = deployedPath;
|
|
3202
|
+
}
|
|
3203
|
+
|
|
3204
|
+
if (!result.repoRelativePath && deployedPath) {
|
|
3205
|
+
const derivedRepoRelativePath = deriveRepoRelativePath(deployedPath, pathMappings);
|
|
3206
|
+
if (derivedRepoRelativePath) {
|
|
3207
|
+
result.repoRelativePath = derivedRepoRelativePath;
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
|
|
3211
|
+
const localPath = resolveLocalPath(
|
|
3212
|
+
deployedPath,
|
|
3213
|
+
typeof result.repoRelativePath === 'string' ? result.repoRelativePath : null,
|
|
3214
|
+
pathMappings,
|
|
3215
|
+
);
|
|
3216
|
+
if (localPath && result.localPath == null) {
|
|
3217
|
+
result.localPath = localPath;
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
return result;
|
|
3221
|
+
}
|
|
3222
|
+
|
|
3223
|
+
function deriveRepoRelativePath(deployedPath, pathMappings) {
|
|
3224
|
+
if (!isAbsolutePath(deployedPath)) {
|
|
3225
|
+
return deployedPath;
|
|
3226
|
+
}
|
|
3227
|
+
|
|
3228
|
+
const mapping = findMatchingPathMapping(deployedPath, pathMappings);
|
|
3229
|
+
if (!mapping) {
|
|
3230
|
+
return null;
|
|
3231
|
+
}
|
|
3232
|
+
|
|
3233
|
+
const suffix = trimLeadingSeparators(deployedPath.slice(mapping.remotePrefix.length));
|
|
3234
|
+
return suffix || path.basename(deployedPath);
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
function resolveLocalPath(deployedPath, repoRelativePath, pathMappings) {
|
|
3238
|
+
if (typeof deployedPath === 'string' && isAbsolutePath(deployedPath)) {
|
|
3239
|
+
const mapping = findMatchingPathMapping(deployedPath, pathMappings);
|
|
3240
|
+
if (mapping) {
|
|
3241
|
+
const suffix = trimLeadingSeparators(deployedPath.slice(mapping.remotePrefix.length));
|
|
3242
|
+
return suffix ? path.join(mapping.localPrefix, suffix) : mapping.localPrefix;
|
|
3243
|
+
}
|
|
3244
|
+
}
|
|
3245
|
+
|
|
3246
|
+
if (typeof repoRelativePath === 'string' && repoRelativePath && pathMappings.length > 0) {
|
|
3247
|
+
return path.join(pathMappings[0].localPrefix, repoRelativePath);
|
|
3248
|
+
}
|
|
3249
|
+
|
|
3250
|
+
if (typeof deployedPath === 'string' && deployedPath && !isAbsolutePath(deployedPath) && pathMappings.length > 0) {
|
|
3251
|
+
return path.join(pathMappings[0].localPrefix, deployedPath);
|
|
3252
|
+
}
|
|
3253
|
+
|
|
3254
|
+
return null;
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3257
|
+
function findMatchingPathMapping(targetPath, pathMappings) {
|
|
3258
|
+
let bestMatch = null;
|
|
3259
|
+
for (const mapping of pathMappings) {
|
|
3260
|
+
if (
|
|
3261
|
+
targetPath === mapping.remotePrefix ||
|
|
3262
|
+
targetPath.startsWith(`${mapping.remotePrefix}/`) ||
|
|
3263
|
+
targetPath.startsWith(`${mapping.remotePrefix}${path.sep}`)
|
|
3264
|
+
) {
|
|
3265
|
+
if (!bestMatch || mapping.remotePrefix.length > bestMatch.remotePrefix.length) {
|
|
3266
|
+
bestMatch = mapping;
|
|
3267
|
+
}
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3270
|
+
return bestMatch;
|
|
3271
|
+
}
|
|
3272
|
+
|
|
3273
|
+
function trimLeadingSeparators(value) {
|
|
3274
|
+
return value.replace(/^[\\/]+/, '');
|
|
3275
|
+
}
|
|
3276
|
+
|
|
3277
|
+
function isAbsolutePath(value) {
|
|
3278
|
+
return path.isAbsolute(value);
|
|
3279
|
+
}
|
|
3280
|
+
|
|
3281
|
+
function rpcError(code, message, data) {
|
|
3282
|
+
const error = new Error(message);
|
|
3283
|
+
error.rpcCode = code;
|
|
3284
|
+
error.rpcData = data;
|
|
3285
|
+
return error;
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
function startStdioServer(server) {
|
|
3289
|
+
let buffer = Buffer.alloc(0);
|
|
3290
|
+
let draining = false;
|
|
3291
|
+
let transportMode = null;
|
|
3292
|
+
|
|
3293
|
+
process.stdin.on('end', () => {
|
|
3294
|
+
debugLog('stdin end');
|
|
3295
|
+
});
|
|
3296
|
+
|
|
3297
|
+
process.stdin.on('close', () => {
|
|
3298
|
+
debugLog('stdin close');
|
|
3299
|
+
});
|
|
3300
|
+
|
|
3301
|
+
process.stdin.on('data', (chunk) => {
|
|
3302
|
+
debugLog(
|
|
3303
|
+
`stdin data bytes=${chunk.length} text=${summarizeChunkForLog(chunk)}`,
|
|
3304
|
+
);
|
|
3305
|
+
buffer = Buffer.concat([buffer, Buffer.from(chunk)]);
|
|
3306
|
+
if (!draining) {
|
|
3307
|
+
draining = true;
|
|
3308
|
+
drain().finally(() => {
|
|
3309
|
+
draining = false;
|
|
3310
|
+
});
|
|
3311
|
+
}
|
|
3312
|
+
});
|
|
3313
|
+
|
|
3314
|
+
process.stdin.on('error', (error) => {
|
|
3315
|
+
logError('stdin error', error);
|
|
3316
|
+
});
|
|
3317
|
+
|
|
3318
|
+
async function drain() {
|
|
3319
|
+
while (true) {
|
|
3320
|
+
const parsed = parseFrame(buffer);
|
|
3321
|
+
if (!parsed) {
|
|
3322
|
+
return;
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
buffer = parsed.rest;
|
|
3326
|
+
if (!transportMode) {
|
|
3327
|
+
transportMode = parsed.transport;
|
|
3328
|
+
debugLog(`transport mode=${transportMode}`);
|
|
3329
|
+
}
|
|
3330
|
+
let message;
|
|
3331
|
+
try {
|
|
3332
|
+
message = JSON.parse(parsed.body);
|
|
3333
|
+
} catch (error) {
|
|
3334
|
+
logError('failed to parse JSON-RPC payload', error);
|
|
3335
|
+
continue;
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
try {
|
|
3339
|
+
const response = await server.handleMessage(message);
|
|
3340
|
+
if (response) {
|
|
3341
|
+
writeMessage(response, transportMode || 'framed');
|
|
3342
|
+
}
|
|
3343
|
+
} catch (error) {
|
|
3344
|
+
logError('failed to handle JSON-RPC message', error);
|
|
3345
|
+
if (message && Object.prototype.hasOwnProperty.call(message, 'id')) {
|
|
3346
|
+
writeMessage(formatRpcErrorResponse(message.id, error), transportMode || 'framed');
|
|
3347
|
+
}
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
3350
|
+
}
|
|
3351
|
+
}
|
|
3352
|
+
|
|
3353
|
+
function startHttpServer(mcpServer, config) {
|
|
3354
|
+
const httpServer = http.createServer(createHttpRequestHandler(mcpServer, config));
|
|
3355
|
+
httpServer.listen(config.httpPort, config.httpHost, () => {
|
|
3356
|
+
process.stderr.write(
|
|
3357
|
+
`[${SERVER_NAME}] HTTP MCP listening on http://${config.httpHost}:${config.httpPort}${config.httpPath}\n`,
|
|
3358
|
+
);
|
|
3359
|
+
});
|
|
3360
|
+
return httpServer;
|
|
3361
|
+
}
|
|
3362
|
+
|
|
3363
|
+
function createHttpRequestHandler(mcpServer, config) {
|
|
3364
|
+
return async (request, response) => {
|
|
3365
|
+
applyCorsHeaders(response, config);
|
|
3366
|
+
|
|
3367
|
+
if (request.method === 'OPTIONS') {
|
|
3368
|
+
response.writeHead(204);
|
|
3369
|
+
response.end();
|
|
3370
|
+
return;
|
|
3371
|
+
}
|
|
3372
|
+
|
|
3373
|
+
const requestUrl = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
|
|
3374
|
+
if (request.method === 'GET' && isHealthPath(requestUrl.pathname)) {
|
|
3375
|
+
writeJsonResponse(response, 200, {
|
|
3376
|
+
status: 'ok',
|
|
3377
|
+
service: SERVER_NAME,
|
|
3378
|
+
transport: 'http',
|
|
3379
|
+
});
|
|
3380
|
+
return;
|
|
3381
|
+
}
|
|
3382
|
+
|
|
3383
|
+
if (requestUrl.pathname !== config.httpPath) {
|
|
3384
|
+
writeJsonResponse(response, 404, {
|
|
3385
|
+
error: 'Not found',
|
|
3386
|
+
});
|
|
3387
|
+
return;
|
|
3388
|
+
}
|
|
3389
|
+
|
|
3390
|
+
if (request.method === 'GET') {
|
|
3391
|
+
startHttpEventStream(request, response, config);
|
|
3392
|
+
return;
|
|
3393
|
+
}
|
|
3394
|
+
|
|
3395
|
+
if (request.method !== 'POST') {
|
|
3396
|
+
response.setHeader('Allow', 'GET, POST, OPTIONS');
|
|
3397
|
+
writeJsonResponse(response, 405, {
|
|
3398
|
+
error: 'Method not allowed',
|
|
3399
|
+
});
|
|
3400
|
+
return;
|
|
3401
|
+
}
|
|
3402
|
+
|
|
3403
|
+
let payload;
|
|
3404
|
+
try {
|
|
3405
|
+
const body = await readHttpRequestBody(request, config.httpMaxBodyBytes);
|
|
3406
|
+
payload = body ? JSON.parse(body) : null;
|
|
3407
|
+
} catch (error) {
|
|
3408
|
+
const rpcResponse = formatRpcErrorResponse(null, rpcError(-32700, error instanceof Error ? error.message : 'Parse error'));
|
|
3409
|
+
writeJsonResponse(response, 400, rpcResponse);
|
|
3410
|
+
return;
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3413
|
+
try {
|
|
3414
|
+
const rpcResponse = await handleHttpRpcPayload(mcpServer, payload);
|
|
3415
|
+
if (rpcResponse == null) {
|
|
3416
|
+
response.writeHead(202);
|
|
3417
|
+
response.end();
|
|
3418
|
+
return;
|
|
3419
|
+
}
|
|
3420
|
+
writeJsonResponse(response, 200, rpcResponse, {
|
|
3421
|
+
'mcp-protocol-version': SUPPORTED_PROTOCOL_VERSION,
|
|
3422
|
+
});
|
|
3423
|
+
} catch (error) {
|
|
3424
|
+
const id = payload && typeof payload === 'object' && !Array.isArray(payload) && Object.prototype.hasOwnProperty.call(payload, 'id')
|
|
3425
|
+
? payload.id
|
|
3426
|
+
: null;
|
|
3427
|
+
const statusCode =
|
|
3428
|
+
error && typeof error === 'object' && typeof error.httpStatusCode === 'number'
|
|
3429
|
+
? error.httpStatusCode
|
|
3430
|
+
: 500;
|
|
3431
|
+
writeJsonResponse(response, statusCode, formatRpcErrorResponse(id, error), {
|
|
3432
|
+
'mcp-protocol-version': SUPPORTED_PROTOCOL_VERSION,
|
|
3433
|
+
});
|
|
3434
|
+
}
|
|
3435
|
+
};
|
|
3436
|
+
}
|
|
3437
|
+
|
|
3438
|
+
function applyCorsHeaders(response, config) {
|
|
3439
|
+
response.setHeader('Access-Control-Allow-Origin', config.corsOrigin || '*');
|
|
3440
|
+
response.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
3441
|
+
response.setHeader('Access-Control-Allow-Headers', 'content-type, accept, mcp-protocol-version, mcp-session-id, authorization');
|
|
3442
|
+
response.setHeader('Access-Control-Expose-Headers', 'mcp-protocol-version, mcp-session-id');
|
|
3443
|
+
}
|
|
3444
|
+
|
|
3445
|
+
function isHealthPath(pathname) {
|
|
3446
|
+
return pathname === '/health' || pathname === '/healthz' || pathname === '/';
|
|
3447
|
+
}
|
|
3448
|
+
|
|
3449
|
+
function startHttpEventStream(request, response, config) {
|
|
3450
|
+
response.writeHead(200, {
|
|
3451
|
+
'content-type': 'text/event-stream',
|
|
3452
|
+
'cache-control': 'no-cache, no-transform',
|
|
3453
|
+
connection: 'keep-alive',
|
|
3454
|
+
'mcp-protocol-version': SUPPORTED_PROTOCOL_VERSION,
|
|
3455
|
+
});
|
|
3456
|
+
response.write(`: ${SERVER_NAME} ready\n\n`);
|
|
3457
|
+
const interval = setInterval(() => {
|
|
3458
|
+
response.write(': ping\n\n');
|
|
3459
|
+
}, positiveIntegerOrFallback(config.httpKeepAliveMs, 30000));
|
|
3460
|
+
request.on('close', () => {
|
|
3461
|
+
clearInterval(interval);
|
|
3462
|
+
});
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3465
|
+
async function handleHttpRpcPayload(mcpServer, payload) {
|
|
3466
|
+
if (Array.isArray(payload)) {
|
|
3467
|
+
const responses = [];
|
|
3468
|
+
for (const message of payload) {
|
|
3469
|
+
const response = await mcpServer.handleMessage(message);
|
|
3470
|
+
if (response) {
|
|
3471
|
+
responses.push(response);
|
|
3472
|
+
}
|
|
3473
|
+
}
|
|
3474
|
+
return responses.length ? responses : null;
|
|
3475
|
+
}
|
|
3476
|
+
return mcpServer.handleMessage(payload);
|
|
3477
|
+
}
|
|
3478
|
+
|
|
3479
|
+
function readHttpRequestBody(request, maxBytes) {
|
|
3480
|
+
return new Promise((resolve, reject) => {
|
|
3481
|
+
const chunks = [];
|
|
3482
|
+
let totalBytes = 0;
|
|
3483
|
+
request.on('data', (chunk) => {
|
|
3484
|
+
totalBytes += chunk.length;
|
|
3485
|
+
if (totalBytes > maxBytes) {
|
|
3486
|
+
reject(new Error(`HTTP request body exceeds ${maxBytes} bytes`));
|
|
3487
|
+
request.destroy();
|
|
3488
|
+
return;
|
|
3489
|
+
}
|
|
3490
|
+
chunks.push(Buffer.from(chunk));
|
|
3491
|
+
});
|
|
3492
|
+
request.on('end', () => {
|
|
3493
|
+
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
3494
|
+
});
|
|
3495
|
+
request.on('error', reject);
|
|
3496
|
+
});
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3499
|
+
function writeJsonResponse(response, statusCode, payload, headers = {}) {
|
|
3500
|
+
const body = JSON.stringify(payload);
|
|
3501
|
+
response.writeHead(statusCode, {
|
|
3502
|
+
'content-type': 'application/json',
|
|
3503
|
+
'content-length': Buffer.byteLength(body),
|
|
3504
|
+
...headers,
|
|
3505
|
+
});
|
|
3506
|
+
response.end(body);
|
|
3507
|
+
}
|
|
3508
|
+
|
|
3509
|
+
function parseFrame(buffer) {
|
|
3510
|
+
const headerBoundary = findHeaderBoundary(buffer);
|
|
3511
|
+
if (headerBoundary) {
|
|
3512
|
+
const headerText = buffer.subarray(0, headerBoundary.index).toString('utf8');
|
|
3513
|
+
const headers = parseHeaders(headerText);
|
|
3514
|
+
const contentLengthHeader = headers['content-length'];
|
|
3515
|
+
if (!contentLengthHeader) {
|
|
3516
|
+
throw new Error('Missing Content-Length header');
|
|
3517
|
+
}
|
|
3518
|
+
|
|
3519
|
+
const contentLength = Number.parseInt(contentLengthHeader, 10);
|
|
3520
|
+
if (!Number.isFinite(contentLength) || contentLength < 0) {
|
|
3521
|
+
throw new Error(`Invalid Content-Length header: ${contentLengthHeader}`);
|
|
3522
|
+
}
|
|
3523
|
+
|
|
3524
|
+
const bodyStart = headerBoundary.bodyStart;
|
|
3525
|
+
const bodyEnd = bodyStart + contentLength;
|
|
3526
|
+
if (buffer.length < bodyEnd) {
|
|
3527
|
+
return null;
|
|
3528
|
+
}
|
|
3529
|
+
|
|
3530
|
+
return {
|
|
3531
|
+
body: buffer.subarray(bodyStart, bodyEnd).toString('utf8'),
|
|
3532
|
+
rest: buffer.subarray(bodyEnd),
|
|
3533
|
+
transport: 'framed',
|
|
3534
|
+
};
|
|
3535
|
+
}
|
|
3536
|
+
|
|
3537
|
+
const lineMessage = parseLineDelimitedMessage(buffer);
|
|
3538
|
+
if (lineMessage) {
|
|
3539
|
+
return lineMessage;
|
|
3540
|
+
}
|
|
3541
|
+
|
|
3542
|
+
return null;
|
|
3543
|
+
}
|
|
3544
|
+
|
|
3545
|
+
function findHeaderBoundary(buffer) {
|
|
3546
|
+
const crlfIndex = buffer.indexOf('\r\n\r\n');
|
|
3547
|
+
if (crlfIndex !== -1) {
|
|
3548
|
+
return {
|
|
3549
|
+
index: crlfIndex,
|
|
3550
|
+
bodyStart: crlfIndex + 4,
|
|
3551
|
+
};
|
|
3552
|
+
}
|
|
3553
|
+
|
|
3554
|
+
const lfIndex = buffer.indexOf('\n\n');
|
|
3555
|
+
if (lfIndex !== -1) {
|
|
3556
|
+
return {
|
|
3557
|
+
index: lfIndex,
|
|
3558
|
+
bodyStart: lfIndex + 2,
|
|
3559
|
+
};
|
|
3560
|
+
}
|
|
3561
|
+
|
|
3562
|
+
return null;
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
function parseHeaders(headerText) {
|
|
3566
|
+
const headers = {};
|
|
3567
|
+
for (const line of headerText.split(/\r?\n/)) {
|
|
3568
|
+
if (!line) {
|
|
3569
|
+
continue;
|
|
3570
|
+
}
|
|
3571
|
+
const separatorIndex = line.indexOf(':');
|
|
3572
|
+
if (separatorIndex === -1) {
|
|
3573
|
+
continue;
|
|
3574
|
+
}
|
|
3575
|
+
const key = line.slice(0, separatorIndex).trim().toLowerCase();
|
|
3576
|
+
const value = line.slice(separatorIndex + 1).trim();
|
|
3577
|
+
headers[key] = value;
|
|
3578
|
+
}
|
|
3579
|
+
return headers;
|
|
3580
|
+
}
|
|
3581
|
+
|
|
3582
|
+
function parseLineDelimitedMessage(buffer) {
|
|
3583
|
+
let start = 0;
|
|
3584
|
+
while (start < buffer.length && isWhitespaceByte(buffer[start])) {
|
|
3585
|
+
start += 1;
|
|
3586
|
+
}
|
|
3587
|
+
|
|
3588
|
+
if (start >= buffer.length) {
|
|
3589
|
+
return null;
|
|
3590
|
+
}
|
|
3591
|
+
|
|
3592
|
+
const firstByte = buffer[start];
|
|
3593
|
+
if (firstByte !== 0x7b && firstByte !== 0x5b) {
|
|
3594
|
+
return null;
|
|
3595
|
+
}
|
|
3596
|
+
|
|
3597
|
+
const newlineIndex = buffer.indexOf('\n', start);
|
|
3598
|
+
if (newlineIndex === -1) {
|
|
3599
|
+
return null;
|
|
3600
|
+
}
|
|
3601
|
+
|
|
3602
|
+
let bodyEnd = newlineIndex;
|
|
3603
|
+
if (bodyEnd > start && buffer[bodyEnd - 1] === 0x0d) {
|
|
3604
|
+
bodyEnd -= 1;
|
|
3605
|
+
}
|
|
3606
|
+
|
|
3607
|
+
return {
|
|
3608
|
+
body: buffer.subarray(start, bodyEnd).toString('utf8'),
|
|
3609
|
+
rest: buffer.subarray(newlineIndex + 1),
|
|
3610
|
+
transport: 'line',
|
|
3611
|
+
};
|
|
3612
|
+
}
|
|
3613
|
+
|
|
3614
|
+
function isWhitespaceByte(byte) {
|
|
3615
|
+
return byte === 0x20 || byte === 0x09 || byte === 0x0a || byte === 0x0d;
|
|
3616
|
+
}
|
|
3617
|
+
|
|
3618
|
+
function writeMessage(message, transportMode = 'framed') {
|
|
3619
|
+
const body = JSON.stringify(message);
|
|
3620
|
+
debugLog(
|
|
3621
|
+
`send transport=${transportMode} id=${Object.prototype.hasOwnProperty.call(message, 'id') ? String(message.id) : '-'} hasResult=${Object.prototype.hasOwnProperty.call(message, 'result')} hasError=${Object.prototype.hasOwnProperty.call(message, 'error')}`,
|
|
3622
|
+
);
|
|
3623
|
+
if (transportMode === 'line') {
|
|
3624
|
+
process.stdout.write(`${body}\n`);
|
|
3625
|
+
return;
|
|
3626
|
+
}
|
|
3627
|
+
|
|
3628
|
+
const payload = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`;
|
|
3629
|
+
process.stdout.write(payload);
|
|
3630
|
+
}
|
|
3631
|
+
|
|
3632
|
+
function formatRpcErrorResponse(id, error) {
|
|
3633
|
+
return {
|
|
3634
|
+
jsonrpc: JSON_RPC_VERSION,
|
|
3635
|
+
id,
|
|
3636
|
+
error: {
|
|
3637
|
+
code: typeof error?.rpcCode === 'number' ? error.rpcCode : -32603,
|
|
3638
|
+
message: error instanceof Error ? error.message : 'Internal error',
|
|
3639
|
+
data: error && typeof error === 'object' && 'rpcData' in error ? error.rpcData : undefined,
|
|
3640
|
+
},
|
|
3641
|
+
};
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
function logError(message, error) {
|
|
3645
|
+
const suffix = error instanceof Error ? error.stack || error.message : String(error);
|
|
3646
|
+
debugLog(`error ${message}: ${suffix}`);
|
|
3647
|
+
process.stderr.write(`[${SERVER_NAME}] ${message}: ${suffix}\n`);
|
|
3648
|
+
}
|
|
3649
|
+
|
|
3650
|
+
async function main() {
|
|
3651
|
+
const config = loadConfig();
|
|
3652
|
+
debugLog(`startup pid=${process.pid} cwd=${process.cwd()} baseUrl=${config.baseUrl} transport=${config.transport}`);
|
|
3653
|
+
const server = createMcpServer(config);
|
|
3654
|
+
if (config.autoAuth && !config.apiToken && config.apiBaseUrl && config.authMode === 'wait') {
|
|
3655
|
+
server.primeAuth().catch((error) => {
|
|
3656
|
+
debugLog(`background auth bootstrap failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
3657
|
+
});
|
|
3658
|
+
}
|
|
3659
|
+
if (config.transport === 'http') {
|
|
3660
|
+
startHttpServer(server, config);
|
|
3661
|
+
return;
|
|
3662
|
+
}
|
|
3663
|
+
process.stderr.write(`[${SERVER_NAME}] running on stdio\n`);
|
|
3664
|
+
startStdioServer(server);
|
|
3665
|
+
}
|
|
3666
|
+
|
|
3667
|
+
function debugLog(message) {
|
|
3668
|
+
if (!DEBUG_LOG_PATH) {
|
|
3669
|
+
return;
|
|
3670
|
+
}
|
|
3671
|
+
|
|
3672
|
+
const line = `${new Date().toISOString()} ${message}\n`;
|
|
3673
|
+
try {
|
|
3674
|
+
fs.appendFileSync(DEBUG_LOG_PATH, line, 'utf8');
|
|
3675
|
+
} catch {
|
|
3676
|
+
// Swallow log write failures so stdio behavior is unaffected.
|
|
3677
|
+
}
|
|
3678
|
+
}
|
|
3679
|
+
|
|
3680
|
+
function summarizeForLog(value) {
|
|
3681
|
+
if (value == null) {
|
|
3682
|
+
return '-';
|
|
3683
|
+
}
|
|
3684
|
+
|
|
3685
|
+
try {
|
|
3686
|
+
const text = JSON.stringify(value);
|
|
3687
|
+
return text.length > 500 ? `${text.slice(0, 500)}...` : text;
|
|
3688
|
+
} catch {
|
|
3689
|
+
return '[unserializable]';
|
|
3690
|
+
}
|
|
3691
|
+
}
|
|
3692
|
+
|
|
3693
|
+
function summarizeChunkForLog(chunk) {
|
|
3694
|
+
const text = Buffer.from(chunk).toString('utf8');
|
|
3695
|
+
return text.length > 500 ? `${JSON.stringify(text.slice(0, 500))}...` : JSON.stringify(text);
|
|
3696
|
+
}
|
|
3697
|
+
|
|
3698
|
+
if (require.main === module) {
|
|
3699
|
+
main().catch((error) => {
|
|
3700
|
+
logError('fatal startup error', error);
|
|
3701
|
+
process.exit(1);
|
|
3702
|
+
});
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3705
|
+
module.exports = {
|
|
3706
|
+
createMcpServer,
|
|
3707
|
+
loadConfig,
|
|
3708
|
+
normalizeBaseUrl,
|
|
3709
|
+
normalizeOptionalBaseUrl,
|
|
3710
|
+
parsePathMappings,
|
|
3711
|
+
createReproAiClient,
|
|
3712
|
+
createReproAuthClient,
|
|
3713
|
+
createHttpRequestHandler,
|
|
3714
|
+
startHttpServer,
|
|
3715
|
+
normalizeToolCatalog,
|
|
3716
|
+
prepareToolInput,
|
|
3717
|
+
augmentPaths,
|
|
3718
|
+
resolveLocalPath,
|
|
3719
|
+
deriveRepoRelativePath,
|
|
3720
|
+
handleRpcMessage,
|
|
3721
|
+
parseFrame,
|
|
3722
|
+
publicToolDefinition,
|
|
3723
|
+
formatRpcErrorResponse,
|
|
3724
|
+
readStoredAuth,
|
|
3725
|
+
storeAuth,
|
|
3726
|
+
clearPersistedAuth,
|
|
3727
|
+
resolveAuthFilePath,
|
|
3728
|
+
};
|