@reproapp/mcp 0.0.5 → 0.0.7
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 +16 -0
- package/package.json +1 -1
- package/src/index.js +383 -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
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,12 @@ 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
|
+
'When a Repro tool returns valuePreview/valueLink for a large payload, return that preview and link to the user and stop there. Do not automatically follow the link or re-query for the full value unless the user explicitly asks for the full payload or raw value. Exact large-value expansion requires expandLargeValue=true and explicit user intent.',
|
|
20
|
+
].join(' ');
|
|
14
21
|
const DEFAULT_REPRO_AI_BASE_URL = 'https://repro-ai-u8lhw.ondigitalocean.app';
|
|
15
22
|
const DEFAULT_REPRO_API_BASE_URL = 'https://repro-api-d7288.ondigitalocean.app/api';
|
|
16
23
|
const DEFAULT_REPRO_WEB_BASE_URL = 'https://getrepro.io';
|
|
@@ -18,6 +25,8 @@ loadEnvironmentFiles(process.env);
|
|
|
18
25
|
const DEBUG_LOG_PATH = process.env.REPRO_MCP_DEBUG_LOG;
|
|
19
26
|
const DEFAULT_AUTH_TIMEOUT_MS = 2 * 60 * 1000;
|
|
20
27
|
const DEFAULT_AUTH_POLL_INTERVAL_MS = 1500;
|
|
28
|
+
const PRIVACY_BLOCKER_SET_TTL_MS = 15 * 60 * 1000;
|
|
29
|
+
const privacyBlockerSets = new Map();
|
|
21
30
|
const LOCAL_TOOL_DEFINITIONS = [
|
|
22
31
|
{
|
|
23
32
|
name: 'resolve_scope',
|
|
@@ -82,10 +91,28 @@ const LOCAL_TOOL_DEFINITIONS = [
|
|
|
82
91
|
additionalProperties: true,
|
|
83
92
|
},
|
|
84
93
|
},
|
|
94
|
+
{
|
|
95
|
+
name: 'list_privacy_policy_blockers',
|
|
96
|
+
description:
|
|
97
|
+
'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.',
|
|
98
|
+
inputSchema: {
|
|
99
|
+
type: 'object',
|
|
100
|
+
properties: {
|
|
101
|
+
tenantId: { type: 'string' },
|
|
102
|
+
appId: { type: 'string' },
|
|
103
|
+
environment: { type: 'string' },
|
|
104
|
+
state: { type: 'string', enum: ['active', 'pending', 'approved'] },
|
|
105
|
+
query: { type: 'string' },
|
|
106
|
+
includeTechnical: { type: 'boolean' },
|
|
107
|
+
openUi: { type: 'boolean' },
|
|
108
|
+
},
|
|
109
|
+
additionalProperties: true,
|
|
110
|
+
},
|
|
111
|
+
},
|
|
85
112
|
{
|
|
86
113
|
name: 'update_privacy_policy',
|
|
87
114
|
description:
|
|
88
|
-
'
|
|
115
|
+
'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
116
|
inputSchema: {
|
|
90
117
|
type: 'object',
|
|
91
118
|
properties: {
|
|
@@ -103,6 +130,7 @@ const LOCAL_TOOL_DEFINITIONS = [
|
|
|
103
130
|
type: 'array',
|
|
104
131
|
items: { type: 'string' },
|
|
105
132
|
},
|
|
133
|
+
blockerSetId: { type: 'string' },
|
|
106
134
|
decision: {
|
|
107
135
|
type: 'string',
|
|
108
136
|
enum: ['SAFE_PARTIAL_MASK', 'KEEP_EXACT', 'DROP', 'REGEX_ASSISTED'],
|
|
@@ -123,6 +151,9 @@ const LOCAL_TOOL_DEFINITIONS = [
|
|
|
123
151
|
apply: { type: 'boolean' },
|
|
124
152
|
approve: { type: 'boolean' },
|
|
125
153
|
allowMultiple: { type: 'boolean' },
|
|
154
|
+
force: { type: 'boolean' },
|
|
155
|
+
reason: { type: 'string' },
|
|
156
|
+
overrideReason: { type: 'string' },
|
|
126
157
|
openUi: { type: 'boolean' },
|
|
127
158
|
},
|
|
128
159
|
required: ['decision'],
|
|
@@ -1072,6 +1103,7 @@ async function handleRpcMessage(message, config, client, getTools) {
|
|
|
1072
1103
|
name: SERVER_NAME,
|
|
1073
1104
|
version: SERVER_VERSION,
|
|
1074
1105
|
},
|
|
1106
|
+
instructions: SERVER_INSTRUCTIONS,
|
|
1075
1107
|
},
|
|
1076
1108
|
};
|
|
1077
1109
|
}
|
|
@@ -1183,7 +1215,7 @@ async function executeTool(params, config, client, getTools) {
|
|
|
1183
1215
|
}
|
|
1184
1216
|
|
|
1185
1217
|
function formatLocalToolContentText(name, value) {
|
|
1186
|
-
if (name === 'inspect_privacy_policy') {
|
|
1218
|
+
if (name === 'inspect_privacy_policy' || name === 'list_privacy_policy_blockers') {
|
|
1187
1219
|
const text = renderPolicyInspectionText(value);
|
|
1188
1220
|
if (text) return text;
|
|
1189
1221
|
}
|
|
@@ -1191,9 +1223,51 @@ function formatLocalToolContentText(name, value) {
|
|
|
1191
1223
|
const text = renderPrivacyCoverageText(value);
|
|
1192
1224
|
if (text) return text;
|
|
1193
1225
|
}
|
|
1226
|
+
if (name === 'get_kafka_messages') {
|
|
1227
|
+
const text = renderKafkaMessagesText(value);
|
|
1228
|
+
if (text) return text;
|
|
1229
|
+
}
|
|
1194
1230
|
return JSON.stringify(value, null, 2);
|
|
1195
1231
|
}
|
|
1196
1232
|
|
|
1233
|
+
function renderKafkaMessagesText(value) {
|
|
1234
|
+
if (!value || typeof value !== 'object') {
|
|
1235
|
+
return null;
|
|
1236
|
+
}
|
|
1237
|
+
const data = value.data && typeof value.data === 'object' ? value.data : null;
|
|
1238
|
+
const items = Array.isArray(data?.items) ? data.items : [];
|
|
1239
|
+
if (!items.length) {
|
|
1240
|
+
return null;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
const lines = [`Found ${items.length} Kafka message${items.length === 1 ? '' : 's'}.`];
|
|
1244
|
+
let omittedCount = 0;
|
|
1245
|
+
|
|
1246
|
+
for (const item of items.slice(0, 3)) {
|
|
1247
|
+
const request = item?.request && typeof item.request === 'object' ? item.request : {};
|
|
1248
|
+
const message = item?.message && typeof item.message === 'object' ? item.message : {};
|
|
1249
|
+
lines.push(
|
|
1250
|
+
[
|
|
1251
|
+
typeof request.rid === 'string' ? `RID ${request.rid}` : null,
|
|
1252
|
+
typeof message.topic === 'string' ? `topic ${message.topic}` : null,
|
|
1253
|
+
typeof message.offset !== 'undefined' ? `offset ${message.offset}` : null,
|
|
1254
|
+
typeof message.key !== 'undefined' ? `key ${message.key}` : null,
|
|
1255
|
+
].filter(Boolean).join(' | '),
|
|
1256
|
+
);
|
|
1257
|
+
if (message.valueOmitted === true || message.rawValueOmitted === true) {
|
|
1258
|
+
omittedCount += 1;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
if (omittedCount > 0) {
|
|
1263
|
+
lines.push(
|
|
1264
|
+
'Large Kafka payload preview returned. Do not fetch the full value automatically. Only follow valueLink if the user explicitly asks for the full payload or raw value. Exact expansion requires expandLargeValue=true.'
|
|
1265
|
+
);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
return lines.filter(Boolean).join('\n');
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1197
1271
|
function mergeLocalTools(remoteTools) {
|
|
1198
1272
|
const byName = new Map();
|
|
1199
1273
|
for (const tool of LOCAL_TOOL_DEFINITIONS) {
|
|
@@ -1221,6 +1295,17 @@ async function executeLocalTool(name, input, config, client) {
|
|
|
1221
1295
|
if (name === 'inspect_privacy_policy') {
|
|
1222
1296
|
return inspectPrivacyPolicy(input, config, client);
|
|
1223
1297
|
}
|
|
1298
|
+
if (name === 'list_privacy_policy_blockers') {
|
|
1299
|
+
return inspectPrivacyPolicy(
|
|
1300
|
+
{
|
|
1301
|
+
...input,
|
|
1302
|
+
query: optionalNonEmptyString(input.query) || 'all privacy policy review blockers',
|
|
1303
|
+
includeTechnical: true,
|
|
1304
|
+
},
|
|
1305
|
+
config,
|
|
1306
|
+
client,
|
|
1307
|
+
);
|
|
1308
|
+
}
|
|
1224
1309
|
if (name === 'update_privacy_policy') {
|
|
1225
1310
|
return updatePrivacyPolicy(input, config, client);
|
|
1226
1311
|
}
|
|
@@ -1662,6 +1747,10 @@ function renderPolicyInspectionText(value) {
|
|
|
1662
1747
|
lines.push(`Summary: ${value.summary}`);
|
|
1663
1748
|
}
|
|
1664
1749
|
lines.push(`Privacy Studio: ${value.ui?.url ?? 'unavailable'}`);
|
|
1750
|
+
if (value.blockerSet?.id) {
|
|
1751
|
+
lines.push(`Blocker set: ${value.blockerSet.id} (${value.blockerSet.count} statement(s), expires ${value.blockerSet.expiresAt})`);
|
|
1752
|
+
lines.push('Use blockerSetId with update_privacy_policy for safe bulk changes.');
|
|
1753
|
+
}
|
|
1665
1754
|
const blockers = Array.isArray(value.policyBlockers) ? value.policyBlockers : [];
|
|
1666
1755
|
if (blockers.length > 0) {
|
|
1667
1756
|
lines.push('', `Policy Blockers (${blockers.length})`);
|
|
@@ -1733,6 +1822,7 @@ function renderPolicyBlockerTextLines(blocker) {
|
|
|
1733
1822
|
` choices: ${choices}`,
|
|
1734
1823
|
` surfaces: ${surfaces}`,
|
|
1735
1824
|
` reason: ${blocker?.reason ?? blocker?.reviewReason ?? 'n/a'}`,
|
|
1825
|
+
...(blocker?.technical?.statementSid ? [` statementSid: ${blocker.technical.statementSid}`] : []),
|
|
1736
1826
|
` UI: ${blocker?.ui?.url ?? 'unavailable'}`,
|
|
1737
1827
|
];
|
|
1738
1828
|
}
|
|
@@ -1782,13 +1872,26 @@ async function inspectPrivacyPolicy(input, config, client) {
|
|
|
1782
1872
|
const contradictions = findSameLeafPolicyContradictions(statements, matchedLeaves);
|
|
1783
1873
|
const uiQuery = query || contradictions[0]?.leaf || '';
|
|
1784
1874
|
const ui = buildPrivacyStudioUi(config, appId, selection.environmentId, uiQuery, input.openUi === true);
|
|
1875
|
+
const blockerStatements = blockerQuery
|
|
1876
|
+
? matches.map((match) => match.statement).filter(isReviewBlockerStatement)
|
|
1877
|
+
: matches.map((match) => match.statement).filter(isReviewBlockerStatement);
|
|
1785
1878
|
const policyBlockers = buildPolicyBlockerSummaries(
|
|
1786
|
-
|
|
1879
|
+
blockerStatements,
|
|
1787
1880
|
config,
|
|
1788
1881
|
appId,
|
|
1789
1882
|
selection.environmentId,
|
|
1790
1883
|
{ includeTechnical },
|
|
1791
1884
|
);
|
|
1885
|
+
const blockerSet = policyBlockers.length
|
|
1886
|
+
? rememberPrivacyBlockerSet({
|
|
1887
|
+
tenantId: optionalNonEmptyString(input.tenantId) || config.defaultTenantId || null,
|
|
1888
|
+
appId,
|
|
1889
|
+
environment: selection.environmentId,
|
|
1890
|
+
state: selection.state,
|
|
1891
|
+
query,
|
|
1892
|
+
statements: blockerStatements,
|
|
1893
|
+
})
|
|
1894
|
+
: null;
|
|
1792
1895
|
if (ui.openRequested) {
|
|
1793
1896
|
try {
|
|
1794
1897
|
await openUrl(ui.url);
|
|
@@ -1814,8 +1917,10 @@ async function inspectPrivacyPolicy(input, config, client) {
|
|
|
1814
1917
|
policyBlockers,
|
|
1815
1918
|
contradictions,
|
|
1816
1919
|
ui,
|
|
1920
|
+
blockerSet,
|
|
1817
1921
|
}),
|
|
1818
1922
|
policyBlockers,
|
|
1923
|
+
blockerSet,
|
|
1819
1924
|
matches: matches.map((match) => formatPolicyMatch(match.statement, match.score, { includeTechnical })),
|
|
1820
1925
|
contradictions,
|
|
1821
1926
|
rawTextHints: summarizeRawTextHints(selection.policy, query),
|
|
@@ -1854,7 +1959,30 @@ async function updatePrivacyPolicy(input, config, client) {
|
|
|
1854
1959
|
|
|
1855
1960
|
const query = policyInspectionQuery(input);
|
|
1856
1961
|
const statements = flattenPrivacyStatements(selection.policy);
|
|
1857
|
-
const
|
|
1962
|
+
const blockerSetSelection = resolvePrivacyBlockerSetSelection(input, {
|
|
1963
|
+
tenantId: optionalNonEmptyString(input.tenantId) || config.defaultTenantId || null,
|
|
1964
|
+
appId,
|
|
1965
|
+
environment: selection.environmentId,
|
|
1966
|
+
state: selection.state,
|
|
1967
|
+
});
|
|
1968
|
+
if (blockerSetSelection?.error) {
|
|
1969
|
+
return {
|
|
1970
|
+
status: 'blocked',
|
|
1971
|
+
code: blockerSetSelection.error.code,
|
|
1972
|
+
appId,
|
|
1973
|
+
environment: selection.environmentId,
|
|
1974
|
+
state: selection.state,
|
|
1975
|
+
summary: blockerSetSelection.error.message,
|
|
1976
|
+
ui: buildPrivacyStudioUi(config, appId, selection.environmentId, query, input.openUi === true),
|
|
1977
|
+
nextStep: 'Run inspect_privacy_policy or list_privacy_policy_blockers again, then pass the returned blockerSetId.',
|
|
1978
|
+
};
|
|
1979
|
+
}
|
|
1980
|
+
const selectedStatements = selectStatementsForPolicyUpdate(
|
|
1981
|
+
statements,
|
|
1982
|
+
input,
|
|
1983
|
+
query,
|
|
1984
|
+
blockerSetSelection?.statementSids,
|
|
1985
|
+
);
|
|
1858
1986
|
const explicitSelection = updateInputHasExplicitStatementSelection(input);
|
|
1859
1987
|
if (!selectedStatements.length) {
|
|
1860
1988
|
return {
|
|
@@ -1867,16 +1995,39 @@ async function updatePrivacyPolicy(input, config, client) {
|
|
|
1867
1995
|
matches: rankPolicyStatementMatches(statements, input, query).slice(0, 8).map((match) => formatPolicyMatch(match.statement, match.score, { includeTechnical: true })),
|
|
1868
1996
|
};
|
|
1869
1997
|
}
|
|
1870
|
-
if (
|
|
1998
|
+
if (blockerSetSelection && selectedStatements.length !== blockerSetSelection.statementSids.length) {
|
|
1871
1999
|
return {
|
|
1872
|
-
status: '
|
|
2000
|
+
status: 'blocked',
|
|
2001
|
+
code: 'STALE_BLOCKER_SET',
|
|
1873
2002
|
appId,
|
|
1874
2003
|
environment: selection.environmentId,
|
|
1875
2004
|
state: selection.state,
|
|
1876
|
-
summary: `The
|
|
2005
|
+
summary: `The blockerSetId references ${blockerSetSelection.statementSids.length} statement(s), but only ${selectedStatements.length} still exist in the current policy.`,
|
|
1877
2006
|
ui: buildPrivacyStudioUi(config, appId, selection.environmentId, query, input.openUi === true),
|
|
1878
2007
|
matches: selectedStatements.map((statement) => formatPolicyMatch(statement, 0, { includeTechnical: true })),
|
|
1879
|
-
|
|
2008
|
+
nextStep: 'Run inspect_privacy_policy or list_privacy_policy_blockers again, then retry with the new blockerSetId.',
|
|
2009
|
+
};
|
|
2010
|
+
}
|
|
2011
|
+
const safety = validatePrivacyPolicyUpdateSafety({
|
|
2012
|
+
input,
|
|
2013
|
+
selectedStatements,
|
|
2014
|
+
decision,
|
|
2015
|
+
explicitSelection,
|
|
2016
|
+
apply,
|
|
2017
|
+
});
|
|
2018
|
+
if (safety.blocked) {
|
|
2019
|
+
return {
|
|
2020
|
+
status: 'blocked',
|
|
2021
|
+
code: safety.code,
|
|
2022
|
+
appId,
|
|
2023
|
+
environment: selection.environmentId,
|
|
2024
|
+
state: selection.state,
|
|
2025
|
+
summary: safety.summary,
|
|
2026
|
+
ui: buildPrivacyStudioUi(config, appId, selection.environmentId, query, input.openUi === true),
|
|
2027
|
+
matches: selectedStatements.map((statement) => formatPolicyMatch(statement, 0, { includeTechnical: true })),
|
|
2028
|
+
proposed: safeBuildPolicyUpdateDiff(selection.policy, selectedStatements, decision, input.rawTextHints),
|
|
2029
|
+
safety,
|
|
2030
|
+
nextStep: safety.nextStep,
|
|
1880
2031
|
};
|
|
1881
2032
|
}
|
|
1882
2033
|
|
|
@@ -1908,6 +2059,8 @@ async function updatePrivacyPolicy(input, config, client) {
|
|
|
1908
2059
|
ui,
|
|
1909
2060
|
matches: selectedStatements.map((statement) => formatPolicyMatch(statement, 0, { includeTechnical: true })),
|
|
1910
2061
|
diff: mutation.diff,
|
|
2062
|
+
blockerSetId: blockerSetSelection?.id,
|
|
2063
|
+
safety,
|
|
1911
2064
|
nextStep: approve
|
|
1912
2065
|
? 'Run again with apply=true to stage the update and approve=true to approve it after API validation.'
|
|
1913
2066
|
: 'Run again with apply=true to stage the pending policy update.',
|
|
@@ -1962,6 +2115,8 @@ async function updatePrivacyPolicy(input, config, client) {
|
|
|
1962
2115
|
: `Staged pending privacy policy update for ${selectedStatements.length} statement(s).`,
|
|
1963
2116
|
ui,
|
|
1964
2117
|
diff: mutation.diff,
|
|
2118
|
+
blockerSetId: blockerSetSelection?.id,
|
|
2119
|
+
safety,
|
|
1965
2120
|
api: {
|
|
1966
2121
|
staged: true,
|
|
1967
2122
|
approved: approve,
|
|
@@ -2072,12 +2227,21 @@ function normalizePolicyUpdateDecision(value) {
|
|
|
2072
2227
|
function updateInputHasExplicitStatementSelection(input) {
|
|
2073
2228
|
return Boolean(
|
|
2074
2229
|
optionalNonEmptyString(input.statementSid) ||
|
|
2230
|
+
optionalNonEmptyString(input.blockerSetId) ||
|
|
2075
2231
|
(Array.isArray(input.statementSids) && input.statementSids.some((sid) => optionalNonEmptyString(sid))),
|
|
2076
2232
|
);
|
|
2077
2233
|
}
|
|
2078
2234
|
|
|
2079
|
-
function selectStatementsForPolicyUpdate(statements, input, query) {
|
|
2235
|
+
function selectStatementsForPolicyUpdate(statements, input, query, selectionSids) {
|
|
2080
2236
|
const explicitSids = new Set();
|
|
2237
|
+
if (Array.isArray(selectionSids)) {
|
|
2238
|
+
for (const sid of selectionSids) {
|
|
2239
|
+
const normalized = optionalNonEmptyString(sid);
|
|
2240
|
+
if (normalized) {
|
|
2241
|
+
explicitSids.add(normalized);
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2081
2245
|
const single = optionalNonEmptyString(input.statementSid);
|
|
2082
2246
|
if (single) {
|
|
2083
2247
|
explicitSids.add(single);
|
|
@@ -2099,10 +2263,215 @@ function selectStatementsForPolicyUpdate(statements, input, query) {
|
|
|
2099
2263
|
.map((match) => match.statement);
|
|
2100
2264
|
}
|
|
2101
2265
|
|
|
2266
|
+
function cleanupExpiredPrivacyBlockerSets(now = Date.now()) {
|
|
2267
|
+
for (const [id, record] of privacyBlockerSets.entries()) {
|
|
2268
|
+
if (!record?.expiresAt || record.expiresAt <= now) {
|
|
2269
|
+
privacyBlockerSets.delete(id);
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
function rememberPrivacyBlockerSet({ tenantId, appId, environment, state, query, statements }) {
|
|
2275
|
+
cleanupExpiredPrivacyBlockerSets();
|
|
2276
|
+
const statementSids = mergeUniqueStrings(
|
|
2277
|
+
(statements ?? []).map((statement) => String(statement?.sid ?? '')).filter(Boolean),
|
|
2278
|
+
);
|
|
2279
|
+
const createdAt = Date.now();
|
|
2280
|
+
const payload = {
|
|
2281
|
+
v: 1,
|
|
2282
|
+
tenantId: tenantId || null,
|
|
2283
|
+
appId,
|
|
2284
|
+
environment,
|
|
2285
|
+
state,
|
|
2286
|
+
queryHash: crypto.createHash('sha256').update(query || '').digest('hex').slice(0, 12),
|
|
2287
|
+
statementSids,
|
|
2288
|
+
createdAt,
|
|
2289
|
+
expiresAt: createdAt + PRIVACY_BLOCKER_SET_TTL_MS,
|
|
2290
|
+
};
|
|
2291
|
+
const encodedPayload = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
|
2292
|
+
const checksum = crypto.createHash('sha256').update(encodedPayload).digest('hex').slice(0, 16);
|
|
2293
|
+
const id = `PBS_${encodedPayload}.${checksum}`;
|
|
2294
|
+
const record = {
|
|
2295
|
+
id,
|
|
2296
|
+
tenantId: payload.tenantId,
|
|
2297
|
+
appId: payload.appId,
|
|
2298
|
+
environment: payload.environment,
|
|
2299
|
+
state: payload.state,
|
|
2300
|
+
query: query || null,
|
|
2301
|
+
statementSids,
|
|
2302
|
+
createdAt,
|
|
2303
|
+
expiresAt: payload.expiresAt,
|
|
2304
|
+
};
|
|
2305
|
+
privacyBlockerSets.set(id, record);
|
|
2306
|
+
return {
|
|
2307
|
+
id,
|
|
2308
|
+
count: statementSids.length,
|
|
2309
|
+
statementSids,
|
|
2310
|
+
expiresAt: new Date(record.expiresAt).toISOString(),
|
|
2311
|
+
expiresInMs: PRIVACY_BLOCKER_SET_TTL_MS,
|
|
2312
|
+
updateContract:
|
|
2313
|
+
'Pass this blockerSetId to update_privacy_policy. Repro MCP will apply only this exact set; broad query writes are rejected.',
|
|
2314
|
+
};
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
function resolvePrivacyBlockerSetSelection(input, expected) {
|
|
2318
|
+
const id = optionalNonEmptyString(input.blockerSetId);
|
|
2319
|
+
if (!id) {
|
|
2320
|
+
return null;
|
|
2321
|
+
}
|
|
2322
|
+
cleanupExpiredPrivacyBlockerSets();
|
|
2323
|
+
const record = decodePrivacyBlockerSetId(id) || privacyBlockerSets.get(id);
|
|
2324
|
+
if (!record) {
|
|
2325
|
+
return {
|
|
2326
|
+
error: {
|
|
2327
|
+
code: 'UNKNOWN_BLOCKER_SET',
|
|
2328
|
+
message: 'Unknown, expired, or malformed blockerSetId.',
|
|
2329
|
+
},
|
|
2330
|
+
};
|
|
2331
|
+
}
|
|
2332
|
+
if (record.expiresAt <= Date.now()) {
|
|
2333
|
+
return {
|
|
2334
|
+
error: {
|
|
2335
|
+
code: 'EXPIRED_BLOCKER_SET',
|
|
2336
|
+
message: 'The blockerSetId has expired.',
|
|
2337
|
+
},
|
|
2338
|
+
};
|
|
2339
|
+
}
|
|
2340
|
+
const mismatches = [];
|
|
2341
|
+
if (record.appId !== expected.appId) mismatches.push(`appId ${record.appId}`);
|
|
2342
|
+
if (record.environment !== expected.environment) mismatches.push(`environment ${record.environment}`);
|
|
2343
|
+
if (record.state !== expected.state) mismatches.push(`state ${record.state}`);
|
|
2344
|
+
if (record.tenantId && expected.tenantId && record.tenantId !== expected.tenantId) {
|
|
2345
|
+
mismatches.push(`tenantId ${record.tenantId}`);
|
|
2346
|
+
}
|
|
2347
|
+
if (mismatches.length) {
|
|
2348
|
+
return {
|
|
2349
|
+
error: {
|
|
2350
|
+
code: 'BLOCKER_SET_SCOPE_MISMATCH',
|
|
2351
|
+
message: `blockerSetId ${id} belongs to a different policy scope: ${mismatches.join(', ')}.`,
|
|
2352
|
+
},
|
|
2353
|
+
};
|
|
2354
|
+
}
|
|
2355
|
+
return {
|
|
2356
|
+
id,
|
|
2357
|
+
statementSids: record.statementSids,
|
|
2358
|
+
record,
|
|
2359
|
+
};
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
function decodePrivacyBlockerSetId(id) {
|
|
2363
|
+
if (!id.startsWith('PBS_')) {
|
|
2364
|
+
return null;
|
|
2365
|
+
}
|
|
2366
|
+
const encoded = id.slice(4);
|
|
2367
|
+
const separator = encoded.lastIndexOf('.');
|
|
2368
|
+
if (separator <= 0) {
|
|
2369
|
+
return null;
|
|
2370
|
+
}
|
|
2371
|
+
const encodedPayload = encoded.slice(0, separator);
|
|
2372
|
+
const checksum = encoded.slice(separator + 1);
|
|
2373
|
+
const expectedChecksum = crypto.createHash('sha256').update(encodedPayload).digest('hex').slice(0, 16);
|
|
2374
|
+
if (checksum !== expectedChecksum) {
|
|
2375
|
+
return null;
|
|
2376
|
+
}
|
|
2377
|
+
try {
|
|
2378
|
+
const payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8'));
|
|
2379
|
+
if (
|
|
2380
|
+
payload?.v !== 1 ||
|
|
2381
|
+
!Array.isArray(payload.statementSids) ||
|
|
2382
|
+
!optionalNonEmptyString(payload.appId) ||
|
|
2383
|
+
!optionalNonEmptyString(payload.environment) ||
|
|
2384
|
+
!optionalNonEmptyString(payload.state) ||
|
|
2385
|
+
typeof payload.expiresAt !== 'number'
|
|
2386
|
+
) {
|
|
2387
|
+
return null;
|
|
2388
|
+
}
|
|
2389
|
+
return {
|
|
2390
|
+
id,
|
|
2391
|
+
tenantId: optionalNonEmptyString(payload.tenantId) || null,
|
|
2392
|
+
appId: payload.appId,
|
|
2393
|
+
environment: payload.environment,
|
|
2394
|
+
state: payload.state,
|
|
2395
|
+
statementSids: mergeUniqueStrings(payload.statementSids),
|
|
2396
|
+
createdAt: typeof payload.createdAt === 'number' ? payload.createdAt : 0,
|
|
2397
|
+
expiresAt: payload.expiresAt,
|
|
2398
|
+
};
|
|
2399
|
+
} catch {
|
|
2400
|
+
return null;
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
function validatePrivacyPolicyUpdateSafety({ input, selectedStatements, decision, explicitSelection, apply }) {
|
|
2405
|
+
const highRiskKeepExact = decision === 'KEEP_EXACT'
|
|
2406
|
+
? selectedStatements.filter(isHighRiskKeepExactStatement)
|
|
2407
|
+
: [];
|
|
2408
|
+
const safety = {
|
|
2409
|
+
blocked: false,
|
|
2410
|
+
explicitSelection,
|
|
2411
|
+
selectedStatementCount: selectedStatements.length,
|
|
2412
|
+
highRiskKeepExact: highRiskKeepExact.slice(0, 20).map((statement) =>
|
|
2413
|
+
formatPolicyStatementSummary(statement, { includeTechnical: true }),
|
|
2414
|
+
),
|
|
2415
|
+
};
|
|
2416
|
+
|
|
2417
|
+
if (apply && !explicitSelection) {
|
|
2418
|
+
return {
|
|
2419
|
+
...safety,
|
|
2420
|
+
blocked: true,
|
|
2421
|
+
code: 'EXACT_SELECTION_REQUIRED',
|
|
2422
|
+
summary:
|
|
2423
|
+
'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.',
|
|
2424
|
+
nextStep:
|
|
2425
|
+
'Run list_privacy_policy_blockers for the app/environment, then call update_privacy_policy with blockerSetId or exact statementSids.',
|
|
2426
|
+
};
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
if (apply && highRiskKeepExact.length > 0) {
|
|
2430
|
+
const reason = optionalNonEmptyString(input.reason) || optionalNonEmptyString(input.overrideReason);
|
|
2431
|
+
if (input.force !== true || !reason) {
|
|
2432
|
+
return {
|
|
2433
|
+
...safety,
|
|
2434
|
+
blocked: true,
|
|
2435
|
+
code: 'KEEP_EXACT_REQUIRES_FORCE_REASON',
|
|
2436
|
+
summary:
|
|
2437
|
+
`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.`,
|
|
2438
|
+
nextStep:
|
|
2439
|
+
'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.',
|
|
2440
|
+
};
|
|
2441
|
+
}
|
|
2442
|
+
safety.overrideReason = reason;
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
return safety;
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
function isHighRiskKeepExactStatement(statement) {
|
|
2449
|
+
const action = statement?.apply?.action;
|
|
2450
|
+
if (action === 'DROP' || action === 'TOKENIZE') {
|
|
2451
|
+
return true;
|
|
2452
|
+
}
|
|
2453
|
+
const review = statement?.meta?.review;
|
|
2454
|
+
if (review?.status === 'REQUIRES_DECISION') {
|
|
2455
|
+
return true;
|
|
2456
|
+
}
|
|
2457
|
+
const haystack = policyStatementHaystack(statement);
|
|
2458
|
+
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);
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2102
2461
|
function buildPolicyUpdateDiff(policy, statements, decision, rawTextHintsInput) {
|
|
2103
2462
|
return mutatePrivacyPolicy(policy, statements, decision, rawTextHintsInput).diff;
|
|
2104
2463
|
}
|
|
2105
2464
|
|
|
2465
|
+
function safeBuildPolicyUpdateDiff(policy, statements, decision, rawTextHintsInput) {
|
|
2466
|
+
try {
|
|
2467
|
+
return buildPolicyUpdateDiff(policy, statements, decision, rawTextHintsInput);
|
|
2468
|
+
} catch (error) {
|
|
2469
|
+
return {
|
|
2470
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2471
|
+
};
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2106
2475
|
function mutatePrivacyPolicy(policy, selectedStatements, decision, rawTextHintsInput) {
|
|
2107
2476
|
const clonedPolicy = cloneJson(policy) ?? {};
|
|
2108
2477
|
const selectedSids = new Set(selectedStatements.map((statement) => String(statement?.sid ?? '')).filter(Boolean));
|
|
@@ -2702,7 +3071,7 @@ function suggestedPolicyBlockerNextStep(statement) {
|
|
|
2702
3071
|
return 'Resolve this review decision before approval.';
|
|
2703
3072
|
}
|
|
2704
3073
|
|
|
2705
|
-
function buildPolicyInspectionAnswerGuide({ query, blockerQuery, policyBlockers, contradictions, ui }) {
|
|
3074
|
+
function buildPolicyInspectionAnswerGuide({ query, blockerQuery, policyBlockers, contradictions, ui, blockerSet }) {
|
|
2706
3075
|
const blockerLines = policyBlockers.map((blocker) => {
|
|
2707
3076
|
const mode = blocker.currentPolicy.rawTextMode || blocker.currentPolicy.descendantRawTextMode || blocker.currentPolicy.action || 'UNKNOWN';
|
|
2708
3077
|
return `${blocker.label}: current ${blocker.currentPolicy.action}${mode && mode !== blocker.currentPolicy.action ? ` / ${mode}` : ''}; recommendation ${blocker.recommendation || 'review'}; UI ${blocker.ui.url}`;
|
|
@@ -2720,6 +3089,10 @@ function buildPolicyInspectionAnswerGuide({ query, blockerQuery, policyBlockers,
|
|
|
2720
3089
|
blockerLines,
|
|
2721
3090
|
contradictionLines: contradictions.map((contradiction) => contradiction.message),
|
|
2722
3091
|
privacyStudioUrl: ui?.url ?? null,
|
|
3092
|
+
blockerSetId: blockerSet?.id ?? null,
|
|
3093
|
+
updateRule: blockerSet?.id
|
|
3094
|
+
? 'For bulk blocker updates, call update_privacy_policy with blockerSetId. Do not use broad query apply.'
|
|
3095
|
+
: 'For policy writes, use update_privacy_policy with exact statementSid(s). Do not use direct Repro API scripts.',
|
|
2723
3096
|
};
|
|
2724
3097
|
}
|
|
2725
3098
|
|