@foldrun/core 3.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.
- package/dist/src/bounded-match.d.ts +8 -0
- package/dist/src/bounded-match.js +80 -0
- package/dist/src/deploy.js +4 -0
- package/dist/src/evals.js +16 -3
- package/dist/src/install-tool.js +15 -6
- package/dist/src/mail.d.ts +2 -6
- package/dist/src/mail.js +50 -0
- package/dist/src/matter.js +18 -2
- package/dist/src/runner.js +8 -3
- package/dist/src/tool-test.js +41 -1
- package/package.json +1 -1
- package/src/bounded-match.ts +70 -0
- package/src/deploy.ts +3 -0
- package/src/evals.ts +15 -3
- package/src/install-tool.ts +15 -6
- package/src/mail.ts +49 -0
- package/src/matter.ts +19 -2
- package/src/runner.ts +7 -5
- package/src/tool-test.ts +40 -1
|
@@ -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
|
+
}
|
package/dist/src/deploy.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/src/install-tool.js
CHANGED
|
@@ -12,7 +12,13 @@
|
|
|
12
12
|
// pip and npm need no root and run here, as the agent, into a per-step
|
|
13
13
|
// directory already on PATH / PYTHONPATH / NODE_PATH (stepInstallEnv), so the
|
|
14
14
|
// next command finds what this one installed. Inside a run container all of
|
|
15
|
-
// it goes when the container does.
|
|
15
|
+
// it goes when the container does.
|
|
16
|
+
//
|
|
17
|
+
// No package runs its own code on the way in (FOL-98, 28 Oct 2026): npm
|
|
18
|
+
// installs with --ignore-scripts (no preinstall/postinstall), and pip takes
|
|
19
|
+
// built wheels only (--only-binary :all:) — an sdist's setup.py is code. A
|
|
20
|
+
// package that has no wheel says so, and `runtime: packages:` builds it
|
|
21
|
+
// before the step instead, where the build is declared, cached and seen. Every install is logged with the line to
|
|
16
22
|
// add to the agent's `runtime:` to have it before the step starts next time.
|
|
17
23
|
import fs from "node:fs";
|
|
18
24
|
import net from "node:net";
|
|
@@ -86,27 +92,30 @@ export function buildInstallTool(opts) {
|
|
|
86
92
|
fs.mkdirSync(target, { recursive: true });
|
|
87
93
|
const uv = findUv();
|
|
88
94
|
const res = uv
|
|
89
|
-
? spawnSync(uv, ["pip", "install", "-q", "--python", opts.python, "--target", target, ...names], {
|
|
95
|
+
? spawnSync(uv, ["pip", "install", "-q", "--only-binary", ":all:", "--python", opts.python, "--target", target, ...names], {
|
|
90
96
|
env: { ...opts.env, UV_CACHE_DIR: path.join(opts.cacheRoot, ".uv-cache"), UV_LINK_MODE: "copy", UV_NO_PROGRESS: "1" },
|
|
91
97
|
encoding: "utf8",
|
|
92
98
|
timeout: 600_000,
|
|
93
99
|
})
|
|
94
|
-
: spawnSync(opts.python, ["-m", "pip", "install", "-q", "--disable-pip-version-check", "--target", target, ...names], {
|
|
100
|
+
: spawnSync(opts.python, ["-m", "pip", "install", "-q", "--disable-pip-version-check", "--only-binary", ":all:", "--target", target, ...names], {
|
|
95
101
|
env: opts.env,
|
|
96
102
|
encoding: "utf8",
|
|
97
103
|
timeout: 600_000,
|
|
98
104
|
});
|
|
99
105
|
if (res.status !== 0) {
|
|
100
|
-
const
|
|
106
|
+
const raw = `${res.stdout ?? ""}${res.stderr ?? ""}${res.error ? res.error.message : ""}`;
|
|
107
|
+
const msg = summarizeInstallFailure(uv ? "uv pip install" : "pip install", raw);
|
|
101
108
|
opts.emit("error", `install: pip ${names.join(" ")} — ${msg.split("\n")[0]}`);
|
|
102
|
-
|
|
109
|
+
// Wheels only: one that exists only as source is not built here.
|
|
110
|
+
const noWheel = /no matching distribution|only-binary|no wheels? (?:with|for)|binary.*not available|doesn't have a wheel|has no wheel/i.test(raw);
|
|
111
|
+
return text(noWheel ? `${msg}\n\nOnly built wheels are installed mid-step — a source package's build runs its own code. Declare it in the agent's runtime: packages: [${names.join(", ")}] to have it built before the step.` : msg, true);
|
|
103
112
|
}
|
|
104
113
|
how = `pip, ${Math.round((Date.now() - t0) / 1000)}s`;
|
|
105
114
|
}
|
|
106
115
|
else {
|
|
107
116
|
const prefix = path.join(opts.dir, "npm");
|
|
108
117
|
fs.mkdirSync(prefix, { recursive: true });
|
|
109
|
-
const res = spawnSync("npm", ["install", "--no-fund", "--no-audit", "--no-progress", "--prefix", prefix, ...names], {
|
|
118
|
+
const res = spawnSync("npm", ["install", "--ignore-scripts", "--no-fund", "--no-audit", "--no-progress", "--prefix", prefix, ...names], {
|
|
110
119
|
env: { ...opts.env, npm_config_cache: path.join(opts.cacheRoot, ".npm-cache") },
|
|
111
120
|
encoding: "utf8",
|
|
112
121
|
timeout: 600_000,
|
package/dist/src/mail.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/src/matter.js
CHANGED
|
@@ -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
|
}
|
package/dist/src/runner.js
CHANGED
|
@@ -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
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
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 {
|
package/dist/src/tool-test.js
CHANGED
|
@@ -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
|
@@ -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
|
-
|
|
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
|
-
|
|
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/install-tool.ts
CHANGED
|
@@ -12,7 +12,13 @@
|
|
|
12
12
|
// pip and npm need no root and run here, as the agent, into a per-step
|
|
13
13
|
// directory already on PATH / PYTHONPATH / NODE_PATH (stepInstallEnv), so the
|
|
14
14
|
// next command finds what this one installed. Inside a run container all of
|
|
15
|
-
// it goes when the container does.
|
|
15
|
+
// it goes when the container does.
|
|
16
|
+
//
|
|
17
|
+
// No package runs its own code on the way in (FOL-98, 28 Oct 2026): npm
|
|
18
|
+
// installs with --ignore-scripts (no preinstall/postinstall), and pip takes
|
|
19
|
+
// built wheels only (--only-binary :all:) — an sdist's setup.py is code. A
|
|
20
|
+
// package that has no wheel says so, and `runtime: packages:` builds it
|
|
21
|
+
// before the step instead, where the build is declared, cached and seen. Every install is logged with the line to
|
|
16
22
|
// add to the agent's `runtime:` to have it before the step starts next time.
|
|
17
23
|
|
|
18
24
|
import fs from "node:fs";
|
|
@@ -106,26 +112,29 @@ export function buildInstallTool(opts: {
|
|
|
106
112
|
fs.mkdirSync(target, { recursive: true });
|
|
107
113
|
const uv = findUv();
|
|
108
114
|
const res = uv
|
|
109
|
-
? spawnSync(uv, ["pip", "install", "-q", "--python", opts.python, "--target", target, ...names], {
|
|
115
|
+
? spawnSync(uv, ["pip", "install", "-q", "--only-binary", ":all:", "--python", opts.python, "--target", target, ...names], {
|
|
110
116
|
env: { ...opts.env, UV_CACHE_DIR: path.join(opts.cacheRoot, ".uv-cache"), UV_LINK_MODE: "copy", UV_NO_PROGRESS: "1" } as unknown as NodeJS.ProcessEnv,
|
|
111
117
|
encoding: "utf8",
|
|
112
118
|
timeout: 600_000,
|
|
113
119
|
})
|
|
114
|
-
: spawnSync(opts.python, ["-m", "pip", "install", "-q", "--disable-pip-version-check", "--target", target, ...names], {
|
|
120
|
+
: spawnSync(opts.python, ["-m", "pip", "install", "-q", "--disable-pip-version-check", "--only-binary", ":all:", "--target", target, ...names], {
|
|
115
121
|
env: opts.env as unknown as NodeJS.ProcessEnv,
|
|
116
122
|
encoding: "utf8",
|
|
117
123
|
timeout: 600_000,
|
|
118
124
|
});
|
|
119
125
|
if (res.status !== 0) {
|
|
120
|
-
const
|
|
126
|
+
const raw = `${res.stdout ?? ""}${res.stderr ?? ""}${res.error ? res.error.message : ""}`;
|
|
127
|
+
const msg = summarizeInstallFailure(uv ? "uv pip install" : "pip install", raw);
|
|
121
128
|
opts.emit("error", `install: pip ${names.join(" ")} — ${msg.split("\n")[0]}`);
|
|
122
|
-
|
|
129
|
+
// Wheels only: one that exists only as source is not built here.
|
|
130
|
+
const noWheel = /no matching distribution|only-binary|no wheels? (?:with|for)|binary.*not available|doesn't have a wheel|has no wheel/i.test(raw);
|
|
131
|
+
return text(noWheel ? `${msg}\n\nOnly built wheels are installed mid-step — a source package's build runs its own code. Declare it in the agent's runtime: packages: [${names.join(", ")}] to have it built before the step.` : msg, true);
|
|
123
132
|
}
|
|
124
133
|
how = `pip, ${Math.round((Date.now() - t0) / 1000)}s`;
|
|
125
134
|
} else {
|
|
126
135
|
const prefix = path.join(opts.dir, "npm");
|
|
127
136
|
fs.mkdirSync(prefix, { recursive: true });
|
|
128
|
-
const res = spawnSync("npm", ["install", "--no-fund", "--no-audit", "--no-progress", "--prefix", prefix, ...names], {
|
|
137
|
+
const res = spawnSync("npm", ["install", "--ignore-scripts", "--no-fund", "--no-audit", "--no-progress", "--prefix", prefix, ...names], {
|
|
129
138
|
env: { ...opts.env, npm_config_cache: path.join(opts.cacheRoot, ".npm-cache") } as unknown as NodeJS.ProcessEnv,
|
|
130
139
|
encoding: "utf8",
|
|
131
140
|
timeout: 600_000,
|
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
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
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])
|