@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.
- 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 +27 -5
- 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/model-env.d.ts +12 -3
- package/dist/src/model-env.js +44 -6
- package/dist/src/platform.d.ts +24 -0
- package/dist/src/platform.js +35 -1
- package/dist/src/runner.d.ts +2 -0
- package/dist/src/runner.js +8 -3
- package/dist/src/tool-test.js +41 -1
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +73 -0
- package/dist/src/webhook.js +46 -6
- package/package.json +1 -1
- package/src/bounded-match.ts +70 -0
- package/src/deploy.ts +3 -0
- package/src/evals.ts +24 -5
- package/src/mail.ts +49 -0
- package/src/matter.ts +19 -2
- package/src/model-env.ts +42 -7
- package/src/platform.ts +45 -1
- package/src/runner.ts +7 -5
- package/src/tool-test.ts +40 -1
- package/src/translator.ts +83 -2
- package/src/webhook.ts +44 -5
|
@@ -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
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
}
|
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/model-env.d.ts
CHANGED
|
@@ -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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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;
|
package/dist/src/model-env.js
CHANGED
|
@@ -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
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
-
|
|
84
|
+
const spec = m.translator ?? (m.supply === "provider" ? anthropicPassthrough(m.env) : null);
|
|
85
|
+
if (!spec)
|
|
74
86
|
return fn(m.env);
|
|
75
|
-
const
|
|
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
|
+
}
|
package/dist/src/platform.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/src/platform.js
CHANGED
|
@@ -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, {
|
package/dist/src/runner.d.ts
CHANGED
|
@@ -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;
|
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/dist/src/translator.d.ts
CHANGED
|
@@ -11,15 +11,21 @@ export declare function anthropicUsage(u: Json | undefined, wire: TranslatorWire
|
|
|
11
11
|
cached: number;
|
|
12
12
|
output: number;
|
|
13
13
|
};
|
|
14
|
-
|
|
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";
|