@foldrun/core 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/dist/src/agent-activity.d.ts +5 -0
  2. package/dist/src/agent-activity.js +31 -0
  3. package/dist/src/agent-import.js +1 -1
  4. package/dist/src/agent-tools.js +1 -1
  5. package/dist/src/agents-md.js +1 -1
  6. package/dist/src/alerts.d.ts +60 -0
  7. package/dist/src/alerts.js +161 -0
  8. package/dist/src/browser-pod.js +4 -0
  9. package/dist/src/capture.d.ts +23 -0
  10. package/dist/src/capture.js +80 -0
  11. package/dist/src/compare.d.ts +57 -0
  12. package/dist/src/compare.js +116 -0
  13. package/dist/src/completions.js +1 -0
  14. package/dist/src/deploy.d.ts +36 -1
  15. package/dist/src/deploy.js +170 -11
  16. package/dist/src/egress.d.ts +16 -1
  17. package/dist/src/evals.d.ts +5 -1
  18. package/dist/src/evals.js +20 -6
  19. package/dist/src/frontmatter-fields.js +9 -0
  20. package/dist/src/frontmatter-schema.js +3 -0
  21. package/dist/src/history.d.ts +4 -0
  22. package/dist/src/history.js +15 -0
  23. package/dist/src/hooks.js +1 -1
  24. package/dist/src/insights.d.ts +30 -0
  25. package/dist/src/insights.js +152 -0
  26. package/dist/src/issues.d.ts +140 -0
  27. package/dist/src/issues.js +302 -0
  28. package/dist/src/library.js +1 -1
  29. package/dist/src/limits.d.ts +8 -0
  30. package/dist/src/limits.js +36 -5
  31. package/dist/src/linkable.js +1 -1
  32. package/dist/src/matter.d.ts +8 -0
  33. package/dist/src/matter.js +28 -0
  34. package/dist/src/model-credential.d.ts +14 -0
  35. package/dist/src/model-credential.js +40 -0
  36. package/dist/src/network.d.ts +12 -0
  37. package/dist/src/network.js +25 -1
  38. package/dist/src/notify.d.ts +16 -0
  39. package/dist/src/notify.js +17 -7
  40. package/dist/src/okf.js +1 -1
  41. package/dist/src/online-checks.d.ts +76 -0
  42. package/dist/src/online-checks.js +432 -0
  43. package/dist/src/operator.d.ts +277 -0
  44. package/dist/src/operator.js +176 -2
  45. package/dist/src/otel-config.d.ts +18 -0
  46. package/dist/src/otel-config.js +69 -0
  47. package/dist/src/otel.d.ts +38 -0
  48. package/dist/src/otel.js +85 -0
  49. package/dist/src/outcome.d.ts +40 -0
  50. package/dist/src/outcome.js +77 -0
  51. package/dist/src/package.js +1 -1
  52. package/dist/src/platform.d.ts +1 -1
  53. package/dist/src/prompt-layers.d.ts +30 -0
  54. package/dist/src/prompt-layers.js +77 -0
  55. package/dist/src/provider-health.js +1 -1
  56. package/dist/src/providers.d.ts +5 -0
  57. package/dist/src/providers.js +2 -2
  58. package/dist/src/run-container.d.ts +16 -2
  59. package/dist/src/run-container.js +57 -5
  60. package/dist/src/runner.d.ts +53 -2
  61. package/dist/src/runner.js +356 -70
  62. package/dist/src/runs-filter.d.ts +48 -0
  63. package/dist/src/runs-filter.js +73 -0
  64. package/dist/src/scan.d.ts +19 -1
  65. package/dist/src/scan.js +60 -7
  66. package/dist/src/starter.js +4 -2
  67. package/dist/src/step-exec.d.ts +29 -1
  68. package/dist/src/step-exec.js +106 -6
  69. package/dist/src/store.d.ts +130 -3
  70. package/dist/src/store.js +145 -6
  71. package/dist/src/tokens.d.ts +38 -0
  72. package/dist/src/tokens.js +57 -0
  73. package/dist/src/tool-names.js +1 -1
  74. package/dist/src/tool-programs.js +1 -1
  75. package/dist/src/trace-tree.d.ts +40 -0
  76. package/dist/src/trace-tree.js +156 -0
  77. package/dist/src/translator.d.ts +8 -0
  78. package/dist/src/translator.js +7 -2
  79. package/dist/src/trigger-log.d.ts +1 -1
  80. package/dist/src/workspace-check.d.ts +18 -0
  81. package/dist/src/workspace-check.js +34 -0
  82. package/package.json +2 -2
  83. package/src/agent-activity.ts +32 -0
  84. package/src/agent-import.ts +1 -1
  85. package/src/agent-tools.ts +1 -1
  86. package/src/agents-md.ts +1 -1
  87. package/src/alerts.ts +190 -0
  88. package/src/browser-pod.ts +3 -0
  89. package/src/capture.ts +88 -0
  90. package/src/compare.ts +146 -0
  91. package/src/completions.ts +1 -0
  92. package/src/deploy.ts +184 -10
  93. package/src/egress.ts +12 -2
  94. package/src/evals.ts +19 -6
  95. package/src/frontmatter-fields.ts +9 -0
  96. package/src/frontmatter-schema.ts +3 -0
  97. package/src/history.ts +13 -0
  98. package/src/hooks.ts +1 -1
  99. package/src/insights.ts +174 -0
  100. package/src/issues.ts +396 -0
  101. package/src/library.ts +1 -1
  102. package/src/limits.ts +38 -5
  103. package/src/linkable.ts +1 -1
  104. package/src/matter.ts +33 -0
  105. package/src/model-credential.ts +45 -0
  106. package/src/network.ts +25 -1
  107. package/src/notify.ts +22 -7
  108. package/src/okf.ts +1 -1
  109. package/src/online-checks.ts +450 -0
  110. package/src/operator.ts +213 -3
  111. package/src/otel-config.ts +67 -0
  112. package/src/otel.ts +100 -0
  113. package/src/outcome.ts +90 -0
  114. package/src/package.ts +1 -1
  115. package/src/platform.ts +1 -1
  116. package/src/prompt-layers.ts +90 -0
  117. package/src/provider-health.ts +1 -1
  118. package/src/providers.ts +7 -2
  119. package/src/run-container.ts +66 -7
  120. package/src/runner.ts +363 -46
  121. package/src/runs-filter.ts +88 -0
  122. package/src/scan.ts +68 -7
  123. package/src/starter.ts +4 -2
  124. package/src/step-exec.ts +147 -9
  125. package/src/store.ts +254 -8
  126. package/src/tokens.ts +68 -0
  127. package/src/tool-names.ts +1 -1
  128. package/src/tool-programs.ts +1 -1
  129. package/src/trace-tree.ts +187 -0
  130. package/src/translator.ts +13 -2
  131. package/src/trigger-log.ts +1 -0
  132. package/src/workspace-check.ts +41 -0
@@ -0,0 +1,432 @@
1
+ // The checks that need the network (FOL-58), kept apart from `scanFiles`,
2
+ // which stays offline and free:
3
+ //
4
+ // supply-chain/known-vulnerability — a runtime package pinned to an exact
5
+ // version that OSV.dev lists advisories for. Only exact pins are asked:
6
+ // an unpinned package is a different program tomorrow (and
7
+ // supply-chain/unpinned-package says so). Transitive dependencies are
8
+ // not resolved — a known limit, documented.
9
+ // content/mcp-tool-text — what an MCP server reached by URL tells the model
10
+ // about its tools, through the content rules as untrusted text. A server
11
+ // started as a command is never run to check it, and no credential is
12
+ // ever sent: a server that needs one is reported as not checked.
13
+ //
14
+ // Both answer findings in the scanner's shape and say, per source, what they
15
+ // could not check — an unreachable or malformed answer is never a pass.
16
+ // Shaped by a design debate and review with Codex (18 Oct 2026).
17
+ import matter from "./matter.js";
18
+ import { filterIgnored, findingFingerprint, untrustedTextFindings } from "./scan.js";
19
+ import { platform } from "./platform.js";
20
+ import { parseToolDef } from "./store.js";
21
+ export const OSV_BATCH_URL = "https://api.osv.dev/v1/querybatch";
22
+ const TIMEOUT_MS = 10_000;
23
+ const MAX_PINS = 500;
24
+ /** One server: pages of tools/list, tools read, characters of text, bytes read. */
25
+ const MCP_LIMITS = { pages: 10, tools: 300, text: 200_000, bytes: 2_000_000 };
26
+ /** The MCP protocol version this client asks for (2025-06-18 spec). */
27
+ const MCP_VERSION = "2025-06-18";
28
+ const front = (content) => {
29
+ try {
30
+ return matter(content).data;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ };
36
+ const lineOf = (content, needle) => {
37
+ const i = content.split(/\r?\n/).findIndex((l) => l.includes(needle));
38
+ return i >= 0 ? i + 1 : undefined;
39
+ };
40
+ /** Files whose frontmatter can carry a runtime: or mcpServers: block. */
41
+ const CONFIG_FILE = /^(?:library\/)?(?:AGENTS\.md|project\.md|agents\/[^/]+\/agent\.md|tools\/[^/]+(?:\/tool)?\.md|\.(?:claude|agents)\/agents\/[^/]+\.md)$/;
42
+ // ── known vulnerabilities ──────────────────────────────────────────────────
43
+ /** An exact pin: pip `name==1.2.3` (extras allowed), npm `name@1.2.3` or `@scope/name@1.2.3`. */
44
+ export function parsePin(spec) {
45
+ const s = spec.trim();
46
+ const pip = /^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s*===?\s*([0-9][A-Za-z0-9.+!-]*)$/.exec(s);
47
+ if (pip)
48
+ return { ecosystem: "PyPI", name: pip[1], version: pip[2] };
49
+ // npm's exact spellings: 1.2.3, =1.2.3, v1.2.3, =v1.2.3 (Codex review).
50
+ const npm = /^((?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*)@=?v?(\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?)$/.exec(s);
51
+ if (npm)
52
+ return { ecosystem: "npm", name: npm[1], version: npm[2] };
53
+ return null;
54
+ }
55
+ /** Every exact pin in the workspace's runtime: blocks (agents, AGENTS.md, tools). */
56
+ export function exactPins(files) {
57
+ const out = [];
58
+ for (const f of files) {
59
+ if (!CONFIG_FILE.test(f.path))
60
+ continue;
61
+ const runtime = front(f.content)?.runtime;
62
+ if (!runtime || typeof runtime !== "object")
63
+ continue;
64
+ const r = runtime;
65
+ // The keys the runtime installs from (runtime.ts parseRuntime): packages,
66
+ // else its pip alias; and npm.
67
+ for (const list of [r.packages ?? r.pip, r.npm]) {
68
+ if (!Array.isArray(list))
69
+ continue;
70
+ for (const raw of list) {
71
+ const p = typeof raw === "string" ? parsePin(raw) : null;
72
+ if (p)
73
+ out.push({ ...p, spec: raw, file: f.path, line: lineOf(f.content, raw) });
74
+ }
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+ const pinKey = (p) => `${p.ecosystem}:${p.name}@${p.version}`;
80
+ /** Ask OSV about every exact pin, once each, in one batch; findings fan out to every file that declares it. */
81
+ export async function vulnerabilityFindings(files, fetchImpl = fetch, deadline = Infinity) {
82
+ const pins = exactPins(files);
83
+ const unique = [...new Map(pins.map((p) => [pinKey(p), p])).values()];
84
+ if (!unique.length)
85
+ return { findings: [], problems: [], sources: [{ source: "osv", checked: true }] };
86
+ const asked = unique.slice(0, MAX_PINS);
87
+ const incomplete = unique.length > MAX_PINS ? [`${unique.length} pinned packages — the first ${MAX_PINS} were checked`] : [];
88
+ const fail = (problem) => ({ findings: [], problems: [problem], sources: [{ source: "osv", checked: false, problem }] });
89
+ // The caller's deadline holds here too (Codex review).
90
+ if (Date.now() >= deadline) {
91
+ const problem = "the check ran out of time before OSV — packages not checked";
92
+ return { findings: [], problems: [problem], sources: [{ source: "osv", checked: false, skipped: true, problem }] };
93
+ }
94
+ let results;
95
+ try {
96
+ const res = await fetchImpl(OSV_BATCH_URL, {
97
+ method: "POST",
98
+ headers: { "content-type": "application/json" },
99
+ body: JSON.stringify({ queries: asked.map((p) => ({ package: { name: p.name, ecosystem: p.ecosystem }, version: p.version })) }),
100
+ signal: AbortSignal.timeout(Math.max(1, Math.min(TIMEOUT_MS, deadline - Date.now()))),
101
+ });
102
+ if (!res.ok)
103
+ return fail(`OSV answered HTTP ${res.status} — packages not checked`);
104
+ const body = (await res.json());
105
+ if (!Array.isArray(body.results) || body.results.length !== asked.length)
106
+ return fail("OSV's answer did not match the question — packages not checked");
107
+ results = body.results;
108
+ }
109
+ catch (err) {
110
+ return fail(`OSV could not be reached (${err instanceof Error ? err.message : String(err)}) — packages not checked`);
111
+ }
112
+ const findings = [];
113
+ for (let i = 0; i < asked.length; i++) {
114
+ const r = results[i];
115
+ if (!r || typeof r !== "object")
116
+ return fail("OSV's answer was malformed — packages not checked");
117
+ const vulns = r.vulns === undefined ? [] : r.vulns;
118
+ if (!Array.isArray(vulns))
119
+ return fail("OSV's answer was malformed — packages not checked");
120
+ const raw = vulns.map((v) => (v && typeof v === "object" ? v.id : null));
121
+ // An advisory with no id is not "no advisory" — unknown, never clean (Codex review).
122
+ if (raw.some((x) => typeof x !== "string" || !x))
123
+ return fail("OSV's answer held an advisory with no id — packages not checked");
124
+ const ids = [...new Set(raw)].sort();
125
+ const paged = typeof r.next_page_token === "string" && r.next_page_token.length > 0;
126
+ const p = asked[i];
127
+ if (paged && !ids.length) {
128
+ incomplete.push(`${p.spec}: OSV paged its answer with no advisories on the first page — unknown, not clean`);
129
+ continue;
130
+ }
131
+ if (!ids.length)
132
+ continue;
133
+ if (paged)
134
+ incomplete.push(`${p.spec}: OSV has more advisories than one page — the rest not read`);
135
+ const count = paged ? `${ids.length} advisories returned (results incomplete)` : `${ids.length} known ${ids.length === 1 ? "advisory" : "advisories"}`;
136
+ const rule = "supply-chain/known-vulnerability";
137
+ for (const decl of pins.filter((x) => pinKey(x) === pinKey(p))) {
138
+ const message = `runtime package ${decl.spec} has ${count}: ${ids.slice(0, 5).join(", ")}${ids.length > 5 ? `, +${ids.length - 5} more` : ""} — upgrade it (https://osv.dev/vulnerability/${ids[0]})`;
139
+ // The occurrence is the pin in this file; the advisories are its
140
+ // evidence, so a new advisory on the same pin is not a new finding.
141
+ findings.push({ rule, level: "warn", file: decl.file, ...(decl.line ? { line: decl.line } : {}), message, fingerprint: findingFingerprint(rule, decl.file, pinKey(decl)), source: "osv" });
142
+ }
143
+ }
144
+ return {
145
+ findings: filterIgnored(findings, files),
146
+ problems: incomplete,
147
+ // A cap or a paged-unknown answer is not a complete check (Codex review).
148
+ sources: [incomplete.length ? { source: "osv", checked: false, partial: true, problem: incomplete.join("; ") } : { source: "osv", checked: true }],
149
+ };
150
+ }
151
+ /** MCP servers declared by URL — in an agent's mcpServers: or a tool with transport: mcp. */
152
+ export function remoteMcpServers(files) {
153
+ const out = [];
154
+ const add = (name, spec, file, content) => {
155
+ if (!spec || typeof spec !== "object")
156
+ return;
157
+ const s = spec;
158
+ if (typeof s.url !== "string" || !/^https?:\/\//i.test(s.url))
159
+ return;
160
+ let credentialUrl = /\$\{/.test(s.url);
161
+ try {
162
+ const u = new URL(s.url);
163
+ if (u.username || u.password || u.search)
164
+ credentialUrl = true;
165
+ }
166
+ catch {
167
+ credentialUrl = true;
168
+ }
169
+ const hasHeaders = Boolean(s.headers && typeof s.headers === "object" && Object.keys(s.headers).length);
170
+ out.push({ name, url: s.url, needsCredential: credentialUrl || hasHeaders, file, line: lineOf(content, s.url) });
171
+ };
172
+ for (const f of files) {
173
+ if (!CONFIG_FILE.test(f.path))
174
+ continue;
175
+ const data = front(f.content);
176
+ if (!data)
177
+ continue;
178
+ if (data.mcpServers && typeof data.mcpServers === "object") {
179
+ for (const [name, spec] of Object.entries(data.mcpServers))
180
+ add(name, spec, f.path, f.content);
181
+ }
182
+ // A tool file is an MCP server exactly when the runtime reads it as one:
183
+ // transport: mcp, the legacy type: mcp, or a url:/command: with neither.
184
+ const tool = /^(?:library\/)?tools\/([^/]+?)(?:\/tool)?\.md$/.exec(f.path);
185
+ if (tool) {
186
+ const def = parseToolDef(data, tool[1], f.content);
187
+ if (def?.kind === "mcp" && def.spec.url)
188
+ add(def.name ?? tool[1], { url: def.spec.url, headers: def.spec.headers }, f.path, f.content);
189
+ }
190
+ }
191
+ return out;
192
+ }
193
+ /** Parse one candidate message: the answer to `id`, an error, or not it. */
194
+ function matchAnswer(raw, id) {
195
+ let msg;
196
+ try {
197
+ msg = JSON.parse(raw);
198
+ }
199
+ catch {
200
+ return null;
201
+ }
202
+ if (!msg || typeof msg !== "object" || msg.id !== id)
203
+ return null;
204
+ const m = msg;
205
+ if (m.error)
206
+ return { problem: `answered a JSON-RPC error (${String(m.error.message ?? "unknown").slice(0, 120)})` };
207
+ if (!m.result || typeof m.result !== "object")
208
+ return { problem: "answered without a result" };
209
+ return { msg: m };
210
+ }
211
+ /**
212
+ * The JSON-RPC message answering `id`: one JSON body, or an SSE stream read
213
+ * incrementally — events joined per the SSE standard, the reader cancelled
214
+ * the moment the answer arrives (a server may keep the stream open). Never
215
+ * more than the byte budget.
216
+ */
217
+ async function rpcAnswer(res, id, budget) {
218
+ if (!res.body)
219
+ return { problem: "gave no answer to the request" };
220
+ const sse = (res.headers.get("content-type") ?? "").includes("text/event-stream");
221
+ const reader = res.body.getReader();
222
+ const decoder = new TextDecoder();
223
+ let buffer = "";
224
+ let data = [];
225
+ const done = async (r) => {
226
+ await reader.cancel().catch(() => undefined);
227
+ return r;
228
+ };
229
+ for (;;) {
230
+ const { done: end, value } = await reader.read();
231
+ if (value) {
232
+ budget.bytes -= value.byteLength;
233
+ if (budget.bytes < 0)
234
+ return done({ problem: `answered more than ${MCP_LIMITS.bytes} bytes` });
235
+ buffer += decoder.decode(value, { stream: true });
236
+ }
237
+ if (sse) {
238
+ // Complete lines only; an event ends at a blank line. A trailing CR
239
+ // may be the first half of a CRLF the next chunk completes, so it
240
+ // waits for more (Codex review).
241
+ let nl;
242
+ while ((nl = buffer.search(end ? /\r\n|\r|\n/ : /\r\n|\r(?!$)|\n/)) >= 0) {
243
+ const line = buffer.slice(0, nl);
244
+ buffer = buffer.slice(nl + (buffer.startsWith("\r\n", nl) ? 2 : 1));
245
+ if (line === "") {
246
+ if (data.length) {
247
+ const hit = matchAnswer(data.join("\n"), id);
248
+ data = [];
249
+ if (hit)
250
+ return done(hit);
251
+ }
252
+ }
253
+ else if (line.startsWith("data:"))
254
+ data.push(line.slice(5).replace(/^ /, ""));
255
+ }
256
+ }
257
+ if (end)
258
+ break;
259
+ }
260
+ if (sse) {
261
+ if (buffer.startsWith("data:"))
262
+ data.push(buffer.slice(5).replace(/^ /, ""));
263
+ if (data.length)
264
+ return matchAnswer(data.join("\n"), id) ?? { problem: "gave no answer to the request" };
265
+ return { problem: "gave no answer to the request" };
266
+ }
267
+ return matchAnswer(buffer, id) ?? { problem: "gave no answer to the request" };
268
+ }
269
+ /** Everything a tool shows the model: name, title, description, annotation title, schema descriptions. */
270
+ function toolText(tool) {
271
+ const parts = [tool.name, tool.title, tool.description, tool.annotations?.title].filter((x) => typeof x === "string");
272
+ // Every string the schema shows the model — property names, defaults,
273
+ // enum values, examples — not only descriptions (Codex review).
274
+ const walk = (v) => {
275
+ if (typeof v === "string") {
276
+ parts.push(v);
277
+ return;
278
+ }
279
+ if (!v || typeof v !== "object")
280
+ return;
281
+ for (const [k, x] of Object.entries(v)) {
282
+ parts.push(k);
283
+ walk(x);
284
+ }
285
+ };
286
+ walk(tool.inputSchema);
287
+ walk(tool.outputSchema);
288
+ return parts.join("\n");
289
+ }
290
+ /** initialize → notifications/initialized → tools/list (every page, within limits), anonymously. */
291
+ export async function mcpTools(url, fetchImpl, deadline = Infinity) {
292
+ const budget = { bytes: MCP_LIMITS.bytes };
293
+ const tools = [];
294
+ let session = null;
295
+ let version = MCP_VERSION;
296
+ const post = (body) => fetchImpl(url, {
297
+ method: "POST",
298
+ // Protocol headers only: nothing configured is forwarded, so no
299
+ // credential can leave on a probe (Codex review).
300
+ headers: {
301
+ "content-type": "application/json",
302
+ accept: "application/json, text/event-stream",
303
+ ...(session ? { "mcp-session-id": session } : {}),
304
+ ...(body.method !== "initialize" ? { "mcp-protocol-version": version } : {}),
305
+ },
306
+ body: JSON.stringify(body),
307
+ // The request's own limit, or what is left of the caller's — whichever is sooner.
308
+ signal: AbortSignal.timeout(Math.max(1, Math.min(TIMEOUT_MS, deadline - Date.now()))),
309
+ redirect: "manual",
310
+ });
311
+ try {
312
+ const init = await post({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: MCP_VERSION, capabilities: {}, clientInfo: { name: "foldrun-check", version: "1" } } });
313
+ if (!init.ok)
314
+ return { problem: `answered HTTP ${init.status} to initialize` };
315
+ session = init.headers.get("mcp-session-id");
316
+ const initAnswer = await rpcAnswer(init, 1, budget);
317
+ if ("problem" in initAnswer)
318
+ return { problem: `initialize: ${initAnswer.problem}` };
319
+ const negotiated = initAnswer.msg.result.protocolVersion;
320
+ if (typeof negotiated !== "string" || !negotiated)
321
+ return { problem: "initialize: no protocolVersion in the answer" };
322
+ version = negotiated;
323
+ const ack = await post({ jsonrpc: "2.0", method: "notifications/initialized" });
324
+ await ack.body?.cancel().catch(() => undefined);
325
+ if (!ack.ok)
326
+ return { problem: `answered HTTP ${ack.status} to notifications/initialized` };
327
+ let text = 0;
328
+ let malformed = 0;
329
+ let cursor;
330
+ for (let page = 0; page < MCP_LIMITS.pages; page++) {
331
+ const id = 2 + page;
332
+ // A failure after the first page keeps what was already read: a
333
+ // server cannot hide text it sent by failing the next page (Codex review).
334
+ const stop = (why) => (page === 0 ? { problem: why } : { tools, incomplete: `page ${page + 1} of tools/list: ${why}` });
335
+ const list = await post({ jsonrpc: "2.0", id, method: "tools/list", ...(cursor ? { params: { cursor } } : {}) });
336
+ if (!list.ok)
337
+ return stop(`answered HTTP ${list.status} to tools/list`);
338
+ const answer = await rpcAnswer(list, id, budget);
339
+ if ("problem" in answer)
340
+ return stop(`tools/list: ${answer.problem}`);
341
+ const result = answer.msg.result;
342
+ if (!Array.isArray(result.tools))
343
+ return stop("tools/list: no tools array in the answer");
344
+ for (const t of result.tools) {
345
+ // A tool with no name is not a tool the model can call — but it is
346
+ // not a clean answer either (Codex review).
347
+ if (!t || typeof t !== "object" || typeof t.name !== "string" || !t.name) {
348
+ malformed++;
349
+ if (t && typeof t === "object")
350
+ tools.push({ name: "(no name)", text: toolText(t) });
351
+ continue;
352
+ }
353
+ const body = toolText(t);
354
+ tools.push({ name: t.name.slice(0, 120), text: body });
355
+ text += body.length;
356
+ if (tools.length >= MCP_LIMITS.tools || text >= MCP_LIMITS.text)
357
+ return { tools, incomplete: `more than ${tools.length} tools or ${MCP_LIMITS.text} characters — the rest not read` };
358
+ }
359
+ cursor = typeof result.nextCursor === "string" && result.nextCursor ? result.nextCursor : undefined;
360
+ if (!cursor)
361
+ return malformed ? { tools, incomplete: `${malformed} malformed ${malformed === 1 ? "entry" : "entries"} in tools/list` } : { tools };
362
+ }
363
+ return { tools, incomplete: `more than ${MCP_LIMITS.pages} pages of tools — the rest not read` };
364
+ }
365
+ catch (err) {
366
+ const why = `could not be reached (${err instanceof Error ? err.message : String(err)})`;
367
+ // Thrown mid-way (a dropped connection on page 2): what was read stays (Codex review).
368
+ return tools.length ? { tools, incomplete: why } : { problem: why };
369
+ }
370
+ }
371
+ /** Every URL-reached MCP server's tool text, through the content rules as untrusted text. */
372
+ export async function mcpDescriptionFindings(files, opts = {}) {
373
+ const deadline = opts.deadline ?? Infinity;
374
+ // `order`: source ids to try first (the rescan's least recently checked),
375
+ // so a slow server early in the list cannot starve the rest for ever.
376
+ const rank = (s) => {
377
+ const i = opts.order?.indexOf(`mcp:${s.file}#${s.name}`) ?? -1;
378
+ return i < 0 ? -1 : i;
379
+ };
380
+ const fetchImpl = opts.fetch ?? ((url, init) => platform.fetchUntrusted(url, init));
381
+ const findings = [];
382
+ const problems = [];
383
+ const sources = [];
384
+ for (const server of remoteMcpServers(files).map((s, i) => ({ s, i })).sort((a, b) => rank(a.s) - rank(b.s) || a.i - b.i).map(({ s }) => s)) {
385
+ const source = `mcp:${server.file}#${server.name}`;
386
+ if (server.needsCredential) {
387
+ const problem = `mcpServers.${server.name}: needs a credential (headers, or a URL carrying one) — no credential is sent on a check, so its tools were not checked`;
388
+ problems.push(problem);
389
+ sources.push({ source, checked: false, problem });
390
+ continue;
391
+ }
392
+ if (Date.now() >= deadline) {
393
+ const problem = `mcpServers.${server.name}: the check ran out of time before it — its tools were not checked`;
394
+ problems.push(problem);
395
+ sources.push({ source, checked: false, skipped: true, problem });
396
+ continue;
397
+ }
398
+ const got = await mcpTools(server.url, fetchImpl, deadline);
399
+ if ("problem" in got) {
400
+ const problem = `mcpServers.${server.name} (${server.url}) ${got.problem} — its tools were not checked`;
401
+ problems.push(problem);
402
+ sources.push({ source, checked: false, problem });
403
+ continue;
404
+ }
405
+ if (got.incomplete)
406
+ problems.push(`mcpServers.${server.name}: ${got.incomplete}`);
407
+ sources.push(got.incomplete ? { source, checked: false, partial: true, problem: got.incomplete } : { source, checked: true });
408
+ for (const tool of got.tools) {
409
+ for (const f of untrustedTextFindings(tool.text)) {
410
+ const rule = "content/mcp-tool-text";
411
+ const message = `mcpServers.${server.name} tool "${tool.name}": ${f.message} [${f.rule}]`;
412
+ findings.push({
413
+ rule,
414
+ level: "warn",
415
+ file: server.file,
416
+ ...(server.line ? { line: server.line } : {}),
417
+ message,
418
+ fingerprint: findingFingerprint(rule, server.file, `${server.name} ${tool.name} ${f.rule}`),
419
+ source,
420
+ });
421
+ }
422
+ }
423
+ }
424
+ // The workspace's own foldrun-ignore can accept a finding — the server's
425
+ // text never can.
426
+ return { findings: filterIgnored(findings, files), problems, sources };
427
+ }
428
+ /** Both, as one answer. */
429
+ export async function onlineFindings(files, opts = {}) {
430
+ const [v, m] = await Promise.all([vulnerabilityFindings(files, opts.osvFetch ?? fetch, opts.deadline), mcpDescriptionFindings(files, { fetch: opts.fetch, deadline: opts.deadline, order: opts.order })]);
431
+ return { findings: [...v.findings, ...m.findings], problems: [...v.problems, ...m.problems], sources: [...v.sources, ...m.sources] };
432
+ }