@foldrun/core 4.0.0 → 5.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
@@ -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
  }
@@ -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
  }
@@ -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));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "4.0.0",
3
+ "version": "5.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
@@ -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
  }
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/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; }
package/src/tool-test.ts CHANGED
@@ -19,7 +19,8 @@ import { spawn } from "node:child_process";
19
19
  import fs from "node:fs";
20
20
  import path from "node:path";
21
21
 
22
- import { resolveSecrets, materializeSecrets } from "./secrets.ts";
22
+ import { resolveSecrets, materializeSecrets, secretSites } from "./secrets.ts";
23
+ import { siteCovers } from "./network.ts";
23
24
  import { listAgents, workspaceDir, type ToolDef } from "./store.ts";
24
25
  import { libraryDir } from "./library.ts";
25
26
  import { secretsUsedByApi } from "./api-tools.ts";
@@ -96,6 +97,27 @@ export interface ToolTestInput {
96
97
  args?: Record<string, string>;
97
98
  }
98
99
 
100
+ /**
101
+ * A script or a command-started MCP server is the tenant's own code. On an
102
+ * install that isolates runs (FOLDRUN_RUN_ISOLATION) that code only ever runs
103
+ * in a step's sandbox — the web pod that answers Test is not one: it holds
104
+ * the platform's keys and every account's files (29 Oct 2026, OWASP review;
105
+ * hooks' Test was already refused the same way).
106
+ */
107
+ function hostCodeRefusal(transport: "script" | "mcp"): Omit<ToolTestResult, "ms"> | null {
108
+ if (!process.env.FOLDRUN_RUN_ISOLATION) return null;
109
+ return {
110
+ ok: false,
111
+ transport,
112
+ missingSecrets: [],
113
+ summary: "this tool runs your own code, which this install runs only inside a step's sandbox — not from here",
114
+ detail:
115
+ "Test it locally with\n foldrun tool test <name>\n" +
116
+ "or with a test run of a flow whose agent has it under tools:, e.g.\n" +
117
+ " foldrun invoke <flow> --to <workspace> --test --wait",
118
+ };
119
+ }
120
+
99
121
  export async function testTool(
100
122
  tenant: string,
101
123
  workspace: string,
@@ -170,6 +192,19 @@ export async function testTool(
170
192
  detail: `base: ${api.base}\npath: ${probePath}`,
171
193
  });
172
194
  }
195
+ // A secret bound to sites is filled in a run only by the egress proxy,
196
+ // only for those sites. The tester fills secrets itself, so it must ask
197
+ // the same question — or any editor could write a tool whose base is
198
+ // their own server and "test" the account's Stripe key out to it.
199
+ const bound = secretSites(tenant, needed, workspace);
200
+ const offSite = Object.entries(bound).filter(([, sites]) => !sites.some((site) => siteCovers(site, url.hostname))).map(([n]) => n);
201
+ if (offSite.length) {
202
+ return done({
203
+ ok: false, transport: "http", missingSecrets: missing,
204
+ summary: `${offSite.join(", ")} ${offSite.length === 1 ? "is" : "are"} bound to other sites than ${url.hostname} — not sent`,
205
+ detail: offSite.map((n) => `${n}: ${bound[n].join(", ")}`).join("\n"),
206
+ });
207
+ }
173
208
  for (const [k, v] of Object.entries(api.query)) url.searchParams.set(k, substitute(v, env));
174
209
 
175
210
  const headers: Record<string, string> = {};
@@ -219,6 +254,8 @@ export async function testTool(
219
254
  }
220
255
 
221
256
  if (def.kind === "script") {
257
+ const refused = hostCodeRefusal("script");
258
+ if (refused) return done(refused);
222
259
  let run = String(def.spec.run ?? "");
223
260
  // A run's working directory is the calling AGENT's folder, not the
224
261
  // workspace root — and scripts rely on that. `path.resolve(cwd, "..",
@@ -404,6 +441,8 @@ export async function testTool(
404
441
  detail: "",
405
442
  });
406
443
  }
444
+ const refusedMcp = hostCodeRefusal("mcp");
445
+ if (refusedMcp) return done(refusedMcp);
407
446
 
408
447
  const named = Object.entries(spec.env ?? {})
409
448
  .map(([, v]) => String(v).match(/^\$\{([A-Z0-9_]+)\}$/)?.[1])