@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.
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Does `text` match `pattern`? `*` matches within one path segment, `**`
3
+ * across segments; every other character is itself. O(pattern × text).
4
+ */
5
+ export declare function globMatch(pattern: string, text: string): boolean;
6
+ /** A regex test that gives up after `ms`: true, false, or "timeout". Throws
7
+ * on a pattern that does not compile, as `new RegExp` would. */
8
+ export declare function boundedRegexTest(source: string, flags: string, text: string, ms?: number): boolean | "timeout";
@@ -0,0 +1,80 @@
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
+ import vm from "node:vm";
10
+ /**
11
+ * Does `text` match `pattern`? `*` matches within one path segment, `**`
12
+ * across segments; every other character is itself. O(pattern × text).
13
+ */
14
+ export function globMatch(pattern, text) {
15
+ const toks = [];
16
+ for (let i = 0; i < pattern.length;) {
17
+ if (pattern[i] === "*") {
18
+ if (pattern[i + 1] === "*") {
19
+ i += 2;
20
+ while (pattern[i] === "*")
21
+ i++;
22
+ // "**" swallows a neighbouring "**" or "*": the same set either way.
23
+ if (toks[toks.length - 1] !== "**")
24
+ toks.push("**");
25
+ }
26
+ else {
27
+ i++;
28
+ if (toks[toks.length - 1] !== "**" && toks[toks.length - 1] !== "*")
29
+ toks.push("*");
30
+ }
31
+ }
32
+ else {
33
+ toks.push(pattern[i]);
34
+ i++;
35
+ }
36
+ }
37
+ const n = text.length;
38
+ let cur = new Array(n + 1).fill(false);
39
+ cur[0] = true;
40
+ for (const t of toks) {
41
+ const next = new Array(n + 1).fill(false);
42
+ if (t === "**") {
43
+ let on = false;
44
+ for (let j = 0; j <= n; j++) {
45
+ on = on || cur[j];
46
+ next[j] = on;
47
+ }
48
+ }
49
+ else if (t === "*") {
50
+ let on = false;
51
+ for (let j = 0; j <= n; j++) {
52
+ if (cur[j])
53
+ on = true;
54
+ next[j] = on;
55
+ if (j < n && text[j] === "/")
56
+ on = false;
57
+ }
58
+ }
59
+ else {
60
+ for (let j = 0; j < n; j++)
61
+ if (cur[j] && text[j] === t)
62
+ next[j + 1] = true;
63
+ }
64
+ cur = next;
65
+ }
66
+ return cur[n];
67
+ }
68
+ /** A regex test that gives up after `ms`: true, false, or "timeout". Throws
69
+ * on a pattern that does not compile, as `new RegExp` would. */
70
+ export function boundedRegexTest(source, flags, text, ms = 250) {
71
+ new RegExp(source, flags); // compile here: a syntax error is the caller's to report
72
+ try {
73
+ return vm.runInNewContext("new RegExp(p, f).test(t)", { p: source, f: flags, t: text }, { timeout: ms }) === true;
74
+ }
75
+ catch (err) {
76
+ if (err.code === "ERR_SCRIPT_EXECUTION_TIMEOUT")
77
+ return "timeout";
78
+ throw err;
79
+ }
80
+ }
@@ -61,6 +61,10 @@ const IN_WORKSPACE_DIR = new RegExp(`^(${WORKSPACE_DIRS.join("|")})/`);
61
61
  export function isSourcePath(rel) {
62
62
  const norm = rel.replaceAll("\\", "/");
63
63
  const parts = norm.split("/");
64
+ // A path that climbs, is absolute or has an empty segment is never a
65
+ // workspace file — and `foldrun pull` writes what this accepts to disk.
66
+ if (norm.startsWith("/") || /^[a-zA-Z]:/.test(norm) || parts.some((p) => p === ".." || p === "." || p === ""))
67
+ return false;
64
68
  if (parts.some((p) => NOT_SOURCE.has(p)))
65
69
  return false;
66
70
  if (NOT_SOURCE_FILE.has(parts[parts.length - 1]))
package/dist/src/evals.js CHANGED
@@ -30,7 +30,7 @@
30
30
  // Deterministic checks run first and cost nothing. The judge only runs if
31
31
  // they pass — no point paying a model to grade output already known to be
32
32
  // wrong.
33
- import { accountModelEnv, withModelEnv } from "./model-env.js";
33
+ import { accountModelEnv, withModelEnv, ModelKeyError } from "./model-env.js";
34
34
  import { sdkEnv } from "./step-exec.js";
35
35
  import { platformModelEnv } from "./runner.js";
36
36
  import fs from "node:fs";
@@ -40,6 +40,8 @@ import { deployedCommit } from "./deploy.js";
40
40
  import { latestRevisionId } from "./history.js";
41
41
  import matter from "./matter.js";
42
42
  import { spawn } from "node:child_process";
43
+ import { hostSafeEnv } from "./host-env.js";
44
+ import { boundedRegexTest } from "./bounded-match.js";
43
45
  import { judgeVerdict, JUDGE_VERDICT_INSTRUCTION, parseJudge } from "./judge-verdict.js";
44
46
  import { readAgentsMdKeys } from "./agents-md.js";
45
47
  import { query } from "@anthropic-ai/claude-agent-sdk";
@@ -443,7 +445,8 @@ async function waitForRun(tenant, workspace, runId) {
443
445
  }
444
446
  function shell(command, cwd) {
445
447
  return new Promise((resolve) => {
446
- const child = spawn("bash", ["-lc", command], { cwd });
448
+ // Never the platform's own environment: keys, database URL, tokens.
449
+ const child = spawn("bash", ["-lc", command], { cwd, env: hostSafeEnv() });
447
450
  let out = "";
448
451
  const append = (c) => {
449
452
  if (out.length < 2000)
@@ -540,7 +543,10 @@ export async function checkAssertion(assertion, output, agentDir, model, effort,
540
543
  }
541
544
  case "matches": {
542
545
  try {
543
- const passed = new RegExp(assertion.value, "i").test(output);
546
+ // Bounded: the pattern is the tenant's, the process is shared.
547
+ const passed = boundedRegexTest(assertion.value, "i", output);
548
+ if (passed === "timeout")
549
+ return { assertion, passed: false, detail: "the pattern took too long to match — simplify it" };
544
550
  return { assertion, passed, detail: passed ? "matched" : "no match" };
545
551
  }
546
552
  catch {
@@ -549,13 +555,20 @@ export async function checkAssertion(assertion, output, agentDir, model, effort,
549
555
  }
550
556
  case "file": {
551
557
  const target = path.resolve(agentDir, assertion.value);
552
- if (!target.startsWith(path.resolve(agentDir))) {
558
+ if (!target.startsWith(path.resolve(agentDir) + path.sep)) {
553
559
  return { assertion, passed: false, detail: "path escapes the agent directory" };
554
560
  }
555
561
  const exists = fs.existsSync(target) && fs.statSync(target).size > 0;
556
562
  return { assertion, passed: exists, detail: exists ? "present and non-empty" : "missing or empty" };
557
563
  }
558
564
  case "run": {
565
+ // A `run:` is the tenant's own shell command. On an install that
566
+ // isolates runs it would execute here, on the web pod, beside the
567
+ // platform's keys and every account's files — so it is refused there,
568
+ // as a hook's Test is. A laptop runs every step in-process anyway.
569
+ if (process.env.FOLDRUN_RUN_ISOLATION) {
570
+ 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" };
571
+ }
559
572
  const { code, out } = await shell(assertion.value, agentDir);
560
573
  return { assertion, passed: code === 0, detail: `exit ${code ?? "error"}${out ? ` — ${out.slice(0, 200)}` : ""}` };
561
574
  }
@@ -569,7 +582,16 @@ export async function checkAssertion(assertion, output, agentDir, model, effort,
569
582
  catch (err) {
570
583
  return { assertion, passed: false, detail: `judge not run — ${err instanceof Error ? err.message : String(err)}` };
571
584
  }
572
- const said = await withModelEnv(m, (env) => judgeCall(assertion.value, output, model, effort, env));
585
+ let said;
586
+ try {
587
+ said = await withModelEnv(m, (env) => judgeCall(assertion.value, output, model, effort, env));
588
+ }
589
+ catch (err) {
590
+ // The provider's URL refused on the host (FOL-95), like a missing key above.
591
+ if (!(err instanceof ModelKeyError))
592
+ throw err;
593
+ return { assertion, passed: false, detail: `judge not run — ${err.message}` };
594
+ }
573
595
  const { passed, detail } = judgeVerdict(said.text);
574
596
  return { assertion, passed, detail, costUsd: said.costUsd };
575
597
  }
@@ -79,10 +79,6 @@ export interface MailResult {
79
79
  }
80
80
  /** Does this person take this category of mail? Required mail: always. */
81
81
  export declare function mailWanted(tenant: string, email: string, category: MailCategory, workspace?: string | null): Promise<boolean>;
82
- /**
83
- * Send one message, honouring each recipient's preference for its
84
- * category. Optional mail is sent one recipient at a time so each copy
85
- * carries that reader's own unsubscribe token; required mail goes as one.
86
- * Never throws: a mail that fails is an `ok: false` with the reason.
87
- */
82
+ /** Recipients this account may still send to today on the platform's key. */
83
+ export declare function platformMailAllowance(tenant: string, category: string, key: string, now?: Date): number;
88
84
  export declare function sendMail(m: MailMessage): Promise<MailResult>;
package/dist/src/mail.js CHANGED
@@ -188,7 +188,44 @@ export async function mailWanted(tenant, email, category, workspace = null) {
188
188
  * carries that reader's own unsubscribe token; required mail goes as one.
189
189
  * Never throws: a mail that fails is an `ok: false` with the reason.
190
190
  */
191
+ // The platform's own sender is shared by every account, so its reputation
192
+ // is everyone's. Mail an account can make the platform send to addresses it
193
+ // typed (invites, notify:, approvals, low balance) is capped per account per
194
+ // day on the platform's key; mail about the account's own sign-in, billing
195
+ // and health is not. An account sending on its own key is its own business.
196
+ // Counted in this process — a brake, not a ledger (29 Oct 2026: open signup
197
+ // on dev let anyone send ~86k invites a day from hello@foldrun.io).
198
+ const UNCAPPED = new Set(["security", "billing", "account"]);
199
+ const PLATFORM_MAX_RECIPIENTS = 10;
200
+ const platformDayCap = (category) => Number(process.env[category === "invites" ? "FOLDRUN_PLATFORM_INVITES_PER_DAY" : "FOLDRUN_PLATFORM_MAIL_PER_DAY"]) ||
201
+ (category === "invites" ? 25 : 200);
202
+ const platformSent = new Map();
203
+ /** Recipients this account may still send to today on the platform's key. */
204
+ export function platformMailAllowance(tenant, category, key, now = new Date()) {
205
+ const platformKey = process.env.FOLDRUN_RESEND_API_KEY;
206
+ if (!platformKey || key !== platformKey || UNCAPPED.has(category))
207
+ return Infinity;
208
+ const used = platformSent.get(`${now.toISOString().slice(0, 10)}|${tenant}|${category === "invites" ? "invites" : "other"}`) ?? 0;
209
+ return Math.max(0, platformDayCap(category) - used);
210
+ }
211
+ function notePlatformSent(tenant, category, n, now = new Date()) {
212
+ const day = now.toISOString().slice(0, 10);
213
+ for (const k of platformSent.keys())
214
+ if (!k.startsWith(day))
215
+ platformSent.delete(k);
216
+ const k = `${day}|${tenant}|${category === "invites" ? "invites" : "other"}`;
217
+ platformSent.set(k, (platformSent.get(k) ?? 0) + n);
218
+ }
191
219
  export async function sendMail(m) {
220
+ const allowance = platformMailAllowance(m.tenant, m.category, m.mail.key);
221
+ if (allowance !== Infinity) {
222
+ const n = recipients(m.to).length;
223
+ if (n > PLATFORM_MAX_RECIPIENTS) {
224
+ 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: [] };
225
+ }
226
+ }
227
+ const capped = allowance !== Infinity;
228
+ 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`;
192
229
  const all = recipients(m.to);
193
230
  const required = isRequiredCategory(m.category);
194
231
  const workspace = m.workspace ?? null;
@@ -225,6 +262,10 @@ export async function sendMail(m) {
225
262
  }
226
263
  };
227
264
  if (required) {
265
+ if (capped && all.length > allowance)
266
+ return { ok: false, error: overCap, sent: [], suppressed: [] };
267
+ if (capped)
268
+ notePlatformSent(m.tenant, m.category, all.length);
228
269
  if (all.length)
229
270
  await post(all, {}, "");
230
271
  return { ok: all.length > 0 && sent.length === all.length, status, error, sent, suppressed };
@@ -234,6 +275,15 @@ export async function sendMail(m) {
234
275
  suppressed.push(to);
235
276
  continue;
236
277
  }
278
+ // Counted only for someone who takes this mail: an opted-out recipient
279
+ // must not use up the day's allowance for the ones who want it.
280
+ if (capped) {
281
+ if (platformMailAllowance(m.tenant, m.category, m.mail.key) < 1) {
282
+ error = overCap;
283
+ continue;
284
+ }
285
+ notePlatformSent(m.tenant, m.category, 1);
286
+ }
237
287
  const claim = { tenant: m.tenant, email: to, category: m.category, workspace: mailCategory(m.category)?.perWorkspace ? workspace : null };
238
288
  const links = unsubscribeLinks(claim);
239
289
  const label = mailCategory(m.category)?.label.toLowerCase() ?? m.category;
@@ -72,8 +72,24 @@ function foldJobs(data) {
72
72
  Object.defineProperty(out, FOLD, { value: fold, enumerable: false });
73
73
  return out;
74
74
  }
75
+ // gray-matter ships a `javascript` engine that eval()s a block opened with
76
+ // `---js` or `---javascript` — with `process` and `require` in reach. Every
77
+ // file it parses here was written by a tenant (or an agent), on the shared
78
+ // web pod and worker. Only YAML is a language here; any other engine name
79
+ // throws, which the readers already treat as frontmatter that does not parse.
80
+ const refuse = (name) => () => {
81
+ throw new Error(`frontmatter opened with "---${name}" — only YAML frontmatter is read`);
82
+ };
83
+ const ENGINES = {
84
+ js: { parse: refuse("js"), stringify: refuse("js") },
85
+ javascript: { parse: refuse("javascript"), stringify: refuse("javascript") },
86
+ coffee: { parse: refuse("coffee"), stringify: refuse("coffee") },
87
+ coffeescript: { parse: refuse("coffeescript"), stringify: refuse("coffeescript") },
88
+ cson: { parse: refuse("cson"), stringify: refuse("cson") },
89
+ };
90
+ const safe = (options) => ({ ...(options ?? {}), engines: { ...(options?.engines ?? {}), ...ENGINES } });
75
91
  function matter(input, options) {
76
- return grayMatter(input, options ?? {});
92
+ return grayMatter(input, safe(options));
77
93
  }
78
94
  matter.stringify = grayMatter.stringify;
79
95
  matter.test = grayMatter.test;
@@ -85,7 +101,7 @@ matter.test = grayMatter.test;
85
101
  * note's own `governance:` is its own (Codex debate, 21 Oct 2026).
86
102
  */
87
103
  export function configMatter(input, options) {
88
- const file = grayMatter(input, options ?? {});
104
+ const file = grayMatter(input, safe(options));
89
105
  if (file.data && typeof file.data === "object" && !Array.isArray(file.data)) {
90
106
  file.data = foldJobs(file.data);
91
107
  }
@@ -19,11 +19,20 @@ export declare function accountModelEnv(tenant: string, workspace: string, platf
19
19
  translator: TranslatorSpec | null;
20
20
  };
21
21
  /**
22
- * Run `fn` with the model env, through a translator on this host when the
23
- * provider speaks Chat Completions or Responses — the same one a step uses
24
- * (runner.ts), started for this call and closed after it.
22
+ * Run `fn` with the model env, through a translator on this host — the one a
23
+ * step uses (runner.ts) when the provider speaks Chat Completions or
24
+ * Responses, and a passthrough when it speaks Anthropic's Messages (FOL-95):
25
+ * either way the SDK talks only to loopback, and the tenant's endpoint is
26
+ * reached through fetchUntrusted, after the platform's check of its URL.
27
+ * The platform's own credential (`supply: "platform"`) goes as it always did.
28
+ * Started for this call and closed after it.
25
29
  */
26
30
  export declare function withModelEnv<T>(m: {
27
31
  env: Record<string, string | undefined>;
28
32
  translator?: TranslatorSpec | null;
33
+ supply?: "provider" | "platform";
29
34
  }, fn: (env: Record<string, string | undefined>) => Promise<T>): Promise<T>;
35
+ /** The passthrough for an Anthropic-format provider, read back from the env
36
+ * providerEnvFor (store.ts) built for it: the base URL, the key in the slot
37
+ * that names its header, and ANTHROPIC_CUSTOM_HEADERS. Null with no base. */
38
+ export declare function anthropicPassthrough(env: Record<string, string | undefined>): TranslatorSpec | null;
@@ -8,7 +8,7 @@
8
8
  import { accountDir, workspaceDir, parseProvider, providerEnvFor } from "./store.js";
9
9
  import { readAgentsMd } from "./runner.js";
10
10
  import { getSecret } from "./secrets.js";
11
- import { platform } from "./platform.js";
11
+ import { platform, hostUrlProblem } from "./platform.js";
12
12
  import { accountModelBlock, isClaudeLoginToken } from "./model-credential.js";
13
13
  import { providerPreset } from "./providers.js";
14
14
  import { startTranslator, translatorSpecFor } from "./translator.js";
@@ -39,6 +39,13 @@ export function accountModelEnv(tenant, workspace, platformEnv) {
39
39
  throw new ModelKeyError(NO_MODEL_KEY);
40
40
  return { env: platformEnv(), supply: "platform", format: "anthropic", translator: null };
41
41
  }
42
+ // A host-side call to a URL the tenant wrote: on a platform, https only
43
+ // (platform.ts hostUrlProblem); withModelEnv asks the platform's own check
44
+ // too, and the guard sees every byte (FOL-95). A step resolves its
45
+ // provider elsewhere (runner.ts) and is not touched by this.
46
+ const refused = hostUrlProblem(spec.baseUrl);
47
+ if (refused)
48
+ throw new ModelKeyError(`provider base_url refused: ${refused}`, 422);
42
49
  const sub = (text) => text.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => getSecret(tenant, name, workspace)?.value ?? whole);
43
50
  const token = sub(spec.token);
44
51
  if (token.includes("${"))
@@ -65,14 +72,22 @@ export function accountModelEnv(tenant, workspace, platformEnv) {
65
72
  };
66
73
  }
67
74
  /**
68
- * Run `fn` with the model env, through a translator on this host when the
69
- * provider speaks Chat Completions or Responses — the same one a step uses
70
- * (runner.ts), started for this call and closed after it.
75
+ * Run `fn` with the model env, through a translator on this host — the one a
76
+ * step uses (runner.ts) when the provider speaks Chat Completions or
77
+ * Responses, and a passthrough when it speaks Anthropic's Messages (FOL-95):
78
+ * either way the SDK talks only to loopback, and the tenant's endpoint is
79
+ * reached through fetchUntrusted, after the platform's check of its URL.
80
+ * The platform's own credential (`supply: "platform"`) goes as it always did.
81
+ * Started for this call and closed after it.
71
82
  */
72
83
  export async function withModelEnv(m, fn) {
73
- if (!m.translator)
84
+ const spec = m.translator ?? (m.supply === "provider" ? anthropicPassthrough(m.env) : null);
85
+ if (!spec)
74
86
  return fn(m.env);
75
- const t = await startTranslator(m.translator);
87
+ const why = await platform.checkUntrustedUrl(spec.upstreamBase);
88
+ if (why)
89
+ throw new ModelKeyError(`provider base_url refused: ${why}`, 422);
90
+ const t = await startTranslator(spec);
76
91
  try {
77
92
  return await fn({ ...m.env, ...t.env });
78
93
  }
@@ -80,3 +95,26 @@ export async function withModelEnv(m, fn) {
80
95
  await t.close();
81
96
  }
82
97
  }
98
+ /** The passthrough for an Anthropic-format provider, read back from the env
99
+ * providerEnvFor (store.ts) built for it: the base URL, the key in the slot
100
+ * that names its header, and ANTHROPIC_CUSTOM_HEADERS. Null with no base. */
101
+ export function anthropicPassthrough(env) {
102
+ const base = env.ANTHROPIC_BASE_URL;
103
+ if (!base)
104
+ return null;
105
+ const apiKey = env.ANTHROPIC_API_KEY ?? "";
106
+ const headers = {};
107
+ for (const line of (env.ANTHROPIC_CUSTOM_HEADERS ?? "").split("\n")) {
108
+ const at = line.indexOf(":");
109
+ if (at > 0)
110
+ headers[line.slice(0, at).trim()] = line.slice(at + 1).trim();
111
+ }
112
+ return {
113
+ wire: "anthropic",
114
+ upstreamBase: base,
115
+ upstreamKey: apiKey || (env.ANTHROPIC_AUTH_TOKEN ?? ""),
116
+ upstreamAuth: apiKey ? "x-api-key" : "bearer",
117
+ headers,
118
+ label: base,
119
+ };
120
+ }
@@ -175,6 +175,16 @@ export interface PlatformHooks {
175
175
  fetchUntrusted(url: string, init?: RequestInit, opts?: {
176
176
  maxBytes?: number;
177
177
  }): Promise<Response>;
178
+ /**
179
+ * May the host call this URL a tenant chose — a provider's base_url,
180
+ * before an eval's judge or a dashboard draft talks to it? A reason it may
181
+ * not, or null. This is the early refusal people can read; the guarantee
182
+ * is that every byte of such a call goes through fetchUntrusted (FOL-95).
183
+ * Default: hostUrlProblem below — https on an install that guards
184
+ * untrusted fetches, anything on a laptop (its own localhost included).
185
+ * A platform may bind a stricter check (the addresses a name resolves to).
186
+ */
187
+ checkUntrustedUrl(url: string): Promise<string | null>;
178
188
  /**
179
189
  * Hand a notify: webhook to whatever delivers it. The platform records
180
190
  * the delivery, makes the first attempt now and retries on a backoff from
@@ -207,6 +217,20 @@ export interface WebhookDeliveryOutcome {
207
217
  retrying?: boolean;
208
218
  }
209
219
  export declare const platform: PlatformHooks;
220
+ /** True when this process fetches tenant-chosen URLs through a guard: a
221
+ * platform bound fetchUntrusted. A laptop has not, and calls its own LAN on
222
+ * purpose. */
223
+ export declare function guardsUntrustedUrls(): boolean;
224
+ /**
225
+ * Why the host may not call a tenant's model endpoint at this URL, or null.
226
+ * On an install that guards untrusted fetches the endpoint must be https: the
227
+ * key rides in every request, and a plain-http base is also how a tenant
228
+ * names a cluster service. Private and internal addresses are refused by the
229
+ * guard itself, on every hop. FOLDRUN_EGRESS_ALLOW_PRIVATE=1 — the operator's
230
+ * own install calling its own network — lifts this as it lifts the guard. A
231
+ * laptop: always null.
232
+ */
233
+ export declare function hostUrlProblem(url: string): string | null;
210
234
  /** Install the platform's implementations. Partial: what is not given keeps its local default. */
211
235
  export declare function registerPlatform(hooks: Partial<PlatformHooks>): void;
212
236
  /**
@@ -12,6 +12,10 @@
12
12
  // and calls registerPlatform() once at boot. A process that forgets to is a
13
13
  // local install — which is exactly the failure that is safe.
14
14
  import { matchFilter } from "./trigger-filter.js";
15
+ /** Marks the laptop's fetchUntrusted. A symbol from the global registry, not
16
+ * the function itself: every bundle has its own copy of this module (see
17
+ * KEY below), and only the shared object says which one was registered. */
18
+ const LAPTOP_FETCH = Symbol.for("foldrun.laptop-fetch");
15
19
  const local = {
16
20
  edition: () => "self-hosted",
17
21
  async enqueueFlowRun(tenant, workspace, steps, flowName, modelOverride, tags = [], startedBy = null, opts = {}) {
@@ -40,7 +44,8 @@ const local = {
40
44
  platformKeyAllowed: () => true,
41
45
  galleryDir: () => null,
42
46
  noteOAuthRefresh: async () => undefined,
43
- fetchUntrusted: (url, init) => fetch(url, init),
47
+ fetchUntrusted: Object.assign((url, init) => fetch(url, init), { [LAPTOP_FETCH]: true }),
48
+ checkUntrustedUrl: async (url) => hostUrlProblem(url),
44
49
  async deliverWebhook(tenant, workspace, delivery) {
45
50
  const { attemptWebhook, newDeliveryId } = await import("./notify.js");
46
51
  const deliveryId = newDeliveryId();
@@ -60,6 +65,35 @@ const local = {
60
65
  const KEY = Symbol.for("foldrun.platform");
61
66
  const g = globalThis;
62
67
  export const platform = (g[KEY] ??= { ...local });
68
+ /** True when this process fetches tenant-chosen URLs through a guard: a
69
+ * platform bound fetchUntrusted. A laptop has not, and calls its own LAN on
70
+ * purpose. */
71
+ export function guardsUntrustedUrls() {
72
+ return !platform.fetchUntrusted[LAPTOP_FETCH];
73
+ }
74
+ /**
75
+ * Why the host may not call a tenant's model endpoint at this URL, or null.
76
+ * On an install that guards untrusted fetches the endpoint must be https: the
77
+ * key rides in every request, and a plain-http base is also how a tenant
78
+ * names a cluster service. Private and internal addresses are refused by the
79
+ * guard itself, on every hop. FOLDRUN_EGRESS_ALLOW_PRIVATE=1 — the operator's
80
+ * own install calling its own network — lifts this as it lifts the guard. A
81
+ * laptop: always null.
82
+ */
83
+ export function hostUrlProblem(url) {
84
+ if (!guardsUntrustedUrls() || process.env.FOLDRUN_EGRESS_ALLOW_PRIVATE === "1")
85
+ return null;
86
+ let u;
87
+ try {
88
+ u = new URL(url);
89
+ }
90
+ catch {
91
+ return `${url} is not a URL`;
92
+ }
93
+ if (u.protocol !== "https:")
94
+ return `${u.origin} is not https — this platform calls a model endpoint only over https`;
95
+ return null;
96
+ }
63
97
  /** Install the platform's implementations. Partial: what is not given keeps its local default. */
64
98
  export function registerPlatform(hooks) {
65
99
  Object.assign(platform, hooks, {
@@ -387,6 +387,7 @@ grantsOnly?: boolean): {
387
387
  upstreamBase: string;
388
388
  wire?: import("./translator.ts").TranslatorWire;
389
389
  upstreamKey: string;
390
+ upstreamAuth?: "bearer" | "x-api-key";
390
391
  headers?: Record<string, string>;
391
392
  maxTokensParam?: "max_tokens" | "max_completion_tokens";
392
393
  reasoningEffort?: boolean;
@@ -400,6 +401,7 @@ grantsOnly?: boolean): {
400
401
  upstreamBase: string;
401
402
  wire?: import("./translator.ts").TranslatorWire;
402
403
  upstreamKey: string;
404
+ upstreamAuth?: "bearer" | "x-api-key";
403
405
  headers?: Record<string, string>;
404
406
  maxTokensParam?: "max_tokens" | "max_completion_tokens";
405
407
  reasoningEffort?: boolean;
@@ -2,6 +2,7 @@
2
2
  // group receives every prior result, labeled per agent. Optional steps ("2?")
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
+ import { globMatch } from "./bounded-match.js";
5
6
  import { latestRevisionId } from "./history.js";
6
7
  import { recordCallIo, redactionsFor } from "./capture.js";
7
8
  import { AUTH_REFUSAL, platformCause } from "./issues.js";
@@ -4074,14 +4075,18 @@ export function startFlowRun(tenant, workspace, steps, flowName, modelOverride,
4074
4075
  export function resolvePreview(storageDir, patterns, cap = 12) {
4075
4076
  const out = new Set();
4076
4077
  for (const pattern of patterns) {
4078
+ // Inside storage/ only: no climbing, no absolute paths.
4079
+ const parts = pattern.replaceAll("\\", "/").split("/");
4080
+ if (pattern.startsWith("/") || parts.includes("..") || pattern.length > 400)
4081
+ continue;
4077
4082
  if (!/[*?]/.test(pattern)) {
4078
4083
  if (fs.existsSync(path.join(storageDir, pattern)))
4079
4084
  out.add(pattern);
4080
4085
  continue;
4081
4086
  }
4082
- const re = new RegExp("^" + pattern
4083
- .split("**").map((part) => part.split("*").map((x) => x.replace(/[.+^${}()|[\]\\?]/g, "\\$&")).join("[^/]*"))
4084
- .join(".*") + "$");
4087
+ // Matched in linear time: this runs on the worker every account shares,
4088
+ // and a regex built from "**a**a**a**b" backtracks for seconds.
4089
+ const re = { test: (r) => globMatch(pattern, r) };
4085
4090
  const walk = (dir, rel) => {
4086
4091
  let entries;
4087
4092
  try {
@@ -17,7 +17,8 @@
17
17
  import { spawn } from "node:child_process";
18
18
  import fs from "node:fs";
19
19
  import path from "node:path";
20
- import { resolveSecrets, materializeSecrets } from "./secrets.js";
20
+ import { resolveSecrets, materializeSecrets, secretSites } from "./secrets.js";
21
+ import { siteCovers } from "./network.js";
21
22
  import { listAgents, workspaceDir } from "./store.js";
22
23
  import { libraryDir } from "./library.js";
23
24
  import { secretsUsedByApi } from "./api-tools.js";
@@ -62,6 +63,26 @@ function matchesTemplate(op, concrete) {
62
63
  const re = new RegExp("^" + op.path.split(/\{[^/}]+\}/).map((part) => part.replace(/[.*+?^$()|[\]\\]/g, "\\$&")).join("[^/]+") + "$");
63
64
  return re.test(concrete);
64
65
  }
66
+ /**
67
+ * A script or a command-started MCP server is the tenant's own code. On an
68
+ * install that isolates runs (FOLDRUN_RUN_ISOLATION) that code only ever runs
69
+ * in a step's sandbox — the web pod that answers Test is not one: it holds
70
+ * the platform's keys and every account's files (29 Oct 2026, OWASP review;
71
+ * hooks' Test was already refused the same way).
72
+ */
73
+ function hostCodeRefusal(transport) {
74
+ if (!process.env.FOLDRUN_RUN_ISOLATION)
75
+ return null;
76
+ return {
77
+ ok: false,
78
+ transport,
79
+ missingSecrets: [],
80
+ summary: "this tool runs your own code, which this install runs only inside a step's sandbox — not from here",
81
+ detail: "Test it locally with\n foldrun tool test <name>\n" +
82
+ "or with a test run of a flow whose agent has it under tools:, e.g.\n" +
83
+ " foldrun invoke <flow> --to <workspace> --test --wait",
84
+ };
85
+ }
65
86
  export async function testTool(tenant, workspace, def, input = {}) {
66
87
  const probePath = input.path ?? "";
67
88
  const started = Date.now();
@@ -128,6 +149,19 @@ export async function testTool(tenant, workspace, def, input = {}) {
128
149
  detail: `base: ${api.base}\npath: ${probePath}`,
129
150
  });
130
151
  }
152
+ // A secret bound to sites is filled in a run only by the egress proxy,
153
+ // only for those sites. The tester fills secrets itself, so it must ask
154
+ // the same question — or any editor could write a tool whose base is
155
+ // their own server and "test" the account's Stripe key out to it.
156
+ const bound = secretSites(tenant, needed, workspace);
157
+ const offSite = Object.entries(bound).filter(([, sites]) => !sites.some((site) => siteCovers(site, url.hostname))).map(([n]) => n);
158
+ if (offSite.length) {
159
+ return done({
160
+ ok: false, transport: "http", missingSecrets: missing,
161
+ summary: `${offSite.join(", ")} ${offSite.length === 1 ? "is" : "are"} bound to other sites than ${url.hostname} — not sent`,
162
+ detail: offSite.map((n) => `${n}: ${bound[n].join(", ")}`).join("\n"),
163
+ });
164
+ }
131
165
  for (const [k, v] of Object.entries(api.query))
132
166
  url.searchParams.set(k, substitute(v, env));
133
167
  const headers = {};
@@ -173,6 +207,9 @@ export async function testTool(tenant, workspace, def, input = {}) {
173
207
  }
174
208
  }
175
209
  if (def.kind === "script") {
210
+ const refused = hostCodeRefusal("script");
211
+ if (refused)
212
+ return done(refused);
176
213
  let run = String(def.spec.run ?? "");
177
214
  // A run's working directory is the calling AGENT's folder, not the
178
215
  // workspace root — and scripts rely on that. `path.resolve(cwd, "..",
@@ -344,6 +381,9 @@ export async function testTool(tenant, workspace, def, input = {}) {
344
381
  detail: "",
345
382
  });
346
383
  }
384
+ const refusedMcp = hostCodeRefusal("mcp");
385
+ if (refusedMcp)
386
+ return done(refusedMcp);
347
387
  const named = Object.entries(spec.env ?? {})
348
388
  .map(([, v]) => String(v).match(/^\$\{([A-Z0-9_]+)\}$/)?.[1])
349
389
  .filter((n) => Boolean(n));
@@ -11,15 +11,21 @@ export declare function anthropicUsage(u: Json | undefined, wire: TranslatorWire
11
11
  cached: number;
12
12
  output: number;
13
13
  };
14
- export type TranslatorWire = "chat" | "responses";
14
+ /** `anthropic` is no translation at all: the host-side passthrough for an
15
+ * endpoint that already speaks Messages, so the SDK on the host talks only
16
+ * to loopback and every byte to the tenant's URL goes through the guard
17
+ * (FOL-95). A step never gets it (translatorSpecFor returns null there). */
18
+ export type TranslatorWire = "chat" | "responses" | "anthropic";
15
19
  export interface TranslatorSpec {
16
20
  /** The provider's base, e.g. https://api.openai.com/v1 — `/chat/completions`
17
21
  * or `/responses` is appended, by `wire`. */
18
22
  upstreamBase: string;
19
23
  /** Which OpenAI shape the endpoint speaks. Default chat. */
20
24
  wire?: TranslatorWire;
21
- /** Already resolved. Sent as `Authorization: Bearer`. */
25
+ /** Already resolved. Sent as `Authorization: Bearer`, or as `x-api-key`
26
+ * where `upstreamAuth` says so (the anthropic wire only). */
22
27
  upstreamKey: string;
28
+ upstreamAuth?: "bearer" | "x-api-key";
23
29
  /** Extra headers the provider wants, already resolved. */
24
30
  headers?: Record<string, string>;
25
31
  maxTokensParam?: "max_tokens" | "max_completion_tokens";