@expo/code-review-cli 0.12.4 → 0.12.5

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 CHANGED
@@ -190,7 +190,7 @@ Enable it only in the root config, which CI loads from the PR's trusted base:
190
190
  "enabled": true,
191
191
  "maxQueries": 8,
192
192
  "resultsPerQuery": 2,
193
- "timeoutMs": 15000
193
+ "timeoutMs": 30000
194
194
  }
195
195
  }
196
196
  ```
@@ -205,13 +205,26 @@ the current absolute Node executable, so a PR-owned `PATH` entry cannot replace
205
205
  either component. Each review gets an owner-only temporary MCP config and append-only
206
206
  audit. Claude receives that explicit config under `--strict-mcp-config`, with project
207
207
  settings and slash commands disabled; OpenCode receives the same fixed local command.
208
- The Brave credential is passed to the MCP child, not the model process.
209
208
 
210
- The MCP is the outbound security boundary. Search queries are normalized before
209
+ That command is a wrapper, not the server. Both engines merge a configured MCP `env`
210
+ onto their own environment instead of replacing it, so the declared block alone cannot
211
+ bound the child — the engine's model credential reaches it, and OpenCode additionally
212
+ passes down the runner's whole ambient environment. The wrapper therefore rebuilds the
213
+ environment from an explicit allowlist before starting the server, and loads no parser
214
+ and opens no socket of its own. The server process sees the search key, the call
215
+ bounds, and locale/proxy settings; it never sees a model credential. The Brave
216
+ credential travels the same way and is never added to the model process env.
217
+
218
+ The MCP bounds the shape, host, and volume of outbound requests. It is not a
219
+ confidentiality boundary: the reviewing model chooses the query terms and URLs, and a
220
+ low-entropy identifier can carry repository-derived data past every check below.
221
+ Enable research only where repository-derived terms may be shared with Brave and the
222
+ documentation providers. Search queries are normalized before
211
223
  logging or networking: quoted literals, URLs, email addresses, paths, prose stop
212
224
  words, overlong/high-entropy tokens, and unsupported punctuation are removed;
213
- credential-shaped or secret-labeled input fails closed. The remaining query must be
214
- at most eight short tokens and contain an API-like symbol. Direct URLs must use plain
225
+ credential-shaped or secret-labeled input fails closed. The remaining query must be at
226
+ most eight short tokens and either contain an API-like symbol or be a short multi-word
227
+ lowercase concept phrase. Direct URLs must use plain
215
228
  HTTPS with no credentials, port, query string, or fragment; suspicious/high-entropy
216
229
  path segments fail closed. The fixed provider host/path allowlist and redirect,
217
230
  response-size, content-type, and timeout checks still apply after that first gate.
@@ -219,6 +232,17 @@ These deterministic checks greatly reduce accidental exfiltration; they are not
219
232
  proof that every low-entropy string is harmless, so reviewer prompts also forbid
220
233
  sending repository text and the review-wide MCP budget defaults to eight calls.
221
234
 
235
+ `maxQueries` bounds MCP calls, not network requests. One search selects up to four
236
+ providers, and each issues its own discovery request plus a page fetch per candidate,
237
+ so eight calls can mean roughly thirty discovery requests and over a hundred page
238
+ downloads. Every call therefore reports its own ledger — discovery requests, page
239
+ fetches, redirect hops, total HTTP requests, and elapsed time — and the review log and
240
+ Actions summary report the totals. `timeoutMs` is the MCP's own end-to-end deadline for
241
+ one call, enforced by the server across discovery, redirects, retrieval, and
242
+ extraction; a call that hits it returns what it already has rather than failing. It has
243
+ to live there because OpenCode's `timeout` bounds only tool discovery and Claude
244
+ provides no per-call timeout at all.
245
+
222
246
  For non-Expo providers, discovery sends a fixed, provider-owned `site:` scope plus
223
247
  the bounded query to Brave's fixed Web Search endpoint. Search snippets and titles
224
248
  are never treated as evidence. ECR independently rejects off-allowlist result URLs,
@@ -276,8 +300,11 @@ same audit trail in the step summary, while `.runs/reviews.jsonl` keeps the quer
276
300
  plus bounded returned passages for short-lived operational inspection. Reviewers
277
301
  are instructed to attach `sources` only when documentation materially supports a
278
302
  finding. ECR accepts only exact URLs returned during that review, restores canonical
279
- titles, carries citations through coordination, and renders them below the finding;
280
- invented or unrelated citations are dropped.
303
+ titles, carries citations through coordination, and renders them below the finding.
304
+ A citation to a URL this review never retrieved is dropped outright. Relatedness is a
305
+ separate, weaker guarantee: a cited finding is escalated to the verifier with the
306
+ audited passage inline, which judges whether that passage actually supports the claim
307
+ and strips the citation when it does not.
281
308
 
282
309
  Reviewers also emit a bounded `researchDecisions` record only when documentation
283
310
  materially confirms a finding candidate or proves one safe. ECR grounds those records
@@ -31,7 +31,7 @@ const RESEARCH_CONFIG_DEFAULTS = {
31
31
  enabled: false,
32
32
  maxQueries: 8,
33
33
  resultsPerQuery: 2,
34
- timeoutMs: 15_000,
34
+ timeoutMs: 30_000,
35
35
  };
36
36
  /** Default OpenCode tool toggles for a reviewer: read the repo, never mutate it. */
37
37
  const DEFAULT_AGENT_TOOLS = toolMap(["read", "grep", "glob", "list"]);
@@ -7,6 +7,8 @@ import { tmpdir } from "node:os";
7
7
  import path from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { readResearchAudit } from "../research-mcp/audit.js";
10
+ import { researchChildEnvironment } from "../research-mcp/child-env.js";
11
+ import { totalResearchNetwork } from "../research-mcp/network.js";
10
12
  export { OPENCODE_RESEARCH_TOOLS } from "./tools.js";
11
13
  export const RESEARCH_DECISION_COUNT_LIMIT = 16;
12
14
  export const RESEARCH_DECISION_BYTES_LIMIT = 20_000;
@@ -15,33 +17,17 @@ export const CLAUDE_RESEARCH_TOOLS = [
15
17
  `mcp__${RESEARCH_MCP_SERVER_NAME}__search_platform_docs`,
16
18
  `mcp__${RESEARCH_MCP_SERVER_NAME}__fetch_platform_doc`,
17
19
  ];
18
- const RESEARCH_PROXY_ENV_KEYS = [
19
- "HTTP_PROXY",
20
- "HTTPS_PROXY",
21
- "NO_PROXY",
22
- "http_proxy",
23
- "https_proxy",
24
- "no_proxy",
25
- ];
26
- const RESEARCH_SEARCH_API_KEY = "BRAVE_SEARCH_API_KEY";
27
- export function researchChildEnvironment(source = process.env) {
28
- const environment = {
29
- LANG: "C.UTF-8",
30
- LC_ALL: "C.UTF-8",
31
- ...(process.platform === "win32" && source.SystemRoot ? { SystemRoot: source.SystemRoot } : {}),
32
- };
33
- for (const key of RESEARCH_PROXY_ENV_KEYS) {
34
- if (source[key])
35
- environment[key] = source[key];
36
- }
37
- if (source[RESEARCH_SEARCH_API_KEY]) {
38
- environment[RESEARCH_SEARCH_API_KEY] = source[RESEARCH_SEARCH_API_KEY];
39
- }
40
- return environment;
41
- }
20
+ export { researchChildEnvironment };
21
+ /**
22
+ * The engine spawns the WRAPPER, not the server. Both Claude Code and OpenCode
23
+ * merge the config's `env` block onto their own environment instead of replacing
24
+ * it, so the block below cannot bound the child on its own; the wrapper rebuilds
25
+ * the environment from an explicit allowlist before the real server starts. See
26
+ * research-mcp/wrapper.ts.
27
+ */
42
28
  export function bundledResearchServer() {
43
- const builtEntry = fileURLToPath(new URL("../research-mcp/cli.js", import.meta.url));
44
- const sourceEntry = fileURLToPath(new URL("../research-mcp/cli.ts", import.meta.url));
29
+ const builtEntry = fileURLToPath(new URL("../research-mcp/wrapper.js", import.meta.url));
30
+ const sourceEntry = fileURLToPath(new URL("../research-mcp/wrapper.ts", import.meta.url));
45
31
  return {
46
32
  command: process.execPath,
47
33
  args: [existsSync(builtEntry) ? builtEntry : sourceEntry],
@@ -49,8 +35,12 @@ export function bundledResearchServer() {
49
35
  }
50
36
  /**
51
37
  * Create one owner-only MCP configuration and append-only audit for a review run.
52
- * The model process receives only the config path; the Brave credential is passed
53
- * directly to the bounded MCP child and never added to the model process env.
38
+ * The model process receives only the config path.
39
+ *
40
+ * The `env` block below is a request the engine merges rather than applies — see
41
+ * bundledResearchServer. The wrapper it names is what actually bounds the server's
42
+ * environment, so the Brave credential and these limits are the only things the
43
+ * server can see, whatever the engine passed down.
54
44
  */
55
45
  export async function createResearchMcpRuntime(config) {
56
46
  if (!config.enabled)
@@ -70,6 +60,10 @@ export async function createResearchMcpRuntime(config) {
70
60
  REVIEW_RESEARCH_AUDIT_PATH: auditPath,
71
61
  REVIEW_RESEARCH_MAX_CALLS: String(config.maxQueries),
72
62
  REVIEW_RESEARCH_MAX_RESULTS: String(config.resultsPerQuery),
63
+ // The MCP enforces this itself as a per-call deadline. OpenCode's `timeout`
64
+ // only bounds tool DISCOVERY and Claude has no equivalent, so neither engine
65
+ // can bound how long a call actually runs — the server has to.
66
+ REVIEW_RESEARCH_TIMEOUT_MS: String(config.timeoutMs),
73
67
  }).flatMap(([key, value]) => (value === undefined ? [] : [[key, value]])));
74
68
  await writeFile(claudeConfigPath, `${JSON.stringify({
75
69
  mcpServers: {
@@ -131,6 +125,9 @@ export async function researchProvenanceFromAudit(auditPath) {
131
125
  warnings: [...new Set(warnings)].slice(0, 10),
132
126
  };
133
127
  const provenance = toResearchProvenance(run);
128
+ const ledgers = records.flatMap((record) => (record.network ? [record.network] : []));
129
+ if (ledgers.length > 0)
130
+ provenance.network = totalResearchNetwork(ledgers);
134
131
  if (rejections.length > 0) {
135
132
  const counts = new Map();
136
133
  for (const rejection of rejections) {
@@ -199,6 +196,11 @@ export function formatResearchProgress(provenance) {
199
196
  lines.push(` result: ${cleanEvidenceText(result.title, 160)} (${result.provider}/${result.sourceKind}) — ${result.url}`);
200
197
  }
201
198
  }
199
+ if (provenance.network) {
200
+ const { searchRequests, documentRequests, redirects, totalRequests } = provenance.network;
201
+ lines.push(` research network: ${searchRequests} search request(s), ${documentRequests} page fetch(es), ` +
202
+ `${redirects} redirect(s) — ${totalRequests} HTTP request(s) total`);
203
+ }
202
204
  for (const rejection of provenance.rejections ?? []) {
203
205
  lines.push(` research: ${rejection.count} ${rejection.tool} call(s) rejected before execution (${rejection.reason})`);
204
206
  }
@@ -242,6 +244,12 @@ export function renderResearchMarkdown(provenance) {
242
244
  lines.push(` - [${escapeMarkdownLabel(result.title)}](<${result.url}>) — ${escapeMarkdownLabel(result.provider)}/${escapeMarkdownLabel(result.sourceKind)}`);
243
245
  }
244
246
  }
247
+ if (provenance.network) {
248
+ const { searchRequests, documentRequests, redirects, totalRequests, elapsedMs } = provenance.network;
249
+ lines.push("", `Outbound: **${searchRequests}** search request(s), **${documentRequests}** page fetch(es), ` +
250
+ `**${redirects}** redirect(s) — **${totalRequests}** HTTP request(s) across ` +
251
+ `${(elapsedMs / 1000).toFixed(1)}s of call time.`);
252
+ }
245
253
  for (const rejection of provenance.rejections ?? []) {
246
254
  lines.push(`- ⚠️ ${rejection.count} \`${escapeMarkdownLabel(rejection.tool)}\` call(s) rejected before execution (${escapeMarkdownLabel(rejection.reason)}).`);
247
255
  }
@@ -129,7 +129,7 @@ export class ResearchAudit {
129
129
  });
130
130
  return requestId;
131
131
  }
132
- async complete(requestId, tool, input, results, warnings = []) {
132
+ async complete(requestId, tool, input, results, warnings = [], network) {
133
133
  await this.append({
134
134
  type: "completed",
135
135
  requestId,
@@ -137,6 +137,7 @@ export class ResearchAudit {
137
137
  input,
138
138
  results: results.map(boundedResult),
139
139
  warnings: warnings.slice(0, 10).map((warning) => warning.slice(0, 500)),
140
+ ...(network ? { network } : {}),
140
141
  timestamp: new Date().toISOString(),
141
142
  });
142
143
  }
@@ -0,0 +1,74 @@
1
+ // @ref LLP 0013#one-package-two-binaries [implements] — the bounded MCP's environment is constructed, never inherited
2
+ /**
3
+ * The single definition of what the bounded documentation MCP is allowed to see
4
+ * in its environment.
5
+ *
6
+ * Two callers share it, and they need it for different reasons:
7
+ *
8
+ * - `createResearchMcpRuntime` writes `researchChildEnvironment()` into the
9
+ * engine's MCP configuration. That block is a REQUEST, not a guarantee: both
10
+ * Claude Code and OpenCode merge it onto the environment the engine already
11
+ * has rather than replacing it, so the declared allowlist alone never bounds
12
+ * the child.
13
+ * - `wrapper.ts` applies `researchWrapperEnvironment()` when it spawns the real
14
+ * server. That IS the guarantee — the server process is handed a constructed
15
+ * environment, so whatever the engine merged in stops at the wrapper.
16
+ *
17
+ * Nothing here imports a parser, a network client, or the config schema: the
18
+ * wrapper must stay loadable without pulling untrusted-content machinery into
19
+ * the one process that still holds the engine's credentials.
20
+ */
21
+ const PROXY_ENV_KEYS = [
22
+ "HTTP_PROXY",
23
+ "HTTPS_PROXY",
24
+ "NO_PROXY",
25
+ "http_proxy",
26
+ "https_proxy",
27
+ "no_proxy",
28
+ ];
29
+ export const RESEARCH_SEARCH_API_KEY = "BRAVE_SEARCH_API_KEY";
30
+ /**
31
+ * Bounds ECR sets on the child through the config env block. These are inputs to
32
+ * the server, so the wrapper must forward them; every one is a number, a path ECR
33
+ * itself chose, or both, and none is a credential.
34
+ */
35
+ export const RESEARCH_RUNTIME_ENV_KEYS = [
36
+ "REVIEW_RESEARCH_AUDIT_PATH",
37
+ "REVIEW_RESEARCH_INDEX_PATH",
38
+ "REVIEW_RESEARCH_MAX_CALLS",
39
+ "REVIEW_RESEARCH_MAX_RESULTS",
40
+ "REVIEW_RESEARCH_TIMEOUT_MS",
41
+ ];
42
+ /**
43
+ * Locale, proxy configuration, and the search-only credential — the environment
44
+ * the MCP actually needs to do its job.
45
+ *
46
+ * NODE_OPTIONS is deliberately absent: it is arbitrary code injection into the
47
+ * process that parses untrusted remote documents. So are PATH and HOME — the
48
+ * wrapper spawns the server by absolute path and the server writes only to the
49
+ * audit path it is given.
50
+ */
51
+ export function researchChildEnvironment(source = process.env) {
52
+ const environment = {
53
+ LANG: "C.UTF-8",
54
+ LC_ALL: "C.UTF-8",
55
+ ...(process.platform === "win32" && source.SystemRoot ? { SystemRoot: source.SystemRoot } : {}),
56
+ };
57
+ for (const key of PROXY_ENV_KEYS) {
58
+ if (source[key])
59
+ environment[key] = source[key];
60
+ }
61
+ if (source[RESEARCH_SEARCH_API_KEY]) {
62
+ environment[RESEARCH_SEARCH_API_KEY] = source[RESEARCH_SEARCH_API_KEY];
63
+ }
64
+ return environment;
65
+ }
66
+ /** The child environment plus the bounds ECR passes through the config block. */
67
+ export function researchWrapperEnvironment(source = process.env) {
68
+ const environment = researchChildEnvironment(source);
69
+ for (const key of RESEARCH_RUNTIME_ENV_KEYS) {
70
+ if (source[key] !== undefined)
71
+ environment[key] = source[key];
72
+ }
73
+ return environment;
74
+ }
@@ -58,6 +58,7 @@ async function main() {
58
58
  : {}),
59
59
  maxCalls: boundedInteger("REVIEW_RESEARCH_MAX_CALLS", 8, 1, 20),
60
60
  maxResultsPerCall: boundedInteger("REVIEW_RESEARCH_MAX_RESULTS", 3, 1, 3),
61
+ timeoutMs: boundedInteger("REVIEW_RESEARCH_TIMEOUT_MS", 30_000, 1_000, 60_000),
61
62
  ...(process.env.BRAVE_SEARCH_API_KEY
62
63
  ? { braveApiKey: process.env.BRAVE_SEARCH_API_KEY }
63
64
  : {}),
@@ -0,0 +1,75 @@
1
+ /** Discovery endpoints. Everything else is a documentation page or asset. */
2
+ const SEARCH_ENDPOINTS = [
3
+ "https://api.search.brave.com/",
4
+ "https://qex7pb7d46-dsn.algolia.net/",
5
+ "https://lysine.dev/okhttp/search/search_index.json",
6
+ ];
7
+ export class ResearchDeadlineError extends Error {
8
+ constructor(timeoutMs) {
9
+ super(`Documentation research call exceeded its ${timeoutMs}ms deadline`);
10
+ this.name = "ResearchDeadlineError";
11
+ }
12
+ }
13
+ export function createResearchNetwork(base, timeoutMs) {
14
+ const startedAt = Date.now();
15
+ const controller = new AbortController();
16
+ const timer = setTimeout(() => controller.abort(new ResearchDeadlineError(timeoutMs)), timeoutMs);
17
+ // The MCP is a short-lived stdio process; never hold the loop open for this.
18
+ timer.unref?.();
19
+ let searchRequests = 0;
20
+ let documentRequests = 0;
21
+ let redirects = 0;
22
+ let totalRequests = 0;
23
+ const wrapped = async (input, init) => {
24
+ if (controller.signal.aborted)
25
+ throw new ResearchDeadlineError(timeoutMs);
26
+ const href = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
27
+ const isSearch = SEARCH_ENDPOINTS.some((endpoint) => href.startsWith(endpoint));
28
+ totalRequests++;
29
+ if (isSearch)
30
+ searchRequests++;
31
+ else
32
+ documentRequests++;
33
+ // Keep the caller's own per-attempt timeout AND add the call deadline, so a
34
+ // single slow hop still fails fast while the whole call stays bounded.
35
+ const signal = init?.signal
36
+ ? AbortSignal.any([init.signal, controller.signal])
37
+ : controller.signal;
38
+ const response = await base(input, { ...init, signal });
39
+ if (response.status >= 300 && response.status < 400) {
40
+ redirects++;
41
+ // A redirect hop is a round trip, not a distinct resource, so undo the
42
+ // classification count. It has to be the SAME counter that was
43
+ // incremented: the search backends use `redirect: "manual"` too and treat
44
+ // a 3xx as an error, so unconditionally decrementing documentRequests
45
+ // drives it negative the first time a discovery endpoint redirects.
46
+ if (isSearch)
47
+ searchRequests--;
48
+ else
49
+ documentRequests--;
50
+ }
51
+ return response;
52
+ };
53
+ return {
54
+ fetch: wrapped,
55
+ signal: controller.signal,
56
+ expired: () => controller.signal.aborted,
57
+ counts: () => ({
58
+ searchRequests,
59
+ documentRequests,
60
+ redirects,
61
+ totalRequests,
62
+ elapsedMs: Date.now() - startedAt,
63
+ }),
64
+ };
65
+ }
66
+ /** Sum per-call ledgers into one review-wide total. */
67
+ export function totalResearchNetwork(counts) {
68
+ return counts.reduce((total, entry) => ({
69
+ searchRequests: total.searchRequests + entry.searchRequests,
70
+ documentRequests: total.documentRequests + entry.documentRequests,
71
+ redirects: total.redirects + entry.redirects,
72
+ totalRequests: total.totalRequests + entry.totalRequests,
73
+ elapsedMs: total.elapsedMs + entry.elapsedMs,
74
+ }), { searchRequests: 0, documentRequests: 0, redirects: 0, totalRequests: 0, elapsedMs: 0 });
75
+ }
@@ -175,7 +175,9 @@ export async function searchRemoteDocumentation(providerId, query, limit, option
175
175
  // downloading every discovery candidate.
176
176
  const fetched = [];
177
177
  let candidateIndex = 0;
178
- while (fetched.length < limit && candidateIndex < candidates.length) {
178
+ while (fetched.length < limit &&
179
+ candidateIndex < candidates.length &&
180
+ !options.deadline?.aborted) {
179
181
  const outstanding = limit - fetched.length;
180
182
  const batch = candidates.slice(candidateIndex, candidateIndex + outstanding);
181
183
  candidateIndex += batch.length;
@@ -2,6 +2,7 @@ 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
4
  import { ResearchAudit } from "./audit.js";
5
+ import { createResearchNetwork } from "./network.js";
5
6
  import { sanitizeDocumentationQuery } from "./query-sanitizer.js";
6
7
  import { DIRECT_DOCUMENT_CONTEXT_MODES, fetchDocumentationUrl, resolveDirectDocumentationTarget, } from "./direct-fetch.js";
7
8
  import { searchExpoAlgolia } from "./expo-algolia.js";
@@ -26,7 +27,9 @@ export async function createDocumentationServer(options = {}) {
26
27
  const index = options.indexPath ? await loadSearchIndex(options.indexPath) : undefined;
27
28
  const maxCalls = Math.min(20, Math.max(1, options.maxCalls ?? 8));
28
29
  const maxResultsPerCall = Math.min(3, Math.max(1, options.maxResultsPerCall ?? 3));
30
+ const timeoutMs = Math.min(60_000, Math.max(1_000, options.timeoutMs ?? 30_000));
29
31
  const audit = new ResearchAudit(options.auditPath, maxCalls);
32
+ const baseFetch = options.fetchImplementation ?? fetch;
30
33
  const server = new McpServer({
31
34
  name: "review-research-mcp",
32
35
  version: "0.2.0",
@@ -89,6 +92,8 @@ export async function createDocumentationServer(options = {}) {
89
92
  query: sanitizedQuery,
90
93
  };
91
94
  const requestId = await audit.reserve("search_platform_docs", auditInput);
95
+ // One deadline and one request ledger for everything this call issues.
96
+ const network = createResearchNetwork(baseFetch, timeoutMs);
92
97
  try {
93
98
  const localResults = index
94
99
  ? searchDocumentation(index, sanitizedQuery, {
@@ -114,7 +119,7 @@ export async function createDocumentationServer(options = {}) {
114
119
  };
115
120
  }
116
121
  try {
117
- const documents = await searchExpoAlgolia(sanitizedQuery, perProviderLimit, options.fetchImplementation ?? fetch);
122
+ const documents = await searchExpoAlgolia(sanitizedQuery, perProviderLimit, network.fetch);
118
123
  return {
119
124
  results: documents.map((document, position) => ({
120
125
  id: `expo-algolia:${document.url}`,
@@ -142,7 +147,7 @@ export async function createDocumentationServer(options = {}) {
142
147
  (!sourceKinds || sourceKinds.includes("official-guide")) &&
143
148
  !language) {
144
149
  try {
145
- const results = await searchOkHttpDocumentation(sanitizedQuery, perProviderLimit, options.fetchImplementation ?? fetch);
150
+ const results = await searchOkHttpDocumentation(sanitizedQuery, perProviderLimit, network.fetch);
146
151
  if (results.length > 0) {
147
152
  return { results, warnings: [] };
148
153
  }
@@ -163,9 +168,8 @@ export async function createDocumentationServer(options = {}) {
163
168
  try {
164
169
  return await searchRemoteDocumentation(provider, sanitizedQuery, perProviderLimit, {
165
170
  apiKey: options.braveApiKey,
166
- ...(options.fetchImplementation
167
- ? { fetchImplementation: options.fetchImplementation }
168
- : {}),
171
+ fetchImplementation: network.fetch,
172
+ deadline: network.signal,
169
173
  ...(language ? { language } : {}),
170
174
  ...(sourceKinds ? { sourceKinds } : {}),
171
175
  });
@@ -201,10 +205,13 @@ export async function createDocumentationServer(options = {}) {
201
205
  })
202
206
  .slice(0, boundedLimit);
203
207
  const uniqueWarnings = [...new Set(warnings)].slice(0, 10);
208
+ const network_ = network.counts();
204
209
  const payload = {
205
210
  notice: untrustedMaterialNotice,
206
211
  retrieval: {
207
212
  scopedWebSearch: Boolean(options.braveApiKey),
213
+ // What this single budget unit actually cost.
214
+ network: network_,
208
215
  expoSearch: selectedProviders.includes("expo"),
209
216
  localIndex: index
210
217
  ? {
@@ -216,7 +223,7 @@ export async function createDocumentationServer(options = {}) {
216
223
  ...(uniqueWarnings.length > 0 ? { warnings: uniqueWarnings } : {}),
217
224
  results,
218
225
  };
219
- await audit.complete(requestId, "search_platform_docs", auditInput, results, uniqueWarnings);
226
+ await audit.complete(requestId, "search_platform_docs", auditInput, results, uniqueWarnings, network_);
220
227
  return {
221
228
  content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
222
229
  };
@@ -294,16 +301,18 @@ export async function createDocumentationServer(options = {}) {
294
301
  context,
295
302
  };
296
303
  const requestId = await audit.reserve("fetch_platform_doc", auditInput);
304
+ // A single URL still costs up to six round trips through the redirect
305
+ // chain, so this call needs the same deadline and ledger as a search.
306
+ const network = createResearchNetwork(baseFetch, timeoutMs);
297
307
  try {
298
308
  const fetched = await fetchDocumentationUrl(target.url.href, {
299
309
  provider: target.provider,
300
310
  ...(sanitizedQuery ? { query: sanitizedQuery } : {}),
301
311
  context,
302
312
  limit,
303
- ...(options.fetchImplementation
304
- ? { fetchImplementation: options.fetchImplementation }
305
- : {}),
313
+ fetchImplementation: network.fetch,
306
314
  });
315
+ const network_ = network.counts();
307
316
  const payload = {
308
317
  notice: untrustedMaterialNotice,
309
318
  retrieval: {
@@ -312,6 +321,7 @@ export async function createDocumentationServer(options = {}) {
312
321
  sourceKind: fetched.sourceKind,
313
322
  canonicalUrl: fetched.canonicalUrl,
314
323
  context: fetched.context,
324
+ network: network_,
315
325
  },
316
326
  results: fetched.results,
317
327
  };
@@ -320,7 +330,7 @@ export async function createDocumentationServer(options = {}) {
320
330
  platform: getProvider(fetched.provider).platform,
321
331
  providers: [fetched.provider],
322
332
  url: fetched.canonicalUrl,
323
- }, fetched.results);
333
+ }, fetched.results, [], network_);
324
334
  return {
325
335
  content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
326
336
  };
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ // @ref LLP 0013#one-package-two-binaries [implements] — the environment boundary the engine's MCP config cannot provide
3
+ /**
4
+ * Environment boundary for the bounded documentation MCP.
5
+ *
6
+ * ECR declares a minimal `env` in the engine's MCP configuration, but neither
7
+ * engine treats that as a replacement: Claude Code and OpenCode both MERGE it
8
+ * onto the environment the engine already holds. Verified by spawning each
9
+ * engine against a probe MCP that records variable names — the child saw the
10
+ * engine's model credential on both, and OpenCode additionally passed through
11
+ * the runner's whole ambient environment.
12
+ *
13
+ * So the boundary has to be ours. The engine spawns this wrapper; the wrapper
14
+ * spawns the real server with an environment it CONSTRUCTS. Whatever the engine
15
+ * merged in reaches this process and stops here.
16
+ *
17
+ * This file deliberately does almost nothing. It loads no HTML/JSON parser and
18
+ * opens no socket, because it is the one process in the chain that still holds
19
+ * the engine's credentials. Everything that touches untrusted remote content
20
+ * runs in the child, which never receives them.
21
+ */
22
+ import { spawn } from "node:child_process";
23
+ import { existsSync } from "node:fs";
24
+ import { constants } from "node:os";
25
+ import { fileURLToPath } from "node:url";
26
+ import { researchWrapperEnvironment } from "./child-env.js";
27
+ const builtEntry = fileURLToPath(new URL("./cli.js", import.meta.url));
28
+ const sourceEntry = fileURLToPath(new URL("./cli.ts", import.meta.url));
29
+ const child = spawn(
30
+ // The current interpreter by absolute path, never a PATH lookup: during a
31
+ // review the cwd is the untrusted PR-head tree.
32
+ process.execPath, [existsSync(builtEntry) ? builtEntry : sourceEntry, ...process.argv.slice(2)], {
33
+ env: researchWrapperEnvironment(process.env),
34
+ // The child owns the engine's stdio directly, so the wrapper never sits in
35
+ // the MCP byte stream and cannot truncate, buffer, or reorder a message.
36
+ stdio: "inherit",
37
+ });
38
+ const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
39
+ for (const signal of FORWARDED_SIGNALS) {
40
+ // The engine signals the process it spawned — us. Pass it on, or the server
41
+ // outlives the review and keeps its audit lock.
42
+ process.on(signal, () => {
43
+ child.kill(signal);
44
+ });
45
+ }
46
+ child.on("error", (error) => {
47
+ process.stderr.write(`review-research-mcp: failed to start bounded server: ${error.message}\n`);
48
+ process.exitCode = 1;
49
+ });
50
+ child.on("exit", (code, signal) => {
51
+ // Report a signal death as the conventional 128+n rather than a silent 0, so a
52
+ // killed server is distinguishable from a clean shutdown.
53
+ process.exitCode = signal ? 128 + (constants.signals[signal] ?? 0) : (code ?? 1);
54
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expo/code-review-cli",
3
- "version": "0.12.4",
3
+ "version": "0.12.5",
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": {
@@ -26,10 +26,20 @@
26
26
 
27
27
  // Optional bounded platform research (ROOT-ONLY; off by default). Reviewer and
28
28
  // cross-file passes can call ECR's bundled MCP for exact API-symbol searches and
29
- // supported documentation URLs. The MCP sanitizes queries, uses fixed provider
30
- // allowlists, audits results, and never receives model credentials. BRAVE_SEARCH_API_KEY
31
- // enables fixed site-scoped discovery; Expo uses its public documentation search.
32
- // indexPath remains an optional offline fallback only.
29
+ // supported documentation URLs. The MCP uses fixed provider allowlists and audits
30
+ // every call. It runs behind a wrapper that rebuilds its environment from an
31
+ // explicit allowlist, because both engines MERGE the configured env onto their own
32
+ // rather than replacing it — so the server sees the search key and these limits,
33
+ // and never the model credential. BRAVE_SEARCH_API_KEY enables fixed site-scoped
34
+ // discovery; Expo uses its public documentation search.
35
+ //
36
+ // Queries are shape-checked, not confidentiality-checked: the reviewing model
37
+ // chooses the outbound terms, so enable this only where repository-derived terms
38
+ // may be shared with Brave and the documentation providers.
39
+ //
40
+ // maxQueries bounds MCP CALLS, not requests — one search can issue a discovery
41
+ // request per provider plus a page fetch per candidate. timeoutMs is the MCP's own
42
+ // end-to-end deadline per call. indexPath remains an optional offline fallback.
33
43
  // "research": {
34
44
  // "enabled": true,
35
45
  // "maxQueries": 8,