@reproapp/mcp 0.0.5 → 0.0.6

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.
Files changed (3) hide show
  1. package/README.md +16 -0
  2. package/package.json +1 -1
  3. package/src/index.js +340 -10
package/README.md CHANGED
@@ -39,6 +39,18 @@ Custom Repro deployment:
39
39
 
40
40
  The installed binary is `repro-mcp`.
41
41
 
42
+ ## Agent Rules
43
+
44
+ When a user asks about Repro sessions, requests, traces, privacy policies, or app configuration, the AI client must use the Repro MCP tools only. It must not write local scripts, call Repro HTTP APIs directly, read cached Repro auth files, or patch policy JSON outside the MCP tool surface.
45
+
46
+ Privacy policy updates are intentionally handle-based:
47
+
48
+ - Use `inspect_privacy_policy` or `list_privacy_policy_blockers` first.
49
+ - For one policy statement, pass the exact `statementSid` to `update_privacy_policy`.
50
+ - For all currently listed blockers, pass the returned `blockerSetId` to `update_privacy_policy`.
51
+ - Broad natural-language updates such as "make all blockers keep exact" are read-only/dry-run unless they include a `blockerSetId` or exact statement ids.
52
+ - `KEEP_EXACT` on sensitive or raw-text review statements requires `force=true` and a human reason.
53
+
42
54
  ## Configuration
43
55
 
44
56
  Built-in defaults:
@@ -181,3 +193,7 @@ npm run start:stdio
181
193
  - `get_trace`
182
194
  - `get_action_path`
183
195
  - `get_function_candidates`
196
+ - `inspect_privacy_policy`
197
+ - `list_privacy_policy_blockers`
198
+ - `update_privacy_policy`
199
+ - `approve_privacy_policy`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reproapp/mcp",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "description": "Repro MCP server for AI-assisted debugging and privacy policy workflows",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
package/src/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  'use strict';
3
3
 
4
4
  const childProcess = require('node:child_process');
5
+ const crypto = require('node:crypto');
5
6
  const fs = require('node:fs');
6
7
  const http = require('node:http');
7
8
  const os = require('node:os');
@@ -11,6 +12,11 @@ const SERVER_NAME = 'repro-mcp';
11
12
  const SERVER_VERSION = '0.0.1';
12
13
  const SUPPORTED_PROTOCOL_VERSION = '2024-11-05';
13
14
  const JSON_RPC_VERSION = '2.0';
15
+ const SERVER_INSTRUCTIONS = [
16
+ 'For Repro-scoped questions about sessions, requests, traces, privacy policies, apps, or configuration, use the Repro MCP tools only.',
17
+ 'Do not create local scripts, read cached Repro auth files, or call Repro HTTP APIs directly for Repro data or policy workflows.',
18
+ 'For privacy policy writes, inspect first and then use exact statementSid(s) or blockerSetId. Broad natural-language policy update queries are read-only.',
19
+ ].join(' ');
14
20
  const DEFAULT_REPRO_AI_BASE_URL = 'https://repro-ai-u8lhw.ondigitalocean.app';
15
21
  const DEFAULT_REPRO_API_BASE_URL = 'https://repro-api-d7288.ondigitalocean.app/api';
16
22
  const DEFAULT_REPRO_WEB_BASE_URL = 'https://getrepro.io';
@@ -18,6 +24,8 @@ loadEnvironmentFiles(process.env);
18
24
  const DEBUG_LOG_PATH = process.env.REPRO_MCP_DEBUG_LOG;
19
25
  const DEFAULT_AUTH_TIMEOUT_MS = 2 * 60 * 1000;
20
26
  const DEFAULT_AUTH_POLL_INTERVAL_MS = 1500;
27
+ const PRIVACY_BLOCKER_SET_TTL_MS = 15 * 60 * 1000;
28
+ const privacyBlockerSets = new Map();
21
29
  const LOCAL_TOOL_DEFINITIONS = [
22
30
  {
23
31
  name: 'resolve_scope',
@@ -82,10 +90,28 @@ const LOCAL_TOOL_DEFINITIONS = [
82
90
  additionalProperties: true,
83
91
  },
84
92
  },
93
+ {
94
+ name: 'list_privacy_policy_blockers',
95
+ description:
96
+ 'Read-only Repro privacy review workflow tool. Lists unresolved privacy policy blockers for an app/environment and returns a blockerSetId that must be used for safe bulk updates. Use this instead of direct API calls, scripts, or broad update queries when a user asks about Repro policy blockers.',
97
+ inputSchema: {
98
+ type: 'object',
99
+ properties: {
100
+ tenantId: { type: 'string' },
101
+ appId: { type: 'string' },
102
+ environment: { type: 'string' },
103
+ state: { type: 'string', enum: ['active', 'pending', 'approved'] },
104
+ query: { type: 'string' },
105
+ includeTechnical: { type: 'boolean' },
106
+ openUi: { type: 'boolean' },
107
+ },
108
+ additionalProperties: true,
109
+ },
110
+ },
85
111
  {
86
112
  name: 'update_privacy_policy',
87
113
  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.',
114
+ 'Safely stage/apply Repro privacy policy decisions. Apply/approve requires exact statementSid(s) or a blockerSetId returned by inspect_privacy_policy/list_privacy_policy_blockers; broad natural-language queries are dry-run only. KEEP_EXACT on sensitive-looking statements requires force=true plus a human reason. Never use direct Repro API scripts for policy changes.',
89
115
  inputSchema: {
90
116
  type: 'object',
91
117
  properties: {
@@ -103,6 +129,7 @@ const LOCAL_TOOL_DEFINITIONS = [
103
129
  type: 'array',
104
130
  items: { type: 'string' },
105
131
  },
132
+ blockerSetId: { type: 'string' },
106
133
  decision: {
107
134
  type: 'string',
108
135
  enum: ['SAFE_PARTIAL_MASK', 'KEEP_EXACT', 'DROP', 'REGEX_ASSISTED'],
@@ -123,6 +150,9 @@ const LOCAL_TOOL_DEFINITIONS = [
123
150
  apply: { type: 'boolean' },
124
151
  approve: { type: 'boolean' },
125
152
  allowMultiple: { type: 'boolean' },
153
+ force: { type: 'boolean' },
154
+ reason: { type: 'string' },
155
+ overrideReason: { type: 'string' },
126
156
  openUi: { type: 'boolean' },
127
157
  },
128
158
  required: ['decision'],
@@ -1072,6 +1102,7 @@ async function handleRpcMessage(message, config, client, getTools) {
1072
1102
  name: SERVER_NAME,
1073
1103
  version: SERVER_VERSION,
1074
1104
  },
1105
+ instructions: SERVER_INSTRUCTIONS,
1075
1106
  },
1076
1107
  };
1077
1108
  }
@@ -1183,7 +1214,7 @@ async function executeTool(params, config, client, getTools) {
1183
1214
  }
1184
1215
 
1185
1216
  function formatLocalToolContentText(name, value) {
1186
- if (name === 'inspect_privacy_policy') {
1217
+ if (name === 'inspect_privacy_policy' || name === 'list_privacy_policy_blockers') {
1187
1218
  const text = renderPolicyInspectionText(value);
1188
1219
  if (text) return text;
1189
1220
  }
@@ -1221,6 +1252,17 @@ async function executeLocalTool(name, input, config, client) {
1221
1252
  if (name === 'inspect_privacy_policy') {
1222
1253
  return inspectPrivacyPolicy(input, config, client);
1223
1254
  }
1255
+ if (name === 'list_privacy_policy_blockers') {
1256
+ return inspectPrivacyPolicy(
1257
+ {
1258
+ ...input,
1259
+ query: optionalNonEmptyString(input.query) || 'all privacy policy review blockers',
1260
+ includeTechnical: true,
1261
+ },
1262
+ config,
1263
+ client,
1264
+ );
1265
+ }
1224
1266
  if (name === 'update_privacy_policy') {
1225
1267
  return updatePrivacyPolicy(input, config, client);
1226
1268
  }
@@ -1662,6 +1704,10 @@ function renderPolicyInspectionText(value) {
1662
1704
  lines.push(`Summary: ${value.summary}`);
1663
1705
  }
1664
1706
  lines.push(`Privacy Studio: ${value.ui?.url ?? 'unavailable'}`);
1707
+ if (value.blockerSet?.id) {
1708
+ lines.push(`Blocker set: ${value.blockerSet.id} (${value.blockerSet.count} statement(s), expires ${value.blockerSet.expiresAt})`);
1709
+ lines.push('Use blockerSetId with update_privacy_policy for safe bulk changes.');
1710
+ }
1665
1711
  const blockers = Array.isArray(value.policyBlockers) ? value.policyBlockers : [];
1666
1712
  if (blockers.length > 0) {
1667
1713
  lines.push('', `Policy Blockers (${blockers.length})`);
@@ -1733,6 +1779,7 @@ function renderPolicyBlockerTextLines(blocker) {
1733
1779
  ` choices: ${choices}`,
1734
1780
  ` surfaces: ${surfaces}`,
1735
1781
  ` reason: ${blocker?.reason ?? blocker?.reviewReason ?? 'n/a'}`,
1782
+ ...(blocker?.technical?.statementSid ? [` statementSid: ${blocker.technical.statementSid}`] : []),
1736
1783
  ` UI: ${blocker?.ui?.url ?? 'unavailable'}`,
1737
1784
  ];
1738
1785
  }
@@ -1782,13 +1829,26 @@ async function inspectPrivacyPolicy(input, config, client) {
1782
1829
  const contradictions = findSameLeafPolicyContradictions(statements, matchedLeaves);
1783
1830
  const uiQuery = query || contradictions[0]?.leaf || '';
1784
1831
  const ui = buildPrivacyStudioUi(config, appId, selection.environmentId, uiQuery, input.openUi === true);
1832
+ const blockerStatements = blockerQuery
1833
+ ? matches.map((match) => match.statement).filter(isReviewBlockerStatement)
1834
+ : matches.map((match) => match.statement).filter(isReviewBlockerStatement);
1785
1835
  const policyBlockers = buildPolicyBlockerSummaries(
1786
- blockerQuery ? matches.map((match) => match.statement) : matches.map((match) => match.statement).filter(isReviewBlockerStatement),
1836
+ blockerStatements,
1787
1837
  config,
1788
1838
  appId,
1789
1839
  selection.environmentId,
1790
1840
  { includeTechnical },
1791
1841
  );
1842
+ const blockerSet = policyBlockers.length
1843
+ ? rememberPrivacyBlockerSet({
1844
+ tenantId: optionalNonEmptyString(input.tenantId) || config.defaultTenantId || null,
1845
+ appId,
1846
+ environment: selection.environmentId,
1847
+ state: selection.state,
1848
+ query,
1849
+ statements: blockerStatements,
1850
+ })
1851
+ : null;
1792
1852
  if (ui.openRequested) {
1793
1853
  try {
1794
1854
  await openUrl(ui.url);
@@ -1814,8 +1874,10 @@ async function inspectPrivacyPolicy(input, config, client) {
1814
1874
  policyBlockers,
1815
1875
  contradictions,
1816
1876
  ui,
1877
+ blockerSet,
1817
1878
  }),
1818
1879
  policyBlockers,
1880
+ blockerSet,
1819
1881
  matches: matches.map((match) => formatPolicyMatch(match.statement, match.score, { includeTechnical })),
1820
1882
  contradictions,
1821
1883
  rawTextHints: summarizeRawTextHints(selection.policy, query),
@@ -1854,7 +1916,30 @@ async function updatePrivacyPolicy(input, config, client) {
1854
1916
 
1855
1917
  const query = policyInspectionQuery(input);
1856
1918
  const statements = flattenPrivacyStatements(selection.policy);
1857
- const selectedStatements = selectStatementsForPolicyUpdate(statements, input, query);
1919
+ const blockerSetSelection = resolvePrivacyBlockerSetSelection(input, {
1920
+ tenantId: optionalNonEmptyString(input.tenantId) || config.defaultTenantId || null,
1921
+ appId,
1922
+ environment: selection.environmentId,
1923
+ state: selection.state,
1924
+ });
1925
+ if (blockerSetSelection?.error) {
1926
+ return {
1927
+ status: 'blocked',
1928
+ code: blockerSetSelection.error.code,
1929
+ appId,
1930
+ environment: selection.environmentId,
1931
+ state: selection.state,
1932
+ summary: blockerSetSelection.error.message,
1933
+ ui: buildPrivacyStudioUi(config, appId, selection.environmentId, query, input.openUi === true),
1934
+ nextStep: 'Run inspect_privacy_policy or list_privacy_policy_blockers again, then pass the returned blockerSetId.',
1935
+ };
1936
+ }
1937
+ const selectedStatements = selectStatementsForPolicyUpdate(
1938
+ statements,
1939
+ input,
1940
+ query,
1941
+ blockerSetSelection?.statementSids,
1942
+ );
1858
1943
  const explicitSelection = updateInputHasExplicitStatementSelection(input);
1859
1944
  if (!selectedStatements.length) {
1860
1945
  return {
@@ -1867,16 +1952,39 @@ async function updatePrivacyPolicy(input, config, client) {
1867
1952
  matches: rankPolicyStatementMatches(statements, input, query).slice(0, 8).map((match) => formatPolicyMatch(match.statement, match.score, { includeTechnical: true })),
1868
1953
  };
1869
1954
  }
1870
- if (apply && !explicitSelection && selectedStatements.length !== 1 && input.allowMultiple !== true) {
1955
+ if (blockerSetSelection && selectedStatements.length !== blockerSetSelection.statementSids.length) {
1871
1956
  return {
1872
- status: 'needs_confirmation',
1957
+ status: 'blocked',
1958
+ code: 'STALE_BLOCKER_SET',
1873
1959
  appId,
1874
1960
  environment: selection.environmentId,
1875
1961
  state: selection.state,
1876
- summary: `The query matched ${selectedStatements.length} statements. Pass statementSids or allowMultiple=true before applying.`,
1962
+ summary: `The blockerSetId references ${blockerSetSelection.statementSids.length} statement(s), but only ${selectedStatements.length} still exist in the current policy.`,
1877
1963
  ui: buildPrivacyStudioUi(config, appId, selection.environmentId, query, input.openUi === true),
1878
1964
  matches: selectedStatements.map((statement) => formatPolicyMatch(statement, 0, { includeTechnical: true })),
1879
- proposed: buildPolicyUpdateDiff(selection.policy, selectedStatements, decision, input.rawTextHints),
1965
+ nextStep: 'Run inspect_privacy_policy or list_privacy_policy_blockers again, then retry with the new blockerSetId.',
1966
+ };
1967
+ }
1968
+ const safety = validatePrivacyPolicyUpdateSafety({
1969
+ input,
1970
+ selectedStatements,
1971
+ decision,
1972
+ explicitSelection,
1973
+ apply,
1974
+ });
1975
+ if (safety.blocked) {
1976
+ return {
1977
+ status: 'blocked',
1978
+ code: safety.code,
1979
+ appId,
1980
+ environment: selection.environmentId,
1981
+ state: selection.state,
1982
+ summary: safety.summary,
1983
+ ui: buildPrivacyStudioUi(config, appId, selection.environmentId, query, input.openUi === true),
1984
+ matches: selectedStatements.map((statement) => formatPolicyMatch(statement, 0, { includeTechnical: true })),
1985
+ proposed: safeBuildPolicyUpdateDiff(selection.policy, selectedStatements, decision, input.rawTextHints),
1986
+ safety,
1987
+ nextStep: safety.nextStep,
1880
1988
  };
1881
1989
  }
1882
1990
 
@@ -1908,6 +2016,8 @@ async function updatePrivacyPolicy(input, config, client) {
1908
2016
  ui,
1909
2017
  matches: selectedStatements.map((statement) => formatPolicyMatch(statement, 0, { includeTechnical: true })),
1910
2018
  diff: mutation.diff,
2019
+ blockerSetId: blockerSetSelection?.id,
2020
+ safety,
1911
2021
  nextStep: approve
1912
2022
  ? 'Run again with apply=true to stage the update and approve=true to approve it after API validation.'
1913
2023
  : 'Run again with apply=true to stage the pending policy update.',
@@ -1962,6 +2072,8 @@ async function updatePrivacyPolicy(input, config, client) {
1962
2072
  : `Staged pending privacy policy update for ${selectedStatements.length} statement(s).`,
1963
2073
  ui,
1964
2074
  diff: mutation.diff,
2075
+ blockerSetId: blockerSetSelection?.id,
2076
+ safety,
1965
2077
  api: {
1966
2078
  staged: true,
1967
2079
  approved: approve,
@@ -2072,12 +2184,21 @@ function normalizePolicyUpdateDecision(value) {
2072
2184
  function updateInputHasExplicitStatementSelection(input) {
2073
2185
  return Boolean(
2074
2186
  optionalNonEmptyString(input.statementSid) ||
2187
+ optionalNonEmptyString(input.blockerSetId) ||
2075
2188
  (Array.isArray(input.statementSids) && input.statementSids.some((sid) => optionalNonEmptyString(sid))),
2076
2189
  );
2077
2190
  }
2078
2191
 
2079
- function selectStatementsForPolicyUpdate(statements, input, query) {
2192
+ function selectStatementsForPolicyUpdate(statements, input, query, selectionSids) {
2080
2193
  const explicitSids = new Set();
2194
+ if (Array.isArray(selectionSids)) {
2195
+ for (const sid of selectionSids) {
2196
+ const normalized = optionalNonEmptyString(sid);
2197
+ if (normalized) {
2198
+ explicitSids.add(normalized);
2199
+ }
2200
+ }
2201
+ }
2081
2202
  const single = optionalNonEmptyString(input.statementSid);
2082
2203
  if (single) {
2083
2204
  explicitSids.add(single);
@@ -2099,10 +2220,215 @@ function selectStatementsForPolicyUpdate(statements, input, query) {
2099
2220
  .map((match) => match.statement);
2100
2221
  }
2101
2222
 
2223
+ function cleanupExpiredPrivacyBlockerSets(now = Date.now()) {
2224
+ for (const [id, record] of privacyBlockerSets.entries()) {
2225
+ if (!record?.expiresAt || record.expiresAt <= now) {
2226
+ privacyBlockerSets.delete(id);
2227
+ }
2228
+ }
2229
+ }
2230
+
2231
+ function rememberPrivacyBlockerSet({ tenantId, appId, environment, state, query, statements }) {
2232
+ cleanupExpiredPrivacyBlockerSets();
2233
+ const statementSids = mergeUniqueStrings(
2234
+ (statements ?? []).map((statement) => String(statement?.sid ?? '')).filter(Boolean),
2235
+ );
2236
+ const createdAt = Date.now();
2237
+ const payload = {
2238
+ v: 1,
2239
+ tenantId: tenantId || null,
2240
+ appId,
2241
+ environment,
2242
+ state,
2243
+ queryHash: crypto.createHash('sha256').update(query || '').digest('hex').slice(0, 12),
2244
+ statementSids,
2245
+ createdAt,
2246
+ expiresAt: createdAt + PRIVACY_BLOCKER_SET_TTL_MS,
2247
+ };
2248
+ const encodedPayload = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
2249
+ const checksum = crypto.createHash('sha256').update(encodedPayload).digest('hex').slice(0, 16);
2250
+ const id = `PBS_${encodedPayload}.${checksum}`;
2251
+ const record = {
2252
+ id,
2253
+ tenantId: payload.tenantId,
2254
+ appId: payload.appId,
2255
+ environment: payload.environment,
2256
+ state: payload.state,
2257
+ query: query || null,
2258
+ statementSids,
2259
+ createdAt,
2260
+ expiresAt: payload.expiresAt,
2261
+ };
2262
+ privacyBlockerSets.set(id, record);
2263
+ return {
2264
+ id,
2265
+ count: statementSids.length,
2266
+ statementSids,
2267
+ expiresAt: new Date(record.expiresAt).toISOString(),
2268
+ expiresInMs: PRIVACY_BLOCKER_SET_TTL_MS,
2269
+ updateContract:
2270
+ 'Pass this blockerSetId to update_privacy_policy. Repro MCP will apply only this exact set; broad query writes are rejected.',
2271
+ };
2272
+ }
2273
+
2274
+ function resolvePrivacyBlockerSetSelection(input, expected) {
2275
+ const id = optionalNonEmptyString(input.blockerSetId);
2276
+ if (!id) {
2277
+ return null;
2278
+ }
2279
+ cleanupExpiredPrivacyBlockerSets();
2280
+ const record = decodePrivacyBlockerSetId(id) || privacyBlockerSets.get(id);
2281
+ if (!record) {
2282
+ return {
2283
+ error: {
2284
+ code: 'UNKNOWN_BLOCKER_SET',
2285
+ message: 'Unknown, expired, or malformed blockerSetId.',
2286
+ },
2287
+ };
2288
+ }
2289
+ if (record.expiresAt <= Date.now()) {
2290
+ return {
2291
+ error: {
2292
+ code: 'EXPIRED_BLOCKER_SET',
2293
+ message: 'The blockerSetId has expired.',
2294
+ },
2295
+ };
2296
+ }
2297
+ const mismatches = [];
2298
+ if (record.appId !== expected.appId) mismatches.push(`appId ${record.appId}`);
2299
+ if (record.environment !== expected.environment) mismatches.push(`environment ${record.environment}`);
2300
+ if (record.state !== expected.state) mismatches.push(`state ${record.state}`);
2301
+ if (record.tenantId && expected.tenantId && record.tenantId !== expected.tenantId) {
2302
+ mismatches.push(`tenantId ${record.tenantId}`);
2303
+ }
2304
+ if (mismatches.length) {
2305
+ return {
2306
+ error: {
2307
+ code: 'BLOCKER_SET_SCOPE_MISMATCH',
2308
+ message: `blockerSetId ${id} belongs to a different policy scope: ${mismatches.join(', ')}.`,
2309
+ },
2310
+ };
2311
+ }
2312
+ return {
2313
+ id,
2314
+ statementSids: record.statementSids,
2315
+ record,
2316
+ };
2317
+ }
2318
+
2319
+ function decodePrivacyBlockerSetId(id) {
2320
+ if (!id.startsWith('PBS_')) {
2321
+ return null;
2322
+ }
2323
+ const encoded = id.slice(4);
2324
+ const separator = encoded.lastIndexOf('.');
2325
+ if (separator <= 0) {
2326
+ return null;
2327
+ }
2328
+ const encodedPayload = encoded.slice(0, separator);
2329
+ const checksum = encoded.slice(separator + 1);
2330
+ const expectedChecksum = crypto.createHash('sha256').update(encodedPayload).digest('hex').slice(0, 16);
2331
+ if (checksum !== expectedChecksum) {
2332
+ return null;
2333
+ }
2334
+ try {
2335
+ const payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8'));
2336
+ if (
2337
+ payload?.v !== 1 ||
2338
+ !Array.isArray(payload.statementSids) ||
2339
+ !optionalNonEmptyString(payload.appId) ||
2340
+ !optionalNonEmptyString(payload.environment) ||
2341
+ !optionalNonEmptyString(payload.state) ||
2342
+ typeof payload.expiresAt !== 'number'
2343
+ ) {
2344
+ return null;
2345
+ }
2346
+ return {
2347
+ id,
2348
+ tenantId: optionalNonEmptyString(payload.tenantId) || null,
2349
+ appId: payload.appId,
2350
+ environment: payload.environment,
2351
+ state: payload.state,
2352
+ statementSids: mergeUniqueStrings(payload.statementSids),
2353
+ createdAt: typeof payload.createdAt === 'number' ? payload.createdAt : 0,
2354
+ expiresAt: payload.expiresAt,
2355
+ };
2356
+ } catch {
2357
+ return null;
2358
+ }
2359
+ }
2360
+
2361
+ function validatePrivacyPolicyUpdateSafety({ input, selectedStatements, decision, explicitSelection, apply }) {
2362
+ const highRiskKeepExact = decision === 'KEEP_EXACT'
2363
+ ? selectedStatements.filter(isHighRiskKeepExactStatement)
2364
+ : [];
2365
+ const safety = {
2366
+ blocked: false,
2367
+ explicitSelection,
2368
+ selectedStatementCount: selectedStatements.length,
2369
+ highRiskKeepExact: highRiskKeepExact.slice(0, 20).map((statement) =>
2370
+ formatPolicyStatementSummary(statement, { includeTechnical: true }),
2371
+ ),
2372
+ };
2373
+
2374
+ if (apply && !explicitSelection) {
2375
+ return {
2376
+ ...safety,
2377
+ blocked: true,
2378
+ code: 'EXACT_SELECTION_REQUIRED',
2379
+ summary:
2380
+ 'For safety, update_privacy_policy will not apply a broad natural-language query. Use statementSid(s), or run inspect_privacy_policy/list_privacy_policy_blockers and pass the returned blockerSetId.',
2381
+ nextStep:
2382
+ 'Run list_privacy_policy_blockers for the app/environment, then call update_privacy_policy with blockerSetId or exact statementSids.',
2383
+ };
2384
+ }
2385
+
2386
+ if (apply && highRiskKeepExact.length > 0) {
2387
+ const reason = optionalNonEmptyString(input.reason) || optionalNonEmptyString(input.overrideReason);
2388
+ if (input.force !== true || !reason) {
2389
+ return {
2390
+ ...safety,
2391
+ blocked: true,
2392
+ code: 'KEEP_EXACT_REQUIRES_FORCE_REASON',
2393
+ summary:
2394
+ `KEEP_EXACT would preserve ${highRiskKeepExact.length} sensitive or raw-text review statement(s). Pass force=true and a human reason only after confirming the data is safe to retain exactly.`,
2395
+ nextStep:
2396
+ 'Use REGEX_ASSISTED or DROP for sensitive/raw text. To intentionally keep exact, retry with force=true and reason describing why exact retention is acceptable.',
2397
+ };
2398
+ }
2399
+ safety.overrideReason = reason;
2400
+ }
2401
+
2402
+ return safety;
2403
+ }
2404
+
2405
+ function isHighRiskKeepExactStatement(statement) {
2406
+ const action = statement?.apply?.action;
2407
+ if (action === 'DROP' || action === 'TOKENIZE') {
2408
+ return true;
2409
+ }
2410
+ const review = statement?.meta?.review;
2411
+ if (review?.status === 'REQUIRES_DECISION') {
2412
+ return true;
2413
+ }
2414
+ const haystack = policyStatementHaystack(statement);
2415
+ return /secret|token|password|passwd|credential|authorization|auth|cookie|setcookie|api[_-]?key|apikey|bearer|webhook|private[_-]?key|connection[_-]?string|card|cvv|cvc|pan|payment|ssn|email|phone|address|fullname|full[_-]?name|contact|accountid|account[_-]?id|session/i.test(haystack);
2416
+ }
2417
+
2102
2418
  function buildPolicyUpdateDiff(policy, statements, decision, rawTextHintsInput) {
2103
2419
  return mutatePrivacyPolicy(policy, statements, decision, rawTextHintsInput).diff;
2104
2420
  }
2105
2421
 
2422
+ function safeBuildPolicyUpdateDiff(policy, statements, decision, rawTextHintsInput) {
2423
+ try {
2424
+ return buildPolicyUpdateDiff(policy, statements, decision, rawTextHintsInput);
2425
+ } catch (error) {
2426
+ return {
2427
+ error: error instanceof Error ? error.message : String(error),
2428
+ };
2429
+ }
2430
+ }
2431
+
2106
2432
  function mutatePrivacyPolicy(policy, selectedStatements, decision, rawTextHintsInput) {
2107
2433
  const clonedPolicy = cloneJson(policy) ?? {};
2108
2434
  const selectedSids = new Set(selectedStatements.map((statement) => String(statement?.sid ?? '')).filter(Boolean));
@@ -2702,7 +3028,7 @@ function suggestedPolicyBlockerNextStep(statement) {
2702
3028
  return 'Resolve this review decision before approval.';
2703
3029
  }
2704
3030
 
2705
- function buildPolicyInspectionAnswerGuide({ query, blockerQuery, policyBlockers, contradictions, ui }) {
3031
+ function buildPolicyInspectionAnswerGuide({ query, blockerQuery, policyBlockers, contradictions, ui, blockerSet }) {
2706
3032
  const blockerLines = policyBlockers.map((blocker) => {
2707
3033
  const mode = blocker.currentPolicy.rawTextMode || blocker.currentPolicy.descendantRawTextMode || blocker.currentPolicy.action || 'UNKNOWN';
2708
3034
  return `${blocker.label}: current ${blocker.currentPolicy.action}${mode && mode !== blocker.currentPolicy.action ? ` / ${mode}` : ''}; recommendation ${blocker.recommendation || 'review'}; UI ${blocker.ui.url}`;
@@ -2720,6 +3046,10 @@ function buildPolicyInspectionAnswerGuide({ query, blockerQuery, policyBlockers,
2720
3046
  blockerLines,
2721
3047
  contradictionLines: contradictions.map((contradiction) => contradiction.message),
2722
3048
  privacyStudioUrl: ui?.url ?? null,
3049
+ blockerSetId: blockerSet?.id ?? null,
3050
+ updateRule: blockerSet?.id
3051
+ ? 'For bulk blocker updates, call update_privacy_policy with blockerSetId. Do not use broad query apply.'
3052
+ : 'For policy writes, use update_privacy_policy with exact statementSid(s). Do not use direct Repro API scripts.',
2723
3053
  };
2724
3054
  }
2725
3055