@foldrun/core 4.0.0 → 6.0.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.
@@ -893,6 +893,8 @@ export async function startTranslator(spec) {
893
893
  req.on("end", () => resolve(body));
894
894
  req.on("error", reject);
895
895
  });
896
+ if (spec.wire === "anthropic")
897
+ return await passthrough(spec, base, label, req, url, raw, res, log, started);
896
898
  if (req.method === "POST" && url.pathname === "/v1/messages/count_tokens") {
897
899
  // No endpoint on the other side counts for us; four characters a
898
900
  // token is the estimate every tokenizer lands near for English.
@@ -1058,6 +1060,77 @@ export async function startTranslator(spec) {
1058
1060
  }),
1059
1061
  };
1060
1062
  }
1063
+ /** Request headers the SDK's own call carries that the endpoint is owed:
1064
+ * the content type, and Anthropic's version and beta flags. Its credential
1065
+ * (the translator's key) is not one of them. */
1066
+ const PASSED_HEADERS = /^(content-type|accept|anthropic-[a-z0-9-]+)$/;
1067
+ /** Response headers that describe the bytes as they came off the wire —
1068
+ * fetch has already decoded them, so they would lie on the way back. */
1069
+ const HOP_HEADERS = new Set(["content-encoding", "content-length", "transfer-encoding", "connection", "keep-alive"]);
1070
+ /**
1071
+ * The anthropic wire: the SDK's request, as it is, to the same path under the
1072
+ * tenant's base URL, with the tenant's key put on here — through
1073
+ * fetchUntrusted, so no private or cluster address, no redirect that takes
1074
+ * the key to another origin, and the answer read to UPSTREAM_MAX_BYTES. The
1075
+ * answer streams back as it arrives, status and headers included.
1076
+ */
1077
+ async function passthrough(spec, base, label, req, url, raw, res, log, started) {
1078
+ // Lowercase keys, so a header named twice in different case is one header,
1079
+ // and the provider's own headers last: one that carries its credential
1080
+ // itself (an Authorization or x-api-key in `headers:`) wins over the key
1081
+ // slot, as on the Chat Completions wire above.
1082
+ const headers = {};
1083
+ for (const [k, v] of Object.entries(req.headers))
1084
+ if (PASSED_HEADERS.test(k) && typeof v === "string")
1085
+ headers[k] = v;
1086
+ if (spec.upstreamKey) {
1087
+ if (spec.upstreamAuth === "x-api-key")
1088
+ headers["x-api-key"] = spec.upstreamKey;
1089
+ else
1090
+ headers.authorization = `Bearer ${spec.upstreamKey}`;
1091
+ }
1092
+ for (const [k, v] of Object.entries(spec.headers ?? {}))
1093
+ headers[k.toLowerCase()] = v;
1094
+ const method = req.method ?? "GET";
1095
+ const target = `${base}${url.pathname}${url.search}`;
1096
+ let upstream;
1097
+ try {
1098
+ upstream = await fetchUntrusted(target, { method, headers, body: method === "GET" || method === "HEAD" ? undefined : raw }, { sameOrigin: true, maxBytes: UPSTREAM_MAX_BYTES });
1099
+ }
1100
+ catch (err) {
1101
+ const message = err instanceof Error ? err.message : String(err);
1102
+ log.push(`${method} ${label}${url.pathname} → unreachable: ${message}`);
1103
+ res.writeHead(502, { "content-type": "application/json" });
1104
+ res.end(errorBody(502, `translator: could not reach ${label}: ${message}`));
1105
+ return;
1106
+ }
1107
+ const out = {};
1108
+ upstream.headers.forEach((v, k) => {
1109
+ if (!HOP_HEADERS.has(k))
1110
+ out[k] = v;
1111
+ });
1112
+ res.writeHead(upstream.status, out);
1113
+ log.push(`${method} ${label}${url.pathname} → ${upstream.status} (${Date.now() - started}ms)`);
1114
+ const reader = upstream.body?.getReader();
1115
+ if (!reader)
1116
+ return void res.end();
1117
+ try {
1118
+ for (;;) {
1119
+ const { value, done } = await reader.read();
1120
+ if (done)
1121
+ break;
1122
+ res.write(value);
1123
+ }
1124
+ res.end();
1125
+ }
1126
+ catch (err) {
1127
+ // Mid-answer (the cap, a dropped connection): an Anthropic error event,
1128
+ // so a stream reads as failed, not as a reply that merely stopped.
1129
+ const message = err instanceof Error ? err.message : String(err);
1130
+ log.push(`translator error: ${message}`);
1131
+ res.end(`event: error\ndata: ${errorBody(500, `translator: ${message}`)}\n\n`);
1132
+ }
1133
+ }
1061
1134
  /**
1062
1135
  * What a provider block needs from the translator, or null when the block
1063
1136
  * speaks Anthropic and needs none. Values only — this crosses into the run
@@ -115,8 +115,8 @@ export function recordDelivery(tenant, workspace, delivery) {
115
115
  const file = deliveriesFile(tenant, workspace);
116
116
  if (!fs.existsSync(path.dirname(file)))
117
117
  return; // no workspace, nothing to log against
118
- if (UNAUTHENTICATED.includes(delivery.outcome) && collapseInto(file, delivery))
119
- return;
118
+ if (UNAUTHENTICATED.includes(delivery.outcome))
119
+ return recordRefusal(file, tenant, workspace, delivery);
120
120
  fs.appendFileSync(file, JSON.stringify(delivery) + "\n");
121
121
  // Bounded: compact to the newest KEEP when it doubles. Amortised cheap,
122
122
  // and a log that grows forever is a disk-full incident with a delay.
@@ -132,9 +132,42 @@ export function recordDelivery(tenant, workspace, delivery) {
132
132
  // compaction is best-effort; the append already landed
133
133
  }
134
134
  }
135
+ /**
136
+ * A refusal — anyone's to send — never changes the workspace's directory: no
137
+ * new file, no tmp file, no rename. The directory's mtime is the workspace's
138
+ * deployedAt (store.ts listWorkspaces), and a stranger posting a bad token
139
+ * moved it (FOL-102). So a refusal goes into a log that already exists, in
140
+ * place (an append, or the last line rewritten), and is never the write that
141
+ * compacts it; a workspace with no log yet gets a server log line instead,
142
+ * at most one a minute per workspace.
143
+ */
144
+ function recordRefusal(file, tenant, workspace, delivery) {
145
+ if (!fs.existsSync(file))
146
+ return noteRefusal(tenant, workspace, delivery);
147
+ if (collapseInto(file, delivery))
148
+ return;
149
+ fs.appendFileSync(file, JSON.stringify(delivery) + "\n");
150
+ }
151
+ const REFUSAL_NOTE_MS = 60_000;
152
+ const refusalNotes = new Map();
153
+ function noteRefusal(tenant, workspace, delivery) {
154
+ const key = `${tenant}/${workspace}`;
155
+ const now = Date.now();
156
+ const seen = refusalNotes.get(key);
157
+ if (seen && now - seen.at < REFUSAL_NOTE_MS) {
158
+ seen.since++;
159
+ return;
160
+ }
161
+ const more = seen?.since ? ` (and ${seen.since} more since the last note)` : "";
162
+ console.warn(`[hooks] refused a delivery to ${key}/${delivery.flow}: ${delivery.outcome}${more}`);
163
+ refusalNotes.set(key, { at: now, since: 0 });
164
+ if (refusalNotes.size > 10_000)
165
+ refusalNotes.clear(); // bounded: a note lost beats memory
166
+ }
135
167
  /** Fold a refusal into the log's last line when that line is a refusal
136
168
  * too. The flow name is not part of the match: it comes from the URL, so
137
- * a flood that varies it would otherwise never collapse. True when folded. */
169
+ * a flood that varies it would otherwise never collapse. True when folded.
170
+ * Rewritten in place, never through a rename (recordRefusal says why). */
138
171
  function collapseInto(file, delivery) {
139
172
  let text;
140
173
  try {
@@ -160,9 +193,16 @@ function collapseInto(file, delivery) {
160
193
  first: last.first ?? last.t,
161
194
  ...(last.detail === "more than one flow name" || last.flow !== delivery.flow ? { detail: "more than one flow name" } : {}),
162
195
  };
163
- const tmp = `${file}.tmp`;
164
- fs.writeFileSync(tmp, end.slice(0, cut) + JSON.stringify(merged) + "\n");
165
- fs.renameSync(tmp, file);
196
+ const line = Buffer.from(JSON.stringify(merged) + "\n");
197
+ const at = Buffer.byteLength(end.slice(0, cut));
198
+ const fd = fs.openSync(file, "r+");
199
+ try {
200
+ fs.writeSync(fd, line, 0, line.length, at);
201
+ fs.ftruncateSync(fd, at + line.length);
202
+ }
203
+ finally {
204
+ fs.closeSync(fd);
205
+ }
166
206
  return true;
167
207
  }
168
208
  export function readDeliveries(tenant, workspace, limit = 20) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "4.0.0",
3
+ "version": "6.0.0",
4
4
  "description": "Agents are just folders. The format, the runner, the checks.",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",
@@ -0,0 +1,70 @@
1
+ // Matching on the shared worker and web pod against tenant-written patterns.
2
+ //
3
+ // A regular expression or a glob a tenant writes runs here, not in their
4
+ // sandbox (an eval's `matches:`, a gate's `preview:`). A pattern that
5
+ // backtracks — `(a+)+$`, or `**a**a**a**b` turned into `.*a.*a.*a.*b` —
6
+ // holds the one event loop every account shares. So: globs match in linear
7
+ // time with no regex at all, and a regex runs under a time limit
8
+ // (29 Oct 2026, OWASP review).
9
+
10
+ import vm from "node:vm";
11
+
12
+ /**
13
+ * Does `text` match `pattern`? `*` matches within one path segment, `**`
14
+ * across segments; every other character is itself. O(pattern × text).
15
+ */
16
+ export function globMatch(pattern: string, text: string): boolean {
17
+ const toks: string[] = [];
18
+ for (let i = 0; i < pattern.length; ) {
19
+ if (pattern[i] === "*") {
20
+ if (pattern[i + 1] === "*") {
21
+ i += 2;
22
+ while (pattern[i] === "*") i++;
23
+ // "**" swallows a neighbouring "**" or "*": the same set either way.
24
+ if (toks[toks.length - 1] !== "**") toks.push("**");
25
+ } else {
26
+ i++;
27
+ if (toks[toks.length - 1] !== "**" && toks[toks.length - 1] !== "*") toks.push("*");
28
+ }
29
+ } else {
30
+ toks.push(pattern[i]);
31
+ i++;
32
+ }
33
+ }
34
+ const n = text.length;
35
+ let cur: boolean[] = new Array(n + 1).fill(false);
36
+ cur[0] = true;
37
+ for (const t of toks) {
38
+ const next: boolean[] = new Array(n + 1).fill(false);
39
+ if (t === "**") {
40
+ let on = false;
41
+ for (let j = 0; j <= n; j++) {
42
+ on = on || cur[j];
43
+ next[j] = on;
44
+ }
45
+ } else if (t === "*") {
46
+ let on = false;
47
+ for (let j = 0; j <= n; j++) {
48
+ if (cur[j]) on = true;
49
+ next[j] = on;
50
+ if (j < n && text[j] === "/") on = false;
51
+ }
52
+ } else {
53
+ for (let j = 0; j < n; j++) if (cur[j] && text[j] === t) next[j + 1] = true;
54
+ }
55
+ cur = next;
56
+ }
57
+ return cur[n];
58
+ }
59
+
60
+ /** A regex test that gives up after `ms`: true, false, or "timeout". Throws
61
+ * on a pattern that does not compile, as `new RegExp` would. */
62
+ export function boundedRegexTest(source: string, flags: string, text: string, ms = 250): boolean | "timeout" {
63
+ new RegExp(source, flags); // compile here: a syntax error is the caller's to report
64
+ try {
65
+ return vm.runInNewContext("new RegExp(p, f).test(t)", { p: source, f: flags, t: text }, { timeout: ms }) === true;
66
+ } catch (err) {
67
+ if ((err as { code?: string }).code === "ERR_SCRIPT_EXECUTION_TIMEOUT") return "timeout";
68
+ throw err;
69
+ }
70
+ }
package/src/deploy.ts CHANGED
@@ -80,6 +80,9 @@ const IN_WORKSPACE_DIR = new RegExp(`^(${WORKSPACE_DIRS.join("|")})/`);
80
80
  export function isSourcePath(rel: string): boolean {
81
81
  const norm = rel.replaceAll("\\", "/");
82
82
  const parts = norm.split("/");
83
+ // A path that climbs, is absolute or has an empty segment is never a
84
+ // workspace file — and `foldrun pull` writes what this accepts to disk.
85
+ if (norm.startsWith("/") || /^[a-zA-Z]:/.test(norm) || parts.some((p) => p === ".." || p === "." || p === "")) return false;
83
86
  if (parts.some((p) => NOT_SOURCE.has(p))) return false;
84
87
  if (NOT_SOURCE_FILE.has(parts[parts.length - 1])) return false;
85
88
  return norm === "AGENTS.md" || norm === "project.md" || IN_WORKSPACE_DIR.test(norm);
package/src/evals.ts CHANGED
@@ -31,7 +31,7 @@
31
31
  // they pass — no point paying a model to grade output already known to be
32
32
  // wrong.
33
33
 
34
- import { accountModelEnv, withModelEnv } from "./model-env.ts";
34
+ import { accountModelEnv, withModelEnv, ModelKeyError } from "./model-env.ts";
35
35
  import type { TranslatorSpec } from "./translator.ts";
36
36
  import { sdkEnv } from "./step-exec.ts";
37
37
  import { platformModelEnv } from "./runner.ts";
@@ -42,6 +42,8 @@ import { deployedCommit } from "./deploy.ts";
42
42
  import { latestRevisionId } from "./history.ts";
43
43
  import matter from "./matter.ts";
44
44
  import { spawn } from "node:child_process";
45
+ import { hostSafeEnv } from "./host-env.ts";
46
+ import { boundedRegexTest } from "./bounded-match.ts";
45
47
  import { judgeVerdict, JUDGE_VERDICT_INSTRUCTION, parseJudge } from "./judge-verdict.ts";
46
48
  import { readAgentsMdKeys } from "./agents-md.ts";
47
49
  import { query } from "@anthropic-ai/claude-agent-sdk";
@@ -596,7 +598,8 @@ async function waitForRun(tenant: string, workspace: string, runId: string) {
596
598
 
597
599
  function shell(command: string, cwd: string): Promise<{ code: number | null; out: string }> {
598
600
  return new Promise((resolve) => {
599
- const child = spawn("bash", ["-lc", command], { cwd });
601
+ // Never the platform's own environment: keys, database URL, tokens.
602
+ const child = spawn("bash", ["-lc", command], { cwd, env: hostSafeEnv() });
600
603
  let out = "";
601
604
  const append = (c: Buffer) => {
602
605
  if (out.length < 2000) out += c.toString();
@@ -707,7 +710,9 @@ export async function checkAssertion(
707
710
  }
708
711
  case "matches": {
709
712
  try {
710
- const passed = new RegExp(assertion.value, "i").test(output);
713
+ // Bounded: the pattern is the tenant's, the process is shared.
714
+ const passed = boundedRegexTest(assertion.value, "i", output);
715
+ if (passed === "timeout") return { assertion, passed: false, detail: "the pattern took too long to match — simplify it" };
711
716
  return { assertion, passed, detail: passed ? "matched" : "no match" };
712
717
  } catch {
713
718
  return { assertion, passed: false, detail: "invalid regular expression" };
@@ -715,13 +720,20 @@ export async function checkAssertion(
715
720
  }
716
721
  case "file": {
717
722
  const target = path.resolve(agentDir, assertion.value);
718
- if (!target.startsWith(path.resolve(agentDir))) {
723
+ if (!target.startsWith(path.resolve(agentDir) + path.sep)) {
719
724
  return { assertion, passed: false, detail: "path escapes the agent directory" };
720
725
  }
721
726
  const exists = fs.existsSync(target) && fs.statSync(target).size > 0;
722
727
  return { assertion, passed: exists, detail: exists ? "present and non-empty" : "missing or empty" };
723
728
  }
724
729
  case "run": {
730
+ // A `run:` is the tenant's own shell command. On an install that
731
+ // isolates runs it would execute here, on the web pod, beside the
732
+ // platform's keys and every account's files — so it is refused there,
733
+ // as a hook's Test is. A laptop runs every step in-process anyway.
734
+ if (process.env.FOLDRUN_RUN_ISOLATION) {
735
+ return { assertion, passed: false, detail: "run: assertions are not executed on this install — check the output with contains:/matches:/judge:, or a file with file:; `foldrun eval` runs them on your own machine" };
736
+ }
725
737
  const { code, out } = await shell(assertion.value, agentDir);
726
738
  return { assertion, passed: code === 0, detail: `exit ${code ?? "error"}${out ? ` — ${out.slice(0, 200)}` : ""}` };
727
739
  }
@@ -734,7 +746,14 @@ export async function checkAssertion(
734
746
  } catch (err) {
735
747
  return { assertion, passed: false, detail: `judge not run — ${err instanceof Error ? err.message : String(err)}` };
736
748
  }
737
- const said = await withModelEnv(m, (env) => judgeCall(assertion.value, output, model, effort, env));
749
+ let said: Awaited<ReturnType<typeof judgeCall>>;
750
+ try {
751
+ said = await withModelEnv(m, (env) => judgeCall(assertion.value, output, model, effort, env));
752
+ } catch (err) {
753
+ // The provider's URL refused on the host (FOL-95), like a missing key above.
754
+ if (!(err instanceof ModelKeyError)) throw err;
755
+ return { assertion, passed: false, detail: `judge not run — ${err.message}` };
756
+ }
738
757
  const { passed, detail } = judgeVerdict(said.text);
739
758
  return { assertion, passed, detail, costUsd: said.costUsd };
740
759
  }
package/src/mail.ts CHANGED
@@ -258,7 +258,45 @@ export async function mailWanted(tenant: string, email: string, category: MailCa
258
258
  * carries that reader's own unsubscribe token; required mail goes as one.
259
259
  * Never throws: a mail that fails is an `ok: false` with the reason.
260
260
  */
261
+ // The platform's own sender is shared by every account, so its reputation
262
+ // is everyone's. Mail an account can make the platform send to addresses it
263
+ // typed (invites, notify:, approvals, low balance) is capped per account per
264
+ // day on the platform's key; mail about the account's own sign-in, billing
265
+ // and health is not. An account sending on its own key is its own business.
266
+ // Counted in this process — a brake, not a ledger (29 Oct 2026: open signup
267
+ // on dev let anyone send ~86k invites a day from hello@foldrun.io).
268
+ const UNCAPPED = new Set<string>(["security", "billing", "account"]);
269
+ const PLATFORM_MAX_RECIPIENTS = 10;
270
+ const platformDayCap = (category: string) =>
271
+ Number(process.env[category === "invites" ? "FOLDRUN_PLATFORM_INVITES_PER_DAY" : "FOLDRUN_PLATFORM_MAIL_PER_DAY"]) ||
272
+ (category === "invites" ? 25 : 200);
273
+ const platformSent = new Map<string, number>();
274
+
275
+ /** Recipients this account may still send to today on the platform's key. */
276
+ export function platformMailAllowance(tenant: string, category: string, key: string, now = new Date()): number {
277
+ const platformKey = process.env.FOLDRUN_RESEND_API_KEY;
278
+ if (!platformKey || key !== platformKey || UNCAPPED.has(category)) return Infinity;
279
+ const used = platformSent.get(`${now.toISOString().slice(0, 10)}|${tenant}|${category === "invites" ? "invites" : "other"}`) ?? 0;
280
+ return Math.max(0, platformDayCap(category) - used);
281
+ }
282
+
283
+ function notePlatformSent(tenant: string, category: string, n: number, now = new Date()): void {
284
+ const day = now.toISOString().slice(0, 10);
285
+ for (const k of platformSent.keys()) if (!k.startsWith(day)) platformSent.delete(k);
286
+ const k = `${day}|${tenant}|${category === "invites" ? "invites" : "other"}`;
287
+ platformSent.set(k, (platformSent.get(k) ?? 0) + n);
288
+ }
289
+
261
290
  export async function sendMail(m: MailMessage): Promise<MailResult> {
291
+ const allowance = platformMailAllowance(m.tenant, m.category, m.mail.key);
292
+ if (allowance !== Infinity) {
293
+ const n = recipients(m.to).length;
294
+ if (n > PLATFORM_MAX_RECIPIENTS) {
295
+ return { ok: false, error: `more than ${PLATFORM_MAX_RECIPIENTS} recipients on the platform's sender — set the account's own RESEND_API_KEY and EMAIL_FROM to send more`, sent: [], suppressed: [] };
296
+ }
297
+ }
298
+ const capped = allowance !== Infinity;
299
+ const overCap = `this account reached today's limit for ${m.category} mail on the platform's sender — set its own RESEND_API_KEY and EMAIL_FROM, or try tomorrow`;
262
300
  const all = recipients(m.to);
263
301
  const required = isRequiredCategory(m.category);
264
302
  const workspace = m.workspace ?? null;
@@ -296,6 +334,8 @@ export async function sendMail(m: MailMessage): Promise<MailResult> {
296
334
  };
297
335
 
298
336
  if (required) {
337
+ if (capped && all.length > allowance) return { ok: false, error: overCap, sent: [], suppressed: [] };
338
+ if (capped) notePlatformSent(m.tenant, m.category, all.length);
299
339
  if (all.length) await post(all, {}, "");
300
340
  return { ok: all.length > 0 && sent.length === all.length, status, error, sent, suppressed };
301
341
  }
@@ -305,6 +345,15 @@ export async function sendMail(m: MailMessage): Promise<MailResult> {
305
345
  suppressed.push(to);
306
346
  continue;
307
347
  }
348
+ // Counted only for someone who takes this mail: an opted-out recipient
349
+ // must not use up the day's allowance for the ones who want it.
350
+ if (capped) {
351
+ if (platformMailAllowance(m.tenant, m.category, m.mail.key) < 1) {
352
+ error = overCap;
353
+ continue;
354
+ }
355
+ notePlatformSent(m.tenant, m.category, 1);
356
+ }
308
357
  const claim = { tenant: m.tenant, email: to, category: m.category, workspace: mailCategory(m.category)?.perWorkspace ? workspace : null };
309
358
  const links = unsubscribeLinks(claim);
310
359
  const label = mailCategory(m.category)?.label.toLowerCase() ?? m.category;
package/src/matter.ts CHANGED
@@ -79,8 +79,25 @@ function foldJobs(data: Record<string, unknown>): Record<string, unknown> {
79
79
  return out;
80
80
  }
81
81
 
82
+ // gray-matter ships a `javascript` engine that eval()s a block opened with
83
+ // `---js` or `---javascript` — with `process` and `require` in reach. Every
84
+ // file it parses here was written by a tenant (or an agent), on the shared
85
+ // web pod and worker. Only YAML is a language here; any other engine name
86
+ // throws, which the readers already treat as frontmatter that does not parse.
87
+ const refuse = (name: string) => () => {
88
+ throw new Error(`frontmatter opened with "---${name}" — only YAML frontmatter is read`);
89
+ };
90
+ const ENGINES = {
91
+ js: { parse: refuse("js"), stringify: refuse("js") },
92
+ javascript: { parse: refuse("javascript"), stringify: refuse("javascript") },
93
+ coffee: { parse: refuse("coffee"), stringify: refuse("coffee") },
94
+ coffeescript: { parse: refuse("coffeescript"), stringify: refuse("coffeescript") },
95
+ cson: { parse: refuse("cson"), stringify: refuse("cson") },
96
+ };
97
+ const safe = (options?: Options): Options => ({ ...(options ?? {}), engines: { ...(options?.engines ?? {}), ...ENGINES } as Options["engines"] });
98
+
82
99
  function matter(input: string, options?: Options) {
83
- return grayMatter(input, options ?? {});
100
+ return grayMatter(input, safe(options));
84
101
  }
85
102
  matter.stringify = grayMatter.stringify;
86
103
  matter.test = grayMatter.test;
@@ -93,7 +110,7 @@ matter.test = grayMatter.test;
93
110
  * note's own `governance:` is its own (Codex debate, 21 Oct 2026).
94
111
  */
95
112
  export function configMatter(input: string, options?: Options) {
96
- const file = grayMatter(input, options ?? {});
113
+ const file = grayMatter(input, safe(options));
97
114
  if (file.data && typeof file.data === "object" && !Array.isArray(file.data)) {
98
115
  (file as { data: Record<string, unknown> }).data = foldJobs(file.data as Record<string, unknown>);
99
116
  }
package/src/model-env.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  import { accountDir, workspaceDir, parseProvider, providerEnvFor } from "./store.ts";
10
10
  import { readAgentsMd } from "./runner.ts";
11
11
  import { getSecret } from "./secrets.ts";
12
- import { platform } from "./platform.ts";
12
+ import { platform, hostUrlProblem } from "./platform.ts";
13
13
  import { accountModelBlock, isClaudeLoginToken } from "./model-credential.ts";
14
14
  import { providerPreset } from "./providers.ts";
15
15
  import { startTranslator, translatorSpecFor, type TranslatorSpec } from "./translator.ts";
@@ -53,6 +53,12 @@ export function accountModelEnv(
53
53
  if (!platform.platformKeyAllowed(tenant)) throw new ModelKeyError(NO_MODEL_KEY);
54
54
  return { env: platformEnv(), supply: "platform", format: "anthropic", translator: null };
55
55
  }
56
+ // A host-side call to a URL the tenant wrote: on a platform, https only
57
+ // (platform.ts hostUrlProblem); withModelEnv asks the platform's own check
58
+ // too, and the guard sees every byte (FOL-95). A step resolves its
59
+ // provider elsewhere (runner.ts) and is not touched by this.
60
+ const refused = hostUrlProblem(spec.baseUrl);
61
+ if (refused) throw new ModelKeyError(`provider base_url refused: ${refused}`, 422);
56
62
  const sub = (text: string) =>
57
63
  text.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => getSecret(tenant, name, workspace)?.value ?? whole);
58
64
  const token = sub(spec.token);
@@ -80,19 +86,48 @@ export function accountModelEnv(
80
86
  }
81
87
 
82
88
  /**
83
- * Run `fn` with the model env, through a translator on this host when the
84
- * provider speaks Chat Completions or Responses — the same one a step uses
85
- * (runner.ts), started for this call and closed after it.
89
+ * Run `fn` with the model env, through a translator on this host — the one a
90
+ * step uses (runner.ts) when the provider speaks Chat Completions or
91
+ * Responses, and a passthrough when it speaks Anthropic's Messages (FOL-95):
92
+ * either way the SDK talks only to loopback, and the tenant's endpoint is
93
+ * reached through fetchUntrusted, after the platform's check of its URL.
94
+ * The platform's own credential (`supply: "platform"`) goes as it always did.
95
+ * Started for this call and closed after it.
86
96
  */
87
97
  export async function withModelEnv<T>(
88
- m: { env: Record<string, string | undefined>; translator?: TranslatorSpec | null },
98
+ m: { env: Record<string, string | undefined>; translator?: TranslatorSpec | null; supply?: "provider" | "platform" },
89
99
  fn: (env: Record<string, string | undefined>) => Promise<T>,
90
100
  ): Promise<T> {
91
- if (!m.translator) return fn(m.env);
92
- const t = await startTranslator(m.translator);
101
+ const spec = m.translator ?? (m.supply === "provider" ? anthropicPassthrough(m.env) : null);
102
+ if (!spec) return fn(m.env);
103
+ const why = await platform.checkUntrustedUrl(spec.upstreamBase);
104
+ if (why) throw new ModelKeyError(`provider base_url refused: ${why}`, 422);
105
+ const t = await startTranslator(spec);
93
106
  try {
94
107
  return await fn({ ...m.env, ...t.env });
95
108
  } finally {
96
109
  await t.close();
97
110
  }
98
111
  }
112
+
113
+ /** The passthrough for an Anthropic-format provider, read back from the env
114
+ * providerEnvFor (store.ts) built for it: the base URL, the key in the slot
115
+ * that names its header, and ANTHROPIC_CUSTOM_HEADERS. Null with no base. */
116
+ export function anthropicPassthrough(env: Record<string, string | undefined>): TranslatorSpec | null {
117
+ const base = env.ANTHROPIC_BASE_URL;
118
+ if (!base) return null;
119
+ const apiKey = env.ANTHROPIC_API_KEY ?? "";
120
+ const headers: Record<string, string> = {};
121
+ for (const line of (env.ANTHROPIC_CUSTOM_HEADERS ?? "").split("\n")) {
122
+ const at = line.indexOf(":");
123
+ if (at > 0) headers[line.slice(0, at).trim()] = line.slice(at + 1).trim();
124
+ }
125
+ return {
126
+ wire: "anthropic",
127
+ upstreamBase: base,
128
+ upstreamKey: apiKey || (env.ANTHROPIC_AUTH_TOKEN ?? ""),
129
+ upstreamAuth: apiKey ? "x-api-key" : "bearer",
130
+ headers,
131
+ label: base,
132
+ };
133
+ }
package/src/platform.ts CHANGED
@@ -177,6 +177,16 @@ export interface PlatformHooks {
177
177
  * caps it again, so a laptop's plain fetch may ignore it).
178
178
  */
179
179
  fetchUntrusted(url: string, init?: RequestInit, opts?: { maxBytes?: number }): Promise<Response>;
180
+ /**
181
+ * May the host call this URL a tenant chose — a provider's base_url,
182
+ * before an eval's judge or a dashboard draft talks to it? A reason it may
183
+ * not, or null. This is the early refusal people can read; the guarantee
184
+ * is that every byte of such a call goes through fetchUntrusted (FOL-95).
185
+ * Default: hostUrlProblem below — https on an install that guards
186
+ * untrusted fetches, anything on a laptop (its own localhost included).
187
+ * A platform may bind a stricter check (the addresses a name resolves to).
188
+ */
189
+ checkUntrustedUrl(url: string): Promise<string | null>;
180
190
  /**
181
191
  * Hand a notify: webhook to whatever delivers it. The platform records
182
192
  * the delivery, makes the first attempt now and retries on a backoff from
@@ -210,6 +220,11 @@ export interface WebhookDeliveryOutcome {
210
220
  retrying?: boolean;
211
221
  }
212
222
 
223
+ /** Marks the laptop's fetchUntrusted. A symbol from the global registry, not
224
+ * the function itself: every bundle has its own copy of this module (see
225
+ * KEY below), and only the shared object says which one was registered. */
226
+ const LAPTOP_FETCH = Symbol.for("foldrun.laptop-fetch");
227
+
213
228
  const local: PlatformHooks = {
214
229
  edition: () => "self-hosted",
215
230
  async enqueueFlowRun(tenant, workspace, steps, flowName, modelOverride, tags = [], startedBy = null, opts = {}) {
@@ -237,7 +252,8 @@ const local: PlatformHooks = {
237
252
  platformKeyAllowed: () => true,
238
253
  galleryDir: () => null,
239
254
  noteOAuthRefresh: async () => undefined,
240
- fetchUntrusted: (url, init) => fetch(url, init),
255
+ fetchUntrusted: Object.assign((url: string, init?: RequestInit) => fetch(url, init), { [LAPTOP_FETCH]: true }),
256
+ checkUntrustedUrl: async (url) => hostUrlProblem(url),
241
257
  async deliverWebhook(tenant, workspace, delivery) {
242
258
  const { attemptWebhook, newDeliveryId } = await import("./notify.ts");
243
259
  const deliveryId = newDeliveryId();
@@ -259,6 +275,34 @@ const KEY = Symbol.for("foldrun.platform");
259
275
  const g = globalThis as unknown as Record<symbol, PlatformHooks | undefined>;
260
276
  export const platform: PlatformHooks = (g[KEY] ??= { ...local });
261
277
 
278
+ /** True when this process fetches tenant-chosen URLs through a guard: a
279
+ * platform bound fetchUntrusted. A laptop has not, and calls its own LAN on
280
+ * purpose. */
281
+ export function guardsUntrustedUrls(): boolean {
282
+ return !(platform.fetchUntrusted as unknown as Record<symbol, unknown>)[LAPTOP_FETCH];
283
+ }
284
+
285
+ /**
286
+ * Why the host may not call a tenant's model endpoint at this URL, or null.
287
+ * On an install that guards untrusted fetches the endpoint must be https: the
288
+ * key rides in every request, and a plain-http base is also how a tenant
289
+ * names a cluster service. Private and internal addresses are refused by the
290
+ * guard itself, on every hop. FOLDRUN_EGRESS_ALLOW_PRIVATE=1 — the operator's
291
+ * own install calling its own network — lifts this as it lifts the guard. A
292
+ * laptop: always null.
293
+ */
294
+ export function hostUrlProblem(url: string): string | null {
295
+ if (!guardsUntrustedUrls() || process.env.FOLDRUN_EGRESS_ALLOW_PRIVATE === "1") return null;
296
+ let u: URL;
297
+ try {
298
+ u = new URL(url);
299
+ } catch {
300
+ return `${url} is not a URL`;
301
+ }
302
+ if (u.protocol !== "https:") return `${u.origin} is not https — this platform calls a model endpoint only over https`;
303
+ return null;
304
+ }
305
+
262
306
  /** Install the platform's implementations. Partial: what is not given keeps its local default. */
263
307
  export function registerPlatform(hooks: Partial<PlatformHooks>): void {
264
308
  Object.assign(platform, hooks, {
package/src/runner.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // fail without failing the flow. A direct agent run is a one-step ad-hoc
4
4
  // flow, so there is a single execution path.
5
5
 
6
+ import { globMatch } from "./bounded-match.ts";
6
7
  import { latestRevisionId } from "./history.ts";
7
8
  import { recordCallIo, redactionsFor } from "./capture.ts";
8
9
  import { AUTH_REFUSAL, platformCause } from "./issues.ts";
@@ -4308,15 +4309,16 @@ export function startFlowRun(
4308
4309
  export function resolvePreview(storageDir: string, patterns: string[], cap = 12): string[] {
4309
4310
  const out = new Set<string>();
4310
4311
  for (const pattern of patterns) {
4312
+ // Inside storage/ only: no climbing, no absolute paths.
4313
+ const parts = pattern.replaceAll("\\", "/").split("/");
4314
+ if (pattern.startsWith("/") || parts.includes("..") || pattern.length > 400) continue;
4311
4315
  if (!/[*?]/.test(pattern)) {
4312
4316
  if (fs.existsSync(path.join(storageDir, pattern))) out.add(pattern);
4313
4317
  continue;
4314
4318
  }
4315
- const re = new RegExp(
4316
- "^" + pattern
4317
- .split("**").map((part) => part.split("*").map((x) => x.replace(/[.+^${}()|[\]\\?]/g, "\\$&")).join("[^/]*"))
4318
- .join(".*") + "$",
4319
- );
4319
+ // Matched in linear time: this runs on the worker every account shares,
4320
+ // and a regex built from "**a**a**a**b" backtracks for seconds.
4321
+ const re = { test: (r: string) => globMatch(pattern, r) };
4320
4322
  const walk = (dir: string, rel: string) => {
4321
4323
  let entries: fs.Dirent[];
4322
4324
  try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }