@foldrun/core 5.0.0 → 7.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/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";
@@ -582,7 +582,16 @@ export async function checkAssertion(assertion, output, agentDir, model, effort,
582
582
  catch (err) {
583
583
  return { assertion, passed: false, detail: `judge not run — ${err instanceof Error ? err.message : String(err)}` };
584
584
  }
585
- const said = await withModelEnv(m, (env) => judgeCall(assertion.value, output, model, effort, env));
585
+ let said;
586
+ try {
587
+ said = await withModelEnv(m, (env) => judgeCall(assertion.value, output, model, effort, env));
588
+ }
589
+ catch (err) {
590
+ // The provider's URL refused on the host (FOL-95), like a missing key above.
591
+ if (!(err instanceof ModelKeyError))
592
+ throw err;
593
+ return { assertion, passed: false, detail: `judge not run — ${err.message}` };
594
+ }
586
595
  const { passed, detail } = judgeVerdict(said.text);
587
596
  return { assertion, passed, detail, costUsd: said.costUsd };
588
597
  }
@@ -19,11 +19,20 @@ export declare function accountModelEnv(tenant: string, workspace: string, platf
19
19
  translator: TranslatorSpec | null;
20
20
  };
21
21
  /**
22
- * Run `fn` with the model env, through a translator on this host when the
23
- * provider speaks Chat Completions or Responses — the same one a step uses
24
- * (runner.ts), started for this call and closed after it.
22
+ * Run `fn` with the model env, through a translator on this host — the one a
23
+ * step uses (runner.ts) when the provider speaks Chat Completions or
24
+ * Responses, and a passthrough when it speaks Anthropic's Messages (FOL-95):
25
+ * either way the SDK talks only to loopback, and the tenant's endpoint is
26
+ * reached through fetchUntrusted, after the platform's check of its URL.
27
+ * The platform's own credential (`supply: "platform"`) goes as it always did.
28
+ * Started for this call and closed after it.
25
29
  */
26
30
  export declare function withModelEnv<T>(m: {
27
31
  env: Record<string, string | undefined>;
28
32
  translator?: TranslatorSpec | null;
33
+ supply?: "provider" | "platform";
29
34
  }, fn: (env: Record<string, string | undefined>) => Promise<T>): Promise<T>;
35
+ /** The passthrough for an Anthropic-format provider, read back from the env
36
+ * providerEnvFor (store.ts) built for it: the base URL, the key in the slot
37
+ * that names its header, and ANTHROPIC_CUSTOM_HEADERS. Null with no base. */
38
+ export declare function anthropicPassthrough(env: Record<string, string | undefined>): TranslatorSpec | null;
@@ -8,7 +8,7 @@
8
8
  import { accountDir, workspaceDir, parseProvider, providerEnvFor } from "./store.js";
9
9
  import { readAgentsMd } from "./runner.js";
10
10
  import { getSecret } from "./secrets.js";
11
- import { platform } from "./platform.js";
11
+ import { platform, hostUrlProblem } from "./platform.js";
12
12
  import { accountModelBlock, isClaudeLoginToken } from "./model-credential.js";
13
13
  import { providerPreset } from "./providers.js";
14
14
  import { startTranslator, translatorSpecFor } from "./translator.js";
@@ -39,6 +39,13 @@ export function accountModelEnv(tenant, workspace, platformEnv) {
39
39
  throw new ModelKeyError(NO_MODEL_KEY);
40
40
  return { env: platformEnv(), supply: "platform", format: "anthropic", translator: null };
41
41
  }
42
+ // A host-side call to a URL the tenant wrote: on a platform, https only
43
+ // (platform.ts hostUrlProblem); withModelEnv asks the platform's own check
44
+ // too, and the guard sees every byte (FOL-95). A step resolves its
45
+ // provider elsewhere (runner.ts) and is not touched by this.
46
+ const refused = hostUrlProblem(spec.baseUrl);
47
+ if (refused)
48
+ throw new ModelKeyError(`provider base_url refused: ${refused}`, 422);
42
49
  const sub = (text) => text.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => getSecret(tenant, name, workspace)?.value ?? whole);
43
50
  const token = sub(spec.token);
44
51
  if (token.includes("${"))
@@ -65,14 +72,22 @@ export function accountModelEnv(tenant, workspace, platformEnv) {
65
72
  };
66
73
  }
67
74
  /**
68
- * Run `fn` with the model env, through a translator on this host when the
69
- * provider speaks Chat Completions or Responses — the same one a step uses
70
- * (runner.ts), started for this call and closed after it.
75
+ * Run `fn` with the model env, through a translator on this host — the one a
76
+ * step uses (runner.ts) when the provider speaks Chat Completions or
77
+ * Responses, and a passthrough when it speaks Anthropic's Messages (FOL-95):
78
+ * either way the SDK talks only to loopback, and the tenant's endpoint is
79
+ * reached through fetchUntrusted, after the platform's check of its URL.
80
+ * The platform's own credential (`supply: "platform"`) goes as it always did.
81
+ * Started for this call and closed after it.
71
82
  */
72
83
  export async function withModelEnv(m, fn) {
73
- if (!m.translator)
84
+ const spec = m.translator ?? (m.supply === "provider" ? anthropicPassthrough(m.env) : null);
85
+ if (!spec)
74
86
  return fn(m.env);
75
- const t = await startTranslator(m.translator);
87
+ const why = await platform.checkUntrustedUrl(spec.upstreamBase);
88
+ if (why)
89
+ throw new ModelKeyError(`provider base_url refused: ${why}`, 422);
90
+ const t = await startTranslator(spec);
76
91
  try {
77
92
  return await fn({ ...m.env, ...t.env });
78
93
  }
@@ -80,3 +95,26 @@ export async function withModelEnv(m, fn) {
80
95
  await t.close();
81
96
  }
82
97
  }
98
+ /** The passthrough for an Anthropic-format provider, read back from the env
99
+ * providerEnvFor (store.ts) built for it: the base URL, the key in the slot
100
+ * that names its header, and ANTHROPIC_CUSTOM_HEADERS. Null with no base. */
101
+ export function anthropicPassthrough(env) {
102
+ const base = env.ANTHROPIC_BASE_URL;
103
+ if (!base)
104
+ return null;
105
+ const apiKey = env.ANTHROPIC_API_KEY ?? "";
106
+ const headers = {};
107
+ for (const line of (env.ANTHROPIC_CUSTOM_HEADERS ?? "").split("\n")) {
108
+ const at = line.indexOf(":");
109
+ if (at > 0)
110
+ headers[line.slice(0, at).trim()] = line.slice(at + 1).trim();
111
+ }
112
+ return {
113
+ wire: "anthropic",
114
+ upstreamBase: base,
115
+ upstreamKey: apiKey || (env.ANTHROPIC_AUTH_TOKEN ?? ""),
116
+ upstreamAuth: apiKey ? "x-api-key" : "bearer",
117
+ headers,
118
+ label: base,
119
+ };
120
+ }
@@ -175,6 +175,16 @@ export interface PlatformHooks {
175
175
  fetchUntrusted(url: string, init?: RequestInit, opts?: {
176
176
  maxBytes?: number;
177
177
  }): Promise<Response>;
178
+ /**
179
+ * May the host call this URL a tenant chose — a provider's base_url,
180
+ * before an eval's judge or a dashboard draft talks to it? A reason it may
181
+ * not, or null. This is the early refusal people can read; the guarantee
182
+ * is that every byte of such a call goes through fetchUntrusted (FOL-95).
183
+ * Default: hostUrlProblem below — https on an install that guards
184
+ * untrusted fetches, anything on a laptop (its own localhost included).
185
+ * A platform may bind a stricter check (the addresses a name resolves to).
186
+ */
187
+ checkUntrustedUrl(url: string): Promise<string | null>;
178
188
  /**
179
189
  * Hand a notify: webhook to whatever delivers it. The platform records
180
190
  * the delivery, makes the first attempt now and retries on a backoff from
@@ -207,6 +217,20 @@ export interface WebhookDeliveryOutcome {
207
217
  retrying?: boolean;
208
218
  }
209
219
  export declare const platform: PlatformHooks;
220
+ /** True when this process fetches tenant-chosen URLs through a guard: a
221
+ * platform bound fetchUntrusted. A laptop has not, and calls its own LAN on
222
+ * purpose. */
223
+ export declare function guardsUntrustedUrls(): boolean;
224
+ /**
225
+ * Why the host may not call a tenant's model endpoint at this URL, or null.
226
+ * On an install that guards untrusted fetches the endpoint must be https: the
227
+ * key rides in every request, and a plain-http base is also how a tenant
228
+ * names a cluster service. Private and internal addresses are refused by the
229
+ * guard itself, on every hop. FOLDRUN_EGRESS_ALLOW_PRIVATE=1 — the operator's
230
+ * own install calling its own network — lifts this as it lifts the guard. A
231
+ * laptop: always null.
232
+ */
233
+ export declare function hostUrlProblem(url: string): string | null;
210
234
  /** Install the platform's implementations. Partial: what is not given keeps its local default. */
211
235
  export declare function registerPlatform(hooks: Partial<PlatformHooks>): void;
212
236
  /**
@@ -12,6 +12,10 @@
12
12
  // and calls registerPlatform() once at boot. A process that forgets to is a
13
13
  // local install — which is exactly the failure that is safe.
14
14
  import { matchFilter } from "./trigger-filter.js";
15
+ /** Marks the laptop's fetchUntrusted. A symbol from the global registry, not
16
+ * the function itself: every bundle has its own copy of this module (see
17
+ * KEY below), and only the shared object says which one was registered. */
18
+ const LAPTOP_FETCH = Symbol.for("foldrun.laptop-fetch");
15
19
  const local = {
16
20
  edition: () => "self-hosted",
17
21
  async enqueueFlowRun(tenant, workspace, steps, flowName, modelOverride, tags = [], startedBy = null, opts = {}) {
@@ -40,7 +44,8 @@ const local = {
40
44
  platformKeyAllowed: () => true,
41
45
  galleryDir: () => null,
42
46
  noteOAuthRefresh: async () => undefined,
43
- fetchUntrusted: (url, init) => fetch(url, init),
47
+ fetchUntrusted: Object.assign((url, init) => fetch(url, init), { [LAPTOP_FETCH]: true }),
48
+ checkUntrustedUrl: async (url) => hostUrlProblem(url),
44
49
  async deliverWebhook(tenant, workspace, delivery) {
45
50
  const { attemptWebhook, newDeliveryId } = await import("./notify.js");
46
51
  const deliveryId = newDeliveryId();
@@ -60,6 +65,35 @@ const local = {
60
65
  const KEY = Symbol.for("foldrun.platform");
61
66
  const g = globalThis;
62
67
  export const platform = (g[KEY] ??= { ...local });
68
+ /** True when this process fetches tenant-chosen URLs through a guard: a
69
+ * platform bound fetchUntrusted. A laptop has not, and calls its own LAN on
70
+ * purpose. */
71
+ export function guardsUntrustedUrls() {
72
+ return !platform.fetchUntrusted[LAPTOP_FETCH];
73
+ }
74
+ /**
75
+ * Why the host may not call a tenant's model endpoint at this URL, or null.
76
+ * On an install that guards untrusted fetches the endpoint must be https: the
77
+ * key rides in every request, and a plain-http base is also how a tenant
78
+ * names a cluster service. Private and internal addresses are refused by the
79
+ * guard itself, on every hop. FOLDRUN_EGRESS_ALLOW_PRIVATE=1 — the operator's
80
+ * own install calling its own network — lifts this as it lifts the guard. A
81
+ * laptop: always null.
82
+ */
83
+ export function hostUrlProblem(url) {
84
+ if (!guardsUntrustedUrls() || process.env.FOLDRUN_EGRESS_ALLOW_PRIVATE === "1")
85
+ return null;
86
+ let u;
87
+ try {
88
+ u = new URL(url);
89
+ }
90
+ catch {
91
+ return `${url} is not a URL`;
92
+ }
93
+ if (u.protocol !== "https:")
94
+ return `${u.origin} is not https — this platform calls a model endpoint only over https`;
95
+ return null;
96
+ }
63
97
  /** Install the platform's implementations. Partial: what is not given keeps its local default. */
64
98
  export function registerPlatform(hooks) {
65
99
  Object.assign(platform, hooks, {
@@ -387,6 +387,7 @@ grantsOnly?: boolean): {
387
387
  upstreamBase: string;
388
388
  wire?: import("./translator.ts").TranslatorWire;
389
389
  upstreamKey: string;
390
+ upstreamAuth?: "bearer" | "x-api-key";
390
391
  headers?: Record<string, string>;
391
392
  maxTokensParam?: "max_tokens" | "max_completion_tokens";
392
393
  reasoningEffort?: boolean;
@@ -400,6 +401,7 @@ grantsOnly?: boolean): {
400
401
  upstreamBase: string;
401
402
  wire?: import("./translator.ts").TranslatorWire;
402
403
  upstreamKey: string;
404
+ upstreamAuth?: "bearer" | "x-api-key";
403
405
  headers?: Record<string, string>;
404
406
  maxTokensParam?: "max_tokens" | "max_completion_tokens";
405
407
  reasoningEffort?: boolean;
@@ -31,8 +31,12 @@ interface StoredSecret {
31
31
  * these sites alone. Metadata, not a value, so it is stored in the clear
32
32
  * beside the ciphertext and survives the value being set again. */
33
33
  sites?: string[];
34
+ /** 2: sealed with the record's place as AAD — account, scope, name, kind,
35
+ * key and sites (vaultAad). Absent: the older, unbound form. */
36
+ v?: 2;
34
37
  }
35
38
  type SecretsFile = Record<string, StoredSecret>;
39
+ declare const writeBound: () => boolean;
36
40
  /**
37
41
  * Change one vault file safely: `change` gets what is stored now and returns
38
42
  * the new contents, or null to leave the file untouched. Returns what is
@@ -183,6 +187,11 @@ export declare function materializeSecrets(env: Record<string, string>,
183
187
  ctx?: {
184
188
  tenant: string;
185
189
  workspace?: string;
190
+ /** A rotated refresh token goes here INSTEAD of the vault: a caller that
191
+ * cannot open the vault (the egress proxy without the install key)
192
+ * keeps it itself and has it written by a process that can. `next` is
193
+ * the stored value with the new refresh token. */
194
+ onRotate?: (name: string, usedValue: string, next: string) => unknown;
186
195
  }): Promise<Record<string, string>>;
187
196
  export interface ServiceAccountConfig {
188
197
  token_url: string;
@@ -254,6 +263,19 @@ export declare function basicAuthorization(username: string, password: string):
254
263
  export declare function setApiSecret(tenant: string, name: string, config: ApiConfig, workspace?: string): void;
255
264
  export declare function isApiValue(value: string): boolean;
256
265
  export declare function apiConfigOf(value: string): ApiConfig;
266
+ /**
267
+ * Seal every unbound record of an account for its place (v2). Run at boot
268
+ * once the install writes bound records (FOLDRUN_VAULT_WRITE_BOUND=1), and by
269
+ * scripts/seal-vault-bindings.mjs. Per file, all or nothing: every unbound
270
+ * value is opened first, and a file holding one that will not open is left
271
+ * exactly as it is and reported. Idempotent: bound records are skipped.
272
+ */
273
+ export declare function sealVaultBindings(tenant: string): {
274
+ sealed: number;
275
+ unreadable: string[];
276
+ };
277
+ /** Whether this install writes bound vault records (and migrates at boot). */
278
+ export declare const vaultWritesBound: typeof writeBound;
257
279
  /**
258
280
  * Move an account's secrets from the install key onto its own key.
259
281
  *
@@ -56,6 +56,12 @@ export function installKeyMaterial() {
56
56
  const fromEnv = process.env.FOLDRUN_SECRET_KEY;
57
57
  if (fromEnv)
58
58
  return fromEnv;
59
+ // A process that must never hold the install key (the egress proxy) says
60
+ // so; without this, asking for it would quietly mint a NEW random key on
61
+ // the shared data volume — a second root key nobody chose.
62
+ if (process.env.FOLDRUN_INSTALL_KEY_ABSENT === "1") {
63
+ throw new Error("this process runs without the install key (FOLDRUN_INSTALL_KEY_ABSENT=1) — whatever asked for it belongs in the worker");
64
+ }
59
65
  fs.mkdirSync(dataRoot(), { recursive: true });
60
66
  if (!fs.existsSync(keyFile())) {
61
67
  fs.writeFileSync(keyFile(), crypto.randomBytes(32).toString("hex"), { mode: 0o600 });
@@ -76,6 +82,81 @@ function secretsFile(tenant, workspace) {
76
82
  const base = fs.existsSync(legacy) ? legacy : path.join(dataRoot(), tenant, "workspaces", workspace);
77
83
  return path.join(base, "secrets.json");
78
84
  }
85
+ // ------------------------------------------------------------ binding
86
+ //
87
+ // A v2 record is sealed with WHERE it belongs as additional authenticated
88
+ // data: the account, the scope (account or which workspace), the name, its
89
+ // kind, which key sealed it, and the sites it is bound to. Before 29 Oct 2026
90
+ // (OWASP review) a record was bound to nothing: anyone who could write the
91
+ // data volume or a backup could move one secret's ciphertext under another
92
+ // name, scope or account, or rewrite its clear `sites` and have the egress
93
+ // proxy fill it for their own host. Now any of those makes it fail to open,
94
+ // which reads as "not set" — never as unbound.
95
+ //
96
+ // The place comes from the FILE the record is read from, never from fields
97
+ // inside it. Sites stay in clear beside the ciphertext (listing needs no
98
+ // key, and a pod from before v2 reads them there) and are authenticated by
99
+ // the AAD.
100
+ //
101
+ // Rolled out in steps: every release since reads v2; FOLDRUN_VAULT_WRITE_BOUND=1
102
+ // writes it and migrates existing records at boot; FOLDRUN_VAULT_REQUIRE_BOUND=1
103
+ // refuses the old form once none is left.
104
+ // The scope is account or workspace, not WHICH workspace: a workspace can be
105
+ // renamed (its folder moves with its vault), and binding the name would make
106
+ // every rename a re-seal racing other writers. What that leaves open — a
107
+ // ciphertext moved between two workspaces of the same account by someone who
108
+ // can already write the volume — is the residual, and stays inside one account.
109
+ function vaultAad(tenant, workspace, name, rec) {
110
+ if (rec.sites !== undefined && (!Array.isArray(rec.sites) || rec.sites.some((x) => typeof x !== "string"))) {
111
+ throw new Error("malformed sites");
112
+ }
113
+ return Buffer.from(JSON.stringify(["foldrun-vault/2", tenant, workspace ? "w" : "a", name, rec.kind ?? "", rec.k ?? "install", [...(rec.sites ?? [])].sort()]), "utf8");
114
+ }
115
+ const writeBound = () => process.env.FOLDRUN_VAULT_WRITE_BOUND === "1";
116
+ const requireBound = () => process.env.FOLDRUN_VAULT_REQUIRE_BOUND === "1";
117
+ /** One record, sealed for its place. Bound (v2) when the install writes bound
118
+ * records or `bound` says so; the unbound form otherwise. */
119
+ function sealRecord(tenant, workspace, name, value, meta, bound = writeBound()) {
120
+ const { key, mark } = keyFor(tenant);
121
+ const rec = {
122
+ iv: "",
123
+ tag: "",
124
+ data: "",
125
+ updatedAt: meta.updatedAt ?? new Date().toISOString(),
126
+ ...(meta.kind ? { kind: meta.kind } : {}),
127
+ ...(mark ? { k: mark } : {}),
128
+ ...(meta.sites?.length ? { sites: [...meta.sites] } : {}),
129
+ };
130
+ const iv = crypto.randomBytes(12);
131
+ const cipher = crypto.createCipheriv("aes-256-gcm", key, iv, GCM);
132
+ if (bound) {
133
+ cipher.setAAD(vaultAad(tenant, workspace, name, rec));
134
+ rec.v = 2;
135
+ }
136
+ const enc = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
137
+ rec.iv = iv.toString("base64");
138
+ rec.tag = cipher.getAuthTag().toString("base64");
139
+ rec.data = enc.toString("base64");
140
+ return rec;
141
+ }
142
+ /** A record's value, opened for its place; null when it will not open. */
143
+ function openRecord(tenant, workspace, name, rec) {
144
+ if (!rec)
145
+ return null;
146
+ if (rec.v === 2) {
147
+ // Metadata someone rewrote into a shape that is not a list fails closed,
148
+ // like a bad tag — never as an exception up the read path.
149
+ let aad;
150
+ try {
151
+ aad = vaultAad(tenant, workspace, name, rec);
152
+ }
153
+ catch {
154
+ return null;
155
+ }
156
+ return decrypt(tenant, rec, aad);
157
+ }
158
+ return requireBound() ? null : decrypt(tenant, rec);
159
+ }
79
160
  function read(tenant, workspace) {
80
161
  return readFile(secretsFile(tenant, workspace));
81
162
  }
@@ -162,18 +243,15 @@ export function setSecret(tenant, name, value, workspace, kind) {
162
243
  }
163
244
  if (value.length > 8192)
164
245
  throw new Error("secret too long");
165
- const sealed = encryptValue(tenant, value);
166
246
  mutateVaultFile(secretsFile(tenant, workspace), (all) => {
167
247
  // A new value keeps the sites the old one was bound to: binding is a
168
248
  // property of the credential's use, not of its current value, and a
169
249
  // rotation that silently unbound it would put the new value in pods.
250
+ // Sealed here, under the lock: a bound record's AAD names those sites.
170
251
  const sites = all[name]?.sites;
171
- all[name] = {
172
- ...sealed,
173
- updatedAt: new Date().toISOString(),
174
- ...(kind ? { kind } : {}),
175
- ...(sites?.length ? { sites } : {}),
176
- };
252
+ // Once bound, always bound: a re-save with the switch off must not
253
+ // quietly drop the record's authentication.
254
+ all[name] = sealRecord(tenant, workspace, name, value, { kind, sites }, all[name]?.v === 2 || writeBound());
177
255
  return all;
178
256
  });
179
257
  }
@@ -205,6 +283,16 @@ export function setSecretSites(tenant, name, sites, workspace) {
205
283
  if (rec.kind === "client-cert" && clean.some((s) => s.startsWith("*."))) {
206
284
  throw new Error(`${name} is a client certificate — bind it to exact hosts, not a wildcard`);
207
285
  }
286
+ // A bound record names its sites in its seal: open it with the old
287
+ // binding and seal it again with the new one. One that will not open is
288
+ // refused, never relabelled.
289
+ if (rec.v === 2) {
290
+ const value = openRecord(tenant, workspace, name, rec);
291
+ if (value === null)
292
+ throw new Error(`${name} cannot be read with this install's keys — set its value again before binding it`);
293
+ all[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: clean, updatedAt: rec.updatedAt }, true);
294
+ return all;
295
+ }
208
296
  if (clean.length)
209
297
  rec.sites = clean;
210
298
  else
@@ -225,7 +313,11 @@ export function secretSites(tenant, names, workspace) {
225
313
  const own = workspace ? read(tenant, workspace) : {};
226
314
  const account = read(tenant);
227
315
  for (const name of names) {
228
- const rec = own[name] ?? account[name];
316
+ // The record getSecret would take the VALUE from — the workspace's only
317
+ // when it opens, else the account's. Picking by presence paired an
318
+ // account value with a workspace record's (unauthenticated) sites.
319
+ const ownRec = own[name];
320
+ const rec = ownRec && openRecord(tenant, workspace, name, ownRec) !== null ? ownRec : account[name];
229
321
  if (rec?.sites?.length)
230
322
  out[name] = [...rec.sites];
231
323
  }
@@ -260,9 +352,12 @@ export function encryptValue(tenant, value) {
260
352
  export function decryptValue(tenant, rec) {
261
353
  return decrypt(tenant, rec);
262
354
  }
263
- function decrypt(tenant, rec) {
355
+ function decrypt(tenant, rec, aad) {
264
356
  if (!rec)
265
357
  return null;
358
+ // A bound record opens only with its binding: one without is not a guess.
359
+ if (rec.v === 2 && !aad)
360
+ return null;
266
361
  try {
267
362
  // The record says which key wrote it. An unmarked one predates per-account
268
363
  // keys and is under the install key; guessing either way would turn a
@@ -286,6 +381,8 @@ function decrypt(tenant, rec) {
286
381
  }
287
382
  const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(rec.iv, "base64"), GCM);
288
383
  decipher.setAuthTag(Buffer.from(rec.tag, "base64"));
384
+ if (aad)
385
+ decipher.setAAD(aad);
289
386
  return Buffer.concat([
290
387
  decipher.update(Buffer.from(rec.data, "base64")),
291
388
  decipher.final(),
@@ -298,11 +395,11 @@ function decrypt(tenant, rec) {
298
395
  /** Nearest wins: a workspace secret shadows an account one of the same name. */
299
396
  export function getSecret(tenant, name, workspace) {
300
397
  if (workspace) {
301
- const own = decrypt(tenant, read(tenant, workspace)[name]);
398
+ const own = openRecord(tenant, workspace, name, read(tenant, workspace)[name]);
302
399
  if (own !== null)
303
400
  return { value: own, scope: "workspace" };
304
401
  }
305
- const shared = decrypt(tenant, read(tenant)[name]);
402
+ const shared = openRecord(tenant, undefined, name, read(tenant)[name]);
306
403
  return shared === null ? null : { value: shared, scope: "account" };
307
404
  }
308
405
  export function deleteSecret(tenant, name, workspace) {
@@ -408,7 +505,7 @@ export function rotateMasterKey(tenant, from, to) {
408
505
  const newKey = crypto.createHash("sha256").update(to).digest();
409
506
  const rewritten = [];
410
507
  const unreadable = [];
411
- for (const { file } of secretFiles(tenant)) {
508
+ for (const { file, workspace } of secretFiles(tenant)) {
412
509
  let count = 0;
413
510
  mutateVaultFile(file, (all) => {
414
511
  const next = {};
@@ -423,10 +520,16 @@ export function rotateMasterKey(tenant, from, to) {
423
520
  next[name] = rec;
424
521
  continue;
425
522
  }
523
+ // A bound record keeps its binding: same place, same AAD.
524
+ let aad = null;
426
525
  let plain;
427
526
  try {
527
+ if (rec.v === 2)
528
+ aad = vaultAad(tenant, workspace, name, rec);
428
529
  const d = crypto.createDecipheriv("aes-256-gcm", oldKey, Buffer.from(rec.iv, "base64"), GCM);
429
530
  d.setAuthTag(Buffer.from(rec.tag, "base64"));
531
+ if (aad)
532
+ d.setAAD(aad);
430
533
  plain = Buffer.concat([d.update(Buffer.from(rec.data, "base64")), d.final()]).toString("utf8");
431
534
  }
432
535
  catch {
@@ -436,6 +539,8 @@ export function rotateMasterKey(tenant, from, to) {
436
539
  }
437
540
  const iv = crypto.randomBytes(12);
438
541
  const c = crypto.createCipheriv("aes-256-gcm", newKey, iv, GCM);
542
+ if (aad)
543
+ c.setAAD(aad);
439
544
  const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
440
545
  next[name] = {
441
546
  iv: iv.toString("base64"),
@@ -445,6 +550,10 @@ export function rotateMasterKey(tenant, from, to) {
445
550
  // Kept: without it an oauth2 secret stops being refreshed after a
446
551
  // rotation, and a file/ssh/api secret stops being materialised.
447
552
  ...(rec.kind ? { kind: rec.kind } : {}),
553
+ // Kept too (it was dropped until 29 Oct 2026): a rotation that
554
+ // unbinds every bound secret puts their values into pods.
555
+ ...(rec.sites?.length ? { sites: rec.sites } : {}),
556
+ ...(aad ? { v: 2 } : {}),
448
557
  };
449
558
  count++;
450
559
  }
@@ -519,8 +628,11 @@ export function replaceOAuth2RefreshToken(tenant, name, workspace, usedValue, re
519
628
  let replaced = false;
520
629
  mutateVaultFile(secretsFile(tenant, scope), (all) => {
521
630
  const rec = all[name];
522
- if (rec && decrypt(tenant, rec) === usedValue) {
523
- all[name] = { ...rec, ...encryptValue(tenant, next), updatedAt: new Date().toISOString() };
631
+ if (rec && openRecord(tenant, scope, name, rec) === usedValue) {
632
+ // Built whole, not spread over the old record: a spread kept a stale
633
+ // `k: "tenant"` when this seal fell back to the install key, and the
634
+ // record then opened with neither.
635
+ all[name] = sealRecord(tenant, scope, name, next, { kind: rec.kind, sites: rec.sites }, rec.v === 2 || writeBound());
524
636
  replaced = true;
525
637
  }
526
638
  return all;
@@ -641,7 +753,9 @@ ctx) {
641
753
  // the next run does not refresh again.
642
754
  const next = OAUTH2_PREFIX + JSON.stringify({ ...config, refresh_token: live.rotated });
643
755
  try {
644
- if (ctx)
756
+ if (ctx?.onRotate)
757
+ await ctx.onRotate(name, value, next);
758
+ else if (ctx)
645
759
  replaceOAuth2RefreshToken(ctx.tenant, name, ctx.workspace, value, live.rotated);
646
760
  }
647
761
  catch {
@@ -849,7 +963,7 @@ export function setClientCertSecret(tenant, name, config, workspace) {
849
963
  };
850
964
  if (!SECRET_NAME.test(name))
851
965
  throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
852
- const sealed = encryptValue(tenant, CLIENT_CERT_PREFIX + JSON.stringify(stored));
966
+ const plainCert = CLIENT_CERT_PREFIX + JSON.stringify(stored);
853
967
  // Written under the vault's own lock, with the binding checked in the same
854
968
  // step: a value set over a secret keeps its site binding, and a wildcard
855
969
  // one would present the certificate to a whole domain.
@@ -858,7 +972,7 @@ export function setClientCertSecret(tenant, name, config, workspace) {
858
972
  if (sites?.some((site) => site.startsWith("*."))) {
859
973
  throw new Error(`${name} is bound to ${sites.join(", ")} — a client certificate is bound to exact hosts; unbind the wildcard first`);
860
974
  }
861
- all[name] = { ...sealed, updatedAt: new Date().toISOString(), kind: "client-cert", ...(sites?.length ? { sites } : {}) };
975
+ all[name] = sealRecord(tenant, workspace, name, plainCert, { kind: "client-cert", sites }, all[name]?.v === 2 || writeBound());
862
976
  return all;
863
977
  });
864
978
  }
@@ -944,6 +1058,46 @@ export function isApiValue(value) {
944
1058
  export function apiConfigOf(value) {
945
1059
  return JSON.parse(value.slice(API_PREFIX.length));
946
1060
  }
1061
+ /**
1062
+ * Seal every unbound record of an account for its place (v2). Run at boot
1063
+ * once the install writes bound records (FOLDRUN_VAULT_WRITE_BOUND=1), and by
1064
+ * scripts/seal-vault-bindings.mjs. Per file, all or nothing: every unbound
1065
+ * value is opened first, and a file holding one that will not open is left
1066
+ * exactly as it is and reported. Idempotent: bound records are skipped.
1067
+ */
1068
+ export function sealVaultBindings(tenant) {
1069
+ assertSafeName(tenant, "tenant");
1070
+ let sealed = 0;
1071
+ const unreadable = [];
1072
+ for (const { file, workspace } of secretFiles(tenant)) {
1073
+ mutateVaultFile(file, (all) => {
1074
+ const plain = new Map();
1075
+ const bad = [];
1076
+ for (const [name, rec] of Object.entries(all)) {
1077
+ if (rec.v === 2)
1078
+ continue;
1079
+ const value = decrypt(tenant, rec);
1080
+ if (value === null)
1081
+ bad.push(workspace ? `${workspace}/${name}` : name);
1082
+ else
1083
+ plain.set(name, value);
1084
+ }
1085
+ unreadable.push(...bad);
1086
+ if (plain.size === 0 || bad.length)
1087
+ return null;
1088
+ const next = { ...all };
1089
+ for (const [name, value] of plain) {
1090
+ const rec = all[name];
1091
+ next[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: rec.sites, updatedAt: rec.updatedAt }, true);
1092
+ }
1093
+ sealed += plain.size;
1094
+ return next;
1095
+ });
1096
+ }
1097
+ return { sealed, unreadable };
1098
+ }
1099
+ /** Whether this install writes bound vault records (and migrates at boot). */
1100
+ export const vaultWritesBound = writeBound;
947
1101
  /**
948
1102
  * Move an account's secrets from the install key onto its own key.
949
1103
  *
@@ -958,7 +1112,7 @@ export function rewrapTenantSecrets(tenant) {
958
1112
  return { moved: 0, unreadable: [] };
959
1113
  let moved = 0;
960
1114
  const unreadable = [];
961
- for (const { file } of secretFiles(tenant)) {
1115
+ for (const { file, workspace } of secretFiles(tenant)) {
962
1116
  // Under the vault's lock: every pod runs this at boot, and a save made
963
1117
  // between our read and our write must not be lost.
964
1118
  mutateVaultFile(file, (all) => {
@@ -967,7 +1121,7 @@ export function rewrapTenantSecrets(tenant) {
967
1121
  for (const [name, rec] of Object.entries(all)) {
968
1122
  if (rec.k === "tenant")
969
1123
  continue; // already moved
970
- const value = decrypt(tenant, rec);
1124
+ const value = openRecord(tenant, workspace, name, rec);
971
1125
  if (value === null) {
972
1126
  unreadableHere.push(name);
973
1127
  continue;
@@ -982,12 +1136,7 @@ export function rewrapTenantSecrets(tenant) {
982
1136
  return null;
983
1137
  const next = { ...all };
984
1138
  for (const [name, value] of plain) {
985
- next[name] = {
986
- ...encryptValue(tenant, value),
987
- updatedAt: all[name].updatedAt,
988
- ...(all[name].kind ? { kind: all[name].kind } : {}),
989
- ...(all[name].sites?.length ? { sites: all[name].sites } : {}),
990
- };
1139
+ next[name] = sealRecord(tenant, workspace, name, value, { kind: all[name].kind, sites: all[name].sites, updatedAt: all[name].updatedAt }, all[name].v === 2 || writeBound());
991
1140
  }
992
1141
  moved += plain.size;
993
1142
  return next;
@@ -11,15 +11,21 @@ export declare function anthropicUsage(u: Json | undefined, wire: TranslatorWire
11
11
  cached: number;
12
12
  output: number;
13
13
  };
14
- export type TranslatorWire = "chat" | "responses";
14
+ /** `anthropic` is no translation at all: the host-side passthrough for an
15
+ * endpoint that already speaks Messages, so the SDK on the host talks only
16
+ * to loopback and every byte to the tenant's URL goes through the guard
17
+ * (FOL-95). A step never gets it (translatorSpecFor returns null there). */
18
+ export type TranslatorWire = "chat" | "responses" | "anthropic";
15
19
  export interface TranslatorSpec {
16
20
  /** The provider's base, e.g. https://api.openai.com/v1 — `/chat/completions`
17
21
  * or `/responses` is appended, by `wire`. */
18
22
  upstreamBase: string;
19
23
  /** Which OpenAI shape the endpoint speaks. Default chat. */
20
24
  wire?: TranslatorWire;
21
- /** Already resolved. Sent as `Authorization: Bearer`. */
25
+ /** Already resolved. Sent as `Authorization: Bearer`, or as `x-api-key`
26
+ * where `upstreamAuth` says so (the anthropic wire only). */
22
27
  upstreamKey: string;
28
+ upstreamAuth?: "bearer" | "x-api-key";
23
29
  /** Extra headers the provider wants, already resolved. */
24
30
  headers?: Record<string, string>;
25
31
  maxTokensParam?: "max_tokens" | "max_completion_tokens";