@expo/code-review-cli 0.12.1 → 0.12.2

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.
@@ -0,0 +1,146 @@
1
+ const MAX_QUERY_CHARACTERS = 160;
2
+ const MAX_QUERY_TOKENS = 8;
3
+ const MAX_TOKEN_CHARACTERS = 64;
4
+ const SECRET_SHAPE = /(?:-----BEGIN [A-Z ]*PRIVATE KEY-----|\bAKIA[0-9A-Z]{16}\b|\b(?:sk|xox[baprs]|gh[opusr])[-_][A-Za-z0-9_-]{12,}\b|\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b)/i;
5
+ const NAMED_SECRET = /\b(?:api[-_ ]?key|access[-_ ]?token|auth[-_ ]?token|password|passwd|secret|credential)\b\s*(?::|=|is)?\s*\S+/i;
6
+ const QUOTED_LITERAL = /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`/g;
7
+ const URL_OR_EMAIL = /\b(?:https?:\/\/|www\.)\S+|\b[^\s@]+@[^\s@]+\.[^\s@]+\b/gi;
8
+ const PATH_LIKE = /(?:^|\s)(?:\.{0,2}[\\/]|[A-Za-z]:[\\/]|~[\\/])\S+/g;
9
+ const QUERY_TOKEN = /[A-Za-z_][A-Za-z0-9_.:$#<>()[\]-]{0,63}|\b\d{1,4}(?:\.\d{1,3}){0,2}\b/g;
10
+ const PROSE_STOP_WORDS = new Set([
11
+ "a",
12
+ "about",
13
+ "an",
14
+ "and",
15
+ "are",
16
+ "can",
17
+ "could",
18
+ "do",
19
+ "does",
20
+ "documentation",
21
+ "explain",
22
+ "find",
23
+ "for",
24
+ "from",
25
+ "how",
26
+ "i",
27
+ "in",
28
+ "is",
29
+ "it",
30
+ "of",
31
+ "on",
32
+ "or",
33
+ "please",
34
+ "search",
35
+ "show",
36
+ "tell",
37
+ "the",
38
+ "this",
39
+ "to",
40
+ "what",
41
+ "when",
42
+ "where",
43
+ "why",
44
+ "with",
45
+ "work",
46
+ "works",
47
+ ]);
48
+ function shannonEntropy(value) {
49
+ const counts = new Map();
50
+ for (const character of value)
51
+ counts.set(character, (counts.get(character) ?? 0) + 1);
52
+ let entropy = 0;
53
+ for (const count of counts.values()) {
54
+ const probability = count / value.length;
55
+ entropy -= probability * Math.log2(probability);
56
+ }
57
+ return entropy;
58
+ }
59
+ function looksHighEntropy(value) {
60
+ if (/^[A-Fa-f0-9]{24,}$/.test(value))
61
+ return true;
62
+ if (/^[A-Za-z0-9+/]{20,}={0,2}$/.test(value) && /[+/=]/.test(value))
63
+ return true;
64
+ return (value.length >= 24 &&
65
+ /[a-z]/.test(value) &&
66
+ /[A-Z]/.test(value) &&
67
+ /\d/.test(value) &&
68
+ shannonEntropy(value) >= 4.2);
69
+ }
70
+ function isApiAnchor(value) {
71
+ return (/[a-z][A-Z]/.test(value) ||
72
+ /[A-Z][A-Za-z0-9_]{1,}/.test(value) ||
73
+ /[._:$#()]/.test(value) ||
74
+ /_[A-Z0-9]/.test(value));
75
+ }
76
+ /**
77
+ * Convert an agent-authored search into a short API-symbol query. Dangerous shapes
78
+ * fail closed; prose, literals, URLs, paths, and unsupported punctuation are removed.
79
+ */
80
+ export function sanitizeDocumentationQuery(rawQuery) {
81
+ const visible = rawQuery
82
+ .normalize("NFKC")
83
+ // oxlint-disable-next-line no-control-regex -- outbound query containment
84
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
85
+ .replace(/\s+/g, " ")
86
+ .trim();
87
+ if (!visible || visible.length > 300) {
88
+ throw new Error("Query must contain between 1 and 300 visible characters");
89
+ }
90
+ if (SECRET_SHAPE.test(visible) || NAMED_SECRET.test(visible)) {
91
+ throw new Error("Query contains credential-shaped or secret-labeled material");
92
+ }
93
+ const candidates = (visible
94
+ .replace(QUOTED_LITERAL, " ")
95
+ .replace(URL_OR_EMAIL, " ")
96
+ .replace(PATH_LIKE, " ")
97
+ .match(QUERY_TOKEN) ?? []).filter((token) => {
98
+ if (token.length > MAX_TOKEN_CHARACTERS || looksHighEntropy(token))
99
+ return false;
100
+ return !PROSE_STOP_WORDS.has(token.toLowerCase());
101
+ });
102
+ const unique = [...new Set(candidates)].slice(0, MAX_QUERY_TOKENS);
103
+ if (!unique.some(isApiAnchor)) {
104
+ throw new Error("Query must include an API-like symbol or member name");
105
+ }
106
+ const sanitized = unique.join(" ").slice(0, MAX_QUERY_CHARACTERS).trim();
107
+ if (!sanitized)
108
+ throw new Error("Query contained no safe documentation terms");
109
+ return sanitized;
110
+ }
111
+ /** Reject URL decorations and path segments that could encode arbitrary outbound data. */
112
+ export function assertSafeDocumentationUrlShape(rawUrl) {
113
+ let url;
114
+ try {
115
+ url = new URL(rawUrl);
116
+ }
117
+ catch {
118
+ throw new Error("Invalid documentation URL");
119
+ }
120
+ if (url.protocol !== "https:" || url.username || url.password || url.port) {
121
+ throw new Error("Documentation URL must use plain HTTPS without credentials or a port");
122
+ }
123
+ if (url.search || url.hash) {
124
+ throw new Error("Documentation URL must not contain a query string or fragment");
125
+ }
126
+ const segments = url.pathname.split("/").filter(Boolean);
127
+ for (const segment of segments) {
128
+ let decoded;
129
+ try {
130
+ decoded = decodeURIComponent(segment);
131
+ }
132
+ catch {
133
+ throw new Error("Documentation URL contains an invalid encoded path segment");
134
+ }
135
+ // oxlint-disable-next-line no-control-regex -- URL path is an outbound data boundary
136
+ const containsControlCharacter = /[\\/\u0000-\u001f\u007f]/.test(decoded);
137
+ if (decoded.length > 120 ||
138
+ containsControlCharacter ||
139
+ SECRET_SHAPE.test(decoded) ||
140
+ NAMED_SECRET.test(decoded) ||
141
+ looksHighEntropy(decoded)) {
142
+ throw new Error("Documentation URL contains a suspicious path segment");
143
+ }
144
+ }
145
+ return url;
146
+ }
@@ -1,6 +1,9 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
3
  import { z } from "zod";
4
+ import { ResearchAudit } from "./audit.js";
5
+ import { sanitizeDocumentationQuery } from "./query-sanitizer.js";
6
+ import { fetchDocumentationUrl, resolveDirectDocumentationTarget } from "./direct-fetch.js";
4
7
  import { searchExpoAlgolia } from "./expo-algolia.js";
5
8
  import { searchOkHttpDocumentation } from "./okhttp-search.js";
6
9
  import { getProvider, resolveAllowedUrl } from "./providers.js";
@@ -20,6 +23,9 @@ function defaultProviders(platform) {
20
23
  }
21
24
  export async function createDocumentationServer(options = {}) {
22
25
  const index = options.indexPath ? await loadSearchIndex(options.indexPath) : undefined;
26
+ const maxCalls = Math.min(20, Math.max(1, options.maxCalls ?? 8));
27
+ const maxResultsPerCall = Math.min(3, Math.max(1, options.maxResultsPerCall ?? 3));
28
+ const audit = new ResearchAudit(options.auditPath, maxCalls);
23
29
  const server = new McpServer({
24
30
  name: "review-research-mcp",
25
31
  version: "0.2.0",
@@ -58,130 +64,222 @@ export async function createDocumentationServer(options = {}) {
58
64
  openWorldHint: true,
59
65
  },
60
66
  }, async ({ platform, query, limit, language, providers, sourceKinds }) => {
67
+ const sanitizedQuery = sanitizeDocumentationQuery(query);
61
68
  const selectedProviders = (providers ?? defaultProviders(platform)).filter((provider) => platform === "all" || getProvider(provider).platform === platform);
62
- const localResults = index
63
- ? searchDocumentation(index, query, {
64
- platform,
65
- limit,
66
- providers: selectedProviders,
67
- ...(sourceKinds ? { sourceKinds } : {}),
68
- ...(language ? { language } : {}),
69
- })
70
- : [];
71
- const warnings = [];
72
- const remoteResults = [];
73
- const perProviderLimit = Math.max(1, Math.ceil(limit / selectedProviders.length));
74
- const indexedAt = new Date().toISOString();
75
- const searched = await Promise.all(selectedProviders.map(async (provider) => {
76
- if (provider === "expo") {
77
- if (sourceKinds && !sourceKinds.includes("official-api")) {
78
- return { results: [], warnings: [] };
69
+ if (selectedProviders.length === 0) {
70
+ throw new Error("No selected documentation provider matches the requested platform");
71
+ }
72
+ const boundedLimit = Math.min(limit, maxResultsPerCall);
73
+ const auditInput = {
74
+ platform,
75
+ providers: selectedProviders,
76
+ query: sanitizedQuery,
77
+ };
78
+ const requestId = await audit.reserve("search_platform_docs", auditInput);
79
+ try {
80
+ const localResults = index
81
+ ? searchDocumentation(index, sanitizedQuery, {
82
+ platform,
83
+ limit: boundedLimit,
84
+ providers: selectedProviders,
85
+ ...(sourceKinds ? { sourceKinds } : {}),
86
+ ...(language ? { language } : {}),
87
+ })
88
+ : [];
89
+ const warnings = [];
90
+ const remoteResults = [];
91
+ const perProviderLimit = Math.max(1, Math.ceil(boundedLimit / selectedProviders.length));
92
+ const indexedAt = new Date().toISOString();
93
+ const searched = await Promise.all(selectedProviders.map(async (provider) => {
94
+ if (provider === "expo") {
95
+ if (sourceKinds && !sourceKinds.includes("official-api")) {
96
+ return { results: [], warnings: [] };
97
+ }
98
+ try {
99
+ const documents = await searchExpoAlgolia(sanitizedQuery, perProviderLimit);
100
+ return {
101
+ results: documents.map((document, position) => ({
102
+ id: `expo-algolia:${document.url}`,
103
+ platform: document.platform,
104
+ provider: "expo",
105
+ sourceKind: "official-api",
106
+ title: document.title,
107
+ url: document.url,
108
+ passage: document.body.slice(0, 1_400),
109
+ indexedAt,
110
+ score: perProviderLimit - position,
111
+ })),
112
+ warnings: [],
113
+ };
114
+ }
115
+ catch (error) {
116
+ const message = error instanceof Error ? error.message : String(error);
117
+ return {
118
+ results: [],
119
+ warnings: [`Expo documentation search unavailable: ${message}`],
120
+ };
121
+ }
79
122
  }
80
- try {
81
- const documents = await searchExpoAlgolia(query, perProviderLimit);
123
+ if (provider === "okhttp" &&
124
+ (!sourceKinds || sourceKinds.includes("official-guide")) &&
125
+ !language) {
126
+ try {
127
+ const results = await searchOkHttpDocumentation(sanitizedQuery, perProviderLimit, options.fetchImplementation ?? fetch);
128
+ if (results.length > 0) {
129
+ return { results, warnings: [] };
130
+ }
131
+ }
132
+ catch (error) {
133
+ const message = error instanceof Error ? error.message : String(error);
134
+ warnings.push(`OkHttp documentation search unavailable: ${message}`);
135
+ }
136
+ }
137
+ if (!options.braveApiKey) {
82
138
  return {
83
- results: documents.map((document, position) => ({
84
- id: `expo-algolia:${document.url}`,
85
- platform: document.platform,
86
- provider: "expo",
87
- sourceKind: "official-api",
88
- title: document.title,
89
- url: document.url,
90
- passage: document.body.slice(0, 1_400),
91
- indexedAt,
92
- score: perProviderLimit - position,
93
- })),
94
- warnings: [],
139
+ results: [],
140
+ warnings: [
141
+ `Scoped web search unavailable for ${provider}: BRAVE_SEARCH_API_KEY is not set`,
142
+ ],
95
143
  };
96
144
  }
145
+ try {
146
+ return await searchRemoteDocumentation(provider, sanitizedQuery, perProviderLimit, {
147
+ apiKey: options.braveApiKey,
148
+ ...(options.fetchImplementation
149
+ ? { fetchImplementation: options.fetchImplementation }
150
+ : {}),
151
+ ...(language ? { language } : {}),
152
+ ...(sourceKinds ? { sourceKinds } : {}),
153
+ });
154
+ }
97
155
  catch (error) {
98
156
  const message = error instanceof Error ? error.message : String(error);
99
157
  return {
100
158
  results: [],
101
- warnings: [`Expo documentation search unavailable: ${message}`],
159
+ warnings: [`Scoped web search unavailable for ${provider}: ${message}`],
102
160
  };
103
161
  }
162
+ }));
163
+ for (const searchedProvider of searched) {
164
+ remoteResults.push(...searchedProvider.results);
165
+ warnings.push(...searchedProvider.warnings);
104
166
  }
105
- if (provider === "okhttp" &&
106
- (!sourceKinds || sourceKinds.includes("official-guide")) &&
107
- !language) {
167
+ const seen = new Set();
168
+ const results = [...remoteResults, ...localResults]
169
+ .filter((result) => {
170
+ if (!result.provider)
171
+ return false;
108
172
  try {
109
- const results = await searchOkHttpDocumentation(query, perProviderLimit, options.fetchImplementation ?? fetch);
110
- if (results.length > 0) {
111
- return { results, warnings: [] };
112
- }
173
+ resolveAllowedUrl(getProvider(result.provider), result.url);
113
174
  }
114
- catch (error) {
115
- const message = error instanceof Error ? error.message : String(error);
116
- warnings.push(`OkHttp documentation search unavailable: ${message}`);
175
+ catch {
176
+ return false;
117
177
  }
118
- }
119
- if (!options.braveApiKey) {
120
- return {
121
- results: [],
122
- warnings: [
123
- `Scoped web search unavailable for ${provider}: BRAVE_SEARCH_API_KEY is not set`,
124
- ],
125
- };
126
- }
127
- try {
128
- return await searchRemoteDocumentation(provider, query, perProviderLimit, {
129
- apiKey: options.braveApiKey,
130
- ...(options.fetchImplementation
131
- ? { fetchImplementation: options.fetchImplementation }
132
- : {}),
133
- ...(language ? { language } : {}),
134
- ...(sourceKinds ? { sourceKinds } : {}),
135
- });
136
- }
137
- catch (error) {
138
- const message = error instanceof Error ? error.message : String(error);
139
- return {
140
- results: [],
141
- warnings: [`Scoped web search unavailable for ${provider}: ${message}`],
142
- };
143
- }
144
- }));
145
- for (const searchedProvider of searched) {
146
- remoteResults.push(...searchedProvider.results);
147
- warnings.push(...searchedProvider.warnings);
178
+ const key = `${result.provider}|${result.url}|${result.title}`;
179
+ if (seen.has(key))
180
+ return false;
181
+ seen.add(key);
182
+ return true;
183
+ })
184
+ .slice(0, boundedLimit);
185
+ const uniqueWarnings = [...new Set(warnings)].slice(0, 10);
186
+ const payload = {
187
+ notice: untrustedMaterialNotice,
188
+ retrieval: {
189
+ scopedWebSearch: Boolean(options.braveApiKey),
190
+ expoSearch: selectedProviders.includes("expo"),
191
+ localIndex: index
192
+ ? {
193
+ generatedAt: index.serialized.generatedAt,
194
+ providers: index.serialized.providers,
195
+ }
196
+ : null,
197
+ },
198
+ ...(uniqueWarnings.length > 0 ? { warnings: uniqueWarnings } : {}),
199
+ results,
200
+ };
201
+ await audit.complete(requestId, "search_platform_docs", auditInput, results, uniqueWarnings);
202
+ return {
203
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
204
+ };
148
205
  }
149
- const seen = new Set();
150
- const results = [...remoteResults, ...localResults]
151
- .filter((result) => {
152
- if (!result.provider)
153
- return false;
154
- try {
155
- resolveAllowedUrl(getProvider(result.provider), result.url);
156
- }
157
- catch {
158
- return false;
159
- }
160
- const key = `${result.provider}|${result.url}|${result.title}`;
161
- if (seen.has(key))
162
- return false;
163
- seen.add(key);
164
- return true;
165
- })
166
- .slice(0, limit);
167
- const payload = {
168
- notice: untrustedMaterialNotice,
169
- retrieval: {
170
- scopedWebSearch: Boolean(options.braveApiKey),
171
- expoSearch: selectedProviders.includes("expo"),
172
- localIndex: index
173
- ? {
174
- generatedAt: index.serialized.generatedAt,
175
- providers: index.serialized.providers,
176
- }
177
- : null,
178
- },
179
- ...(warnings.length > 0 ? { warnings: [...new Set(warnings)].slice(0, 10) } : {}),
180
- results,
181
- };
182
- return {
183
- content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
206
+ catch (error) {
207
+ await audit.fail(requestId, "search_platform_docs", auditInput, error);
208
+ throw error;
209
+ }
210
+ });
211
+ server.registerTool("fetch_platform_doc", {
212
+ title: "Fetch an official documentation URL",
213
+ description: "Fetch one caller-supplied documentation URL from the fixed Apple, Android, Expo, React Native, dependency, build-tool, release-note, or issue-source allowlist. The URL and every redirect are revalidated before download. Returns bounded extracted passages and the canonical source URL; use query only to rank passages within that page.",
214
+ inputSchema: {
215
+ url: z
216
+ .string()
217
+ .url()
218
+ .max(2_000)
219
+ .describe("Exact HTTPS documentation URL to fetch; must match a supported provider"),
220
+ provider: z
221
+ .enum(PROVIDERS)
222
+ .optional()
223
+ .describe("Optional provider hint for URLs accepted by more than one corpus"),
224
+ query: z
225
+ .string()
226
+ .min(1)
227
+ .max(300)
228
+ .optional()
229
+ .describe("Optional short phrase used only to select the most relevant page passages"),
230
+ limit: z.number().int().min(1).max(5).default(3),
231
+ },
232
+ annotations: {
233
+ readOnlyHint: true,
234
+ destructiveHint: false,
235
+ idempotentHint: true,
236
+ openWorldHint: true,
237
+ },
238
+ }, async ({ url, provider, query, limit }) => {
239
+ const sanitizedQuery = query ? sanitizeDocumentationQuery(query) : undefined;
240
+ // Resolve and validate before recording anything. This prevents credentials,
241
+ // query strings, or covert high-entropy path data from reaching either the
242
+ // network or the append-only audit file.
243
+ const target = resolveDirectDocumentationTarget(url, provider);
244
+ const auditInput = {
245
+ providers: [target.provider],
246
+ ...(sanitizedQuery ? { query: sanitizedQuery } : {}),
247
+ url: target.url.href,
184
248
  };
249
+ const requestId = await audit.reserve("fetch_platform_doc", auditInput);
250
+ try {
251
+ const fetched = await fetchDocumentationUrl(target.url.href, {
252
+ provider: target.provider,
253
+ ...(sanitizedQuery ? { query: sanitizedQuery } : {}),
254
+ limit: Math.min(limit, maxResultsPerCall),
255
+ ...(options.fetchImplementation
256
+ ? { fetchImplementation: options.fetchImplementation }
257
+ : {}),
258
+ });
259
+ const payload = {
260
+ notice: untrustedMaterialNotice,
261
+ retrieval: {
262
+ mode: "direct-url",
263
+ provider: fetched.provider,
264
+ sourceKind: fetched.sourceKind,
265
+ canonicalUrl: fetched.canonicalUrl,
266
+ },
267
+ results: fetched.results,
268
+ };
269
+ await audit.complete(requestId, "fetch_platform_doc", {
270
+ ...auditInput,
271
+ platform: getProvider(fetched.provider).platform,
272
+ providers: [fetched.provider],
273
+ url: fetched.canonicalUrl,
274
+ }, fetched.results);
275
+ return {
276
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
277
+ };
278
+ }
279
+ catch (error) {
280
+ await audit.fail(requestId, "fetch_platform_doc", auditInput, error);
281
+ throw error;
282
+ }
185
283
  });
186
284
  return server;
187
285
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/code-review-cli",
3
- "version": "0.12.1",
3
+ "version": "0.12.2",
4
4
  "description": "Generic, config-driven AI code reviewer engine. Repos supply their agents via .expo-code-review/.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -34,6 +34,8 @@ metadata. You do **not** re-review the code. You consolidate and decide.
34
34
  field from the final finding after folding it into `rationale`; otherwise the
35
35
  reporter detaches it below the collapsed block. Infer conservatively when a
36
36
  reviewer omitted either signal. Drop low-confidence findings.
37
+ Preserve each kept finding's grounded `sources` array. When merging duplicates,
38
+ keep the union of their existing sources. Never invent or edit a source.
37
39
  <!-- @ref LLP 0009#prompt-rules-for-adopters [implements] — the handoff is summary input only, never a reported finding and never a decision input -->
38
40
  4. **Extract overall PR risk.** Find the internal `__overall_pr_risk__` handoff
39
41
  from the cross-cutting reviewer, or from the full-context security reviewer
@@ -215,7 +215,8 @@ Return **only** a single fenced ```json code block, an object of this shape:
215
215
  "title": "short one-line summary",
216
216
  "rationale": "**Confidence:** High — why certainty is high.<br>**Impact if shipped:** Medium — concrete expected consequence.\\n\\n<details>\\n<summary>Evidence and reasoning</summary>\\n\\nFull failure/exploit path.\\n\\n</details>",
217
217
  "evidence": "one contiguous line of the flagged code, copied VERBATIM",
218
- "suggestion": "optional concrete fix, or omit"
218
+ "suggestion": "optional concrete fix, or omit",
219
+ "sources": [{ "title": "exact injected documentation title", "url": "exact injected URL" }]
219
220
  }
220
221
  ],
221
222
  "trace": {
@@ -225,6 +226,11 @@ Return **only** a single fenced ```json code block, an object of this shape:
225
226
  }
226
227
  ```
227
228
 
229
+ `sources` is optional. Include it only when injected platform research materially
230
+ supports the finding. Copy the exact title and URL from that research; the engine
231
+ rejects sources outside the trusted result set. Omit it for findings that did not
232
+ use documentation research.
233
+
228
234
  `line` is the start line in the new version of the file, or `null` if not
229
235
  line-specific. `evidence` is used to help verify the finding, so make it easy to
230
236
  locate: copy **one contiguous line** of the flagged code **verbatim** (not spanning