@reproapp/mcp 0.0.3 → 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.
- package/README.md +18 -2
- package/package.json +1 -1
- package/src/index.js +342 -12
package/README.md
CHANGED
|
@@ -39,12 +39,24 @@ 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:
|
|
45
57
|
|
|
46
|
-
- `REPRO_AI_BASE_URL=
|
|
47
|
-
- `REPRO_API_BASE_URL=
|
|
58
|
+
- `REPRO_AI_BASE_URL=https://repro-ai-u8lhw.ondigitalocean.app`
|
|
59
|
+
- `REPRO_API_BASE_URL=https://repro-api-d7288.ondigitalocean.app/api`
|
|
48
60
|
- `REPRO_WEB_BASE_URL=https://getrepro.io`
|
|
49
61
|
|
|
50
62
|
Optional:
|
|
@@ -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,13 +12,20 @@ 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';
|
|
14
|
-
const
|
|
15
|
-
|
|
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(' ');
|
|
20
|
+
const DEFAULT_REPRO_AI_BASE_URL = 'https://repro-ai-u8lhw.ondigitalocean.app';
|
|
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';
|
|
17
23
|
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
|
-
'
|
|
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
|
-
|
|
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
|
|
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 (
|
|
1955
|
+
if (blockerSetSelection && selectedStatements.length !== blockerSetSelection.statementSids.length) {
|
|
1871
1956
|
return {
|
|
1872
|
-
status: '
|
|
1957
|
+
status: 'blocked',
|
|
1958
|
+
code: 'STALE_BLOCKER_SET',
|
|
1873
1959
|
appId,
|
|
1874
1960
|
environment: selection.environmentId,
|
|
1875
1961
|
state: selection.state,
|
|
1876
|
-
summary: `The
|
|
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
|
-
|
|
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
|
|