@foldrun/core 8.0.1 → 9.1.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.
Files changed (47) hide show
  1. package/dist/src/approvals.d.ts +18 -10
  2. package/dist/src/approvals.js +51 -1
  3. package/dist/src/catalog.d.ts +14 -0
  4. package/dist/src/catalog.js +28 -0
  5. package/dist/src/cron.d.ts +9 -0
  6. package/dist/src/cron.js +20 -0
  7. package/dist/src/frontmatter-fields.js +1 -1
  8. package/dist/src/frontmatter-schema.d.ts +5 -1
  9. package/dist/src/frontmatter-schema.js +40 -1
  10. package/dist/src/history.d.ts +5 -0
  11. package/dist/src/history.js +31 -4
  12. package/dist/src/hooks.d.ts +2 -0
  13. package/dist/src/hooks.js +13 -0
  14. package/dist/src/library.js +4 -0
  15. package/dist/src/notify.d.ts +21 -4
  16. package/dist/src/notify.js +105 -22
  17. package/dist/src/package.d.ts +5 -1
  18. package/dist/src/package.js +5 -3
  19. package/dist/src/runner.js +17 -9
  20. package/dist/src/scan.js +21 -0
  21. package/dist/src/secrets.d.ts +92 -10
  22. package/dist/src/secrets.js +258 -28
  23. package/dist/src/storage.d.ts +76 -4
  24. package/dist/src/storage.js +219 -22
  25. package/dist/src/store.d.ts +20 -2
  26. package/dist/src/store.js +50 -15
  27. package/dist/src/triggers.js +2 -2
  28. package/dist/src/webhook.d.ts +19 -3
  29. package/dist/src/webhook.js +19 -6
  30. package/package.json +1 -1
  31. package/src/approvals.ts +55 -1
  32. package/src/catalog.ts +27 -0
  33. package/src/cron.ts +19 -0
  34. package/src/frontmatter-fields.ts +1 -1
  35. package/src/frontmatter-schema.ts +38 -2
  36. package/src/history.ts +31 -4
  37. package/src/hooks.ts +14 -0
  38. package/src/library.ts +3 -0
  39. package/src/notify.ts +104 -23
  40. package/src/package.ts +5 -3
  41. package/src/runner.ts +17 -8
  42. package/src/scan.ts +23 -0
  43. package/src/secrets.ts +283 -34
  44. package/src/storage.ts +223 -19
  45. package/src/store.ts +49 -14
  46. package/src/triggers.ts +2 -2
  47. package/src/webhook.ts +26 -6
@@ -33,7 +33,8 @@
33
33
  import crypto from "node:crypto";
34
34
  import { readAgentsMd } from "./runner.js";
35
35
  import { accountDir, workspaceDir, runCost, readFlow, runVerdict } from "./store.js";
36
- import { getSecret } from "./secrets.js";
36
+ import { getSecret, secretSites, signingSecretShown, accountSecretUsableIn } from "./secrets.js";
37
+ import { siteCovers } from "./network.js";
37
38
  import { publicUrl } from "./webhook.js";
38
39
  import { approveLinkPath, approveLinkTtlMs } from "./approvals.js";
39
40
  import { noteSecretUse, healthKey } from "./secret-health.js";
@@ -61,12 +62,16 @@ export function platformMail(tenant) {
61
62
  return { key, from: platform.setting("FOLDRUN_EMAIL_FROM") || "foldrun <hello@foldrun.io>" };
62
63
  return own;
63
64
  }
64
- /** The account's own Resend key and sender, or null when it has none. */
65
- export function accountMail(tenant) {
66
- const own = getSecret(tenant, "RESEND_API_KEY");
65
+ /** The account's own Resend key and sender, or null when it has none.
66
+ * With a workspace — mail a workspace's run causes — an account key or
67
+ * sender limited to other workspaces is not used (30 Oct 2026); the
68
+ * account's own mail (resets, invites) names none and is not limited. */
69
+ export function accountMail(tenant, workspace) {
70
+ const usable = (name) => !workspace || accountSecretUsableIn(tenant, name, workspace);
71
+ const own = usable("RESEND_API_KEY") ? getSecret(tenant, "RESEND_API_KEY") : null;
67
72
  if (!own)
68
73
  return null;
69
- const from = getSecret(tenant, "EMAIL_FROM")?.value?.trim() || "foldrun <onboarding@resend.dev>";
74
+ const from = (usable("EMAIL_FROM") ? getSecret(tenant, "EMAIL_FROM")?.value?.trim() : "") || "foldrun <onboarding@resend.dev>";
70
75
  return { key: own.value, from };
71
76
  }
72
77
  /**
@@ -79,8 +84,17 @@ export function accountMail(tenant) {
79
84
  * owner's bin. The platform's key is the fallback for an account that set
80
85
  * none, so `notify:` works before anyone has configured mail at all.
81
86
  */
82
- export function notifyMail(tenant) {
83
- return accountMail(tenant) ?? platformMail(tenant);
87
+ export function notifyMail(tenant, workspace) {
88
+ if (!workspace)
89
+ return accountMail(tenant) ?? platformMail(tenant);
90
+ // A workspace's notification: the account's sender only where it may be
91
+ // used, else the platform's — never platformMail's fall back to the
92
+ // account's own key, which would walk around the limit.
93
+ const own = accountMail(tenant, workspace);
94
+ if (own)
95
+ return own;
96
+ const key = process.env.FOLDRUN_RESEND_API_KEY;
97
+ return key ? { key, from: platform.setting("FOLDRUN_EMAIL_FROM") || "foldrun <hello@foldrun.io>" } : null;
84
98
  }
85
99
  /** Was the mail sent on the account's own key? Only then is a refusal from
86
100
  * Resend a fact about a secret the account holds. Recording a platform-key
@@ -145,7 +159,7 @@ export function signatureHeaders(tenant, workspace, config, body) {
145
159
  return {};
146
160
  const secret = getSecret(tenant, config.signingSecret, workspace);
147
161
  if (!secret) {
148
- console.error(`[foldrun] notify: signing_secret names ${config.signingSecret}, which is not in the vault for ${tenant}/${workspace} — sending unsigned`);
162
+ console.error(`[foldrun] notify: signing_secret names ${signingSecretShown(config.signingSecret)}, which is not in the vault for ${tenant}/${workspace} — sending unsigned`);
149
163
  return {};
150
164
  }
151
165
  const timestamp = Math.floor(Date.now() / 1000).toString();
@@ -180,15 +194,80 @@ export function newDeliveryId() {
180
194
  return `dlv_${crypto.randomUUID().replace(/-/g, "")}`;
181
195
  }
182
196
  /** The URL with ${SECRET}s resolved, and how it may be shown: a Slack hook
183
- * URL is itself a credential, so it is never echoed resolved. */
197
+ * URL is itself a credential, so it is never echoed resolved.
198
+ *
199
+ * A secret bound to sites (`foldrun secrets sites`) is filled only when the
200
+ * resolved URL's host is one of them — the rule the egress proxy and the
201
+ * tool tester keep (30 Oct 2026). Before, notify.url filled any secret into
202
+ * any host, so an editor could write `https://their.server/?k=${STRIPE_KEY}`
203
+ * and press Test. The host is read AFTER filling, because the usual shape is
204
+ * the whole URL as one secret (`${SLACK_WEBHOOK_URL}`, bound to
205
+ * hooks.slack.com). An unbound secret fills as it does for an agent's
206
+ * `secrets:` and a tool test — workspace first, then the account — since
207
+ * binding is how an account says where a value may go. `refused` says why
208
+ * `url` is null when a binding stopped it; `filled` maps each value put
209
+ * into the URL to its name, so an error that echoes the URL can be scrubbed. */
184
210
  export function webhookTarget(tenant, workspace, config = notifyConfig(tenant, workspace)) {
185
211
  const raw = config?.url ?? "";
212
+ const filled = {};
213
+ // Every name put in, kept apart from `filled` (value → name, for
214
+ // scrubbing): two secrets with the same value would otherwise leave only
215
+ // the last name there, and the first one's binding would go unchecked.
216
+ const used = new Set();
186
217
  const url = raw.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => {
187
218
  const hit = getSecret(tenant, name, workspace);
219
+ if (hit) {
220
+ filled[hit.value] = name;
221
+ used.add(name);
222
+ }
188
223
  return hit ? hit.value : whole;
189
224
  });
190
225
  const shown = raw.includes("${") ? raw : raw ? `${raw.slice(0, 40)}${raw.length > 40 ? "…" : ""}` : "none";
191
- return { url: !url || url.includes("${") ? null : url, shown };
226
+ if (!url || url.includes("${"))
227
+ return { url: null, shown, filled: {} };
228
+ const bound = secretSites(tenant, [...used], workspace);
229
+ if (Object.keys(bound).length) {
230
+ let host = "";
231
+ try {
232
+ host = new URL(url).hostname;
233
+ }
234
+ catch {
235
+ // Not a URL: no host can be one of its sites.
236
+ }
237
+ const offSite = Object.entries(bound).filter(([, sites]) => !host || !sites.some((site) => siteCovers(site, host))).map(([n]) => n);
238
+ if (offSite.length) {
239
+ return {
240
+ url: null,
241
+ shown,
242
+ filled: {},
243
+ // The host is not echoed: it is read from the FILLED url, so
244
+ // `https://${KEY}.their.server/` would print the key back.
245
+ refused: `${offSite.join(", ")} ${offSite.length === 1 ? "is" : "are"} bound to other sites than notify.url's host — not sent`,
246
+ };
247
+ }
248
+ }
249
+ return { url, shown, filled };
250
+ }
251
+ /** An error about a request to a resolved notify.url, with every secret value
252
+ * put back as its ${NAME} — a redirect refusal quotes the URL it was sent to,
253
+ * and the delivery log that keeps it is read by every viewer. */
254
+ function scrubTarget(text, filled) {
255
+ let out = text;
256
+ // Longest first, so a value inside another is not half-replaced.
257
+ for (const value of Object.keys(filled).sort((a, b) => b.length - a.length)) {
258
+ if (!value)
259
+ continue;
260
+ const variants = new Set([value]);
261
+ try {
262
+ variants.add(encodeURI(value)).add(encodeURIComponent(value));
263
+ }
264
+ catch {
265
+ // A lone surrogate cannot be encoded, so it cannot appear encoded.
266
+ }
267
+ for (const v of variants)
268
+ out = out.split(v).join(`\${${filled[value]}}`);
269
+ }
270
+ return out;
192
271
  }
193
272
  /**
194
273
  * One attempt at one delivery, against the workspace's notify: as it reads
@@ -204,10 +283,13 @@ export async function attemptWebhook(tenant, workspace, d) {
204
283
  if (!config?.url) {
205
284
  return { ...base, ok: false, statusCode: null, durationMs: 0, response: null, error: "this workspace's notify: has no url any more" };
206
285
  }
207
- const { url } = webhookTarget(tenant, workspace, config);
286
+ const { url, refused, filled } = webhookTarget(tenant, workspace, config);
208
287
  if (!url) {
209
- return { ...base, ok: false, statusCode: null, durationMs: 0, response: null, error: "the secret named in notify.url is not in this account's vault" };
288
+ return { ...base, ok: false, statusCode: null, durationMs: 0, response: null, error: refused ?? "the secret named in notify.url is not in this account's vault" };
210
289
  }
290
+ // A URL that carries a secret is a credential: a redirect is followed only
291
+ // within the origin it was sent to, as the tool tester does.
292
+ const carriesSecret = Object.keys(filled).length > 0;
211
293
  try {
212
294
  const res = await fetchUntrusted(url, {
213
295
  method: "POST",
@@ -222,7 +304,7 @@ export async function attemptWebhook(tenant, workspace, d) {
222
304
  },
223
305
  body: d.body,
224
306
  signal: AbortSignal.timeout(Number(process.env.FOLDRUN_WEBHOOK_TIMEOUT_MS) || 8000),
225
- });
307
+ }, { sameOrigin: carriesSecret });
226
308
  const text = (await res.text().catch(() => "")).slice(0, 500);
227
309
  return {
228
310
  ...base,
@@ -235,7 +317,7 @@ export async function attemptWebhook(tenant, workspace, d) {
235
317
  }
236
318
  catch (err) {
237
319
  const why = err instanceof Error ? (err.name === "TimeoutError" ? "timed out" : err.message) : String(err);
238
- return { ...base, ok: false, statusCode: null, durationMs: Date.now() - started, response: null, error: why.slice(0, 500) };
320
+ return { ...base, ok: false, statusCode: null, durationMs: Date.now() - started, response: null, error: scrubTarget(why, filled).slice(0, 500) };
239
321
  }
240
322
  }
241
323
  /**
@@ -335,7 +417,7 @@ export async function sendPlainNotificationOutcome(tenant, workspace, msg) {
335
417
  };
336
418
  try {
337
419
  if (config.email) {
338
- const mail = notifyMail(tenant);
420
+ const mail = notifyMail(tenant, workspace);
339
421
  if (!mail)
340
422
  return no;
341
423
  const r = await sendMail({
@@ -386,7 +468,7 @@ export async function sendTestNotification(tenant, workspace) {
386
468
  const headline = "\u2713 test notification";
387
469
  const detail = `Sent from ${workspace} at ${sent}. If you are reading this, failures and approval gates will reach you here too.`;
388
470
  if (config.email) {
389
- const mail = notifyMail(tenant);
471
+ const mail = notifyMail(tenant, workspace);
390
472
  if (!mail) {
391
473
  return {
392
474
  ok: false,
@@ -411,9 +493,9 @@ export async function sendTestNotification(tenant, workspace) {
411
493
  // The destination is echoed back with the secret still unresolved: a
412
494
  // Slack webhook URL is itself a credential, and a page that prints it is
413
495
  // a page that leaks it into a screenshot.
414
- const { url, shown } = webhookTarget(tenant, workspace, config);
496
+ const { url, shown, refused } = webhookTarget(tenant, workspace, config);
415
497
  if (!url) {
416
- return { ok: false, destination: shown, detail: `the secret named in notify.url is not in this account's vault` };
498
+ return { ok: false, destination: shown, detail: refused ?? `the secret named in notify.url is not in this account's vault` };
417
499
  }
418
500
  const payload = JSON.stringify({ text: `${headline} — ${detail}`, workspace, status: "test", summary: detail });
419
501
  // One attempt, recorded in the delivery log like any other — and the way
@@ -424,7 +506,7 @@ export async function sendTestNotification(tenant, workspace) {
424
506
  ? {
425
507
  ok: true,
426
508
  destination: shown,
427
- detail: `${config.signingSecret ? "accepted, signed with " + config.signingSecret : "accepted"} (delivery ${r.deliveryId})`,
509
+ detail: `${config.signingSecret ? "accepted, signed with " + signingSecretShown(config.signingSecret) : "accepted"} (delivery ${r.deliveryId})`,
428
510
  }
429
511
  : { ok: false, destination: shown, detail: `${r.statusCode ? `HTTP ${r.statusCode} — ` : ""}${r.detail.replace(/\s+/g, " ").slice(0, 400)}` };
430
512
  }
@@ -510,7 +592,7 @@ export async function sendRunNotification(tenant, workspace, run) {
510
592
  };
511
593
  try {
512
594
  if (config.email) {
513
- const mail = notifyMail(tenant);
595
+ const mail = notifyMail(tenant, workspace);
514
596
  if (!mail) {
515
597
  console.error(`[foldrun] notify: email configured but no mail credential — set RESEND_API_KEY (and EMAIL_FROM) on the account ${tenant}, or FOLDRUN_RESEND_API_KEY on the platform`);
516
598
  return false;
@@ -554,8 +636,9 @@ export async function sendRunNotification(tenant, workspace, run) {
554
636
  }
555
637
  // ${SECRET} so the Slack URL — itself a credential — can live in the
556
638
  // vault instead of in a file that gets committed.
557
- if (!webhookTarget(tenant, workspace, config).url) {
558
- console.error(`[foldrun] notify: secret in URL not set for ${tenant}/${workspace}`);
639
+ const target = webhookTarget(tenant, workspace, config);
640
+ if (!target.url) {
641
+ console.error(`[foldrun] notify: ${target.refused ?? "secret in URL not set"} for ${tenant}/${workspace}`);
559
642
  return false;
560
643
  }
561
644
  const event = blocked ? "blocked" : run.status;
@@ -20,7 +20,11 @@ export interface Package {
20
20
  /** An error the API layer turns into a status rather than a 500. */
21
21
  export declare class PackageError extends Error {
22
22
  readonly status: number;
23
- constructor(message: string, status: number);
23
+ /** `cause`: the error underneath a 500, so an HTTP caller can log it and
24
+ * answer without it — a Node error names the server's paths (30 Oct 2026). */
25
+ constructor(message: string, status: number, opts?: {
26
+ cause?: unknown;
27
+ });
24
28
  }
25
29
  export declare const MANIFEST_FILE = "foldrun-package.json";
26
30
  export declare function isPackagedPath(rel: string): boolean;
@@ -37,8 +37,10 @@ import { zip, unzip, ZipError } from "./zip.js";
37
37
  /** An error the API layer turns into a status rather than a 500. */
38
38
  export class PackageError extends Error {
39
39
  status;
40
- constructor(message, status) {
41
- super(message);
40
+ /** `cause`: the error underneath a 500, so an HTTP caller can log it and
41
+ * answer without it — a Node error names the server's paths (30 Oct 2026). */
42
+ constructor(message, status, opts) {
43
+ super(message, opts);
42
44
  this.status = status;
43
45
  }
44
46
  }
@@ -371,7 +373,7 @@ export function applyImport(tenant, workspace, pkg, opts = {}) {
371
373
  // best effort — the error below is the one to report
372
374
  }
373
375
  }
374
- throw new PackageError(`the import could not be written, and nothing was kept: ${err instanceof Error ? err.message : String(err)}`, 500);
376
+ throw new PackageError(`the import could not be written, and nothing was kept: ${err instanceof Error ? err.message : String(err)}`, 500, { cause: err });
375
377
  }
376
378
  for (const c of changes) {
377
379
  const abs = path.join(dir, c.path);
@@ -41,7 +41,7 @@ import { ASK_TOOL, PEER_TOOLS, applyOperatorEvent, askPromptLine, askTimeoutSec,
41
41
  import { sendPlainNotification } from "./notify.js";
42
42
  import { TOOL_MAP, BUILTIN_TOOLS, ownToolNames, toolRefs, legacyUseNames, legacyUseError, retiredToolNames, retiredToolError } from "./tool-names.js";
43
43
  import { refNames } from "./refs.js";
44
- import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, liveRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, isSplicedRecord, runSummary, verdictOf, sumAttempts, tokensOf, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, } from "./store.js";
44
+ import { accountDir, workspaceDir, buildMemoryIndex, workspaceMemoryIndex, knowledgeIndex, workspaceKnowledgeIndex, workspaceTools, brokenToolReport, listWorkspaces, listTenants, listRuns, liveRuns, syncBundleFor, parseToolDef, readFlow, markerPresent, whenRowsPath, parseApis, resolveModel, parseProvider, providerEnvFor, resolveEffort, isEffortWord, EFFORT_LEVELS, checkFormatVersion, readRun, writeRun, isSplicedRecord, runSummary, verdictOf, sumAttempts, tokensOf, parseSchemaText, STORAGE_DIR, adoptLegacyVersionKey, listAgents, isOneSegment, } from "./store.js";
45
45
  import { canReadBack } from "./tool-output.js";
46
46
  import { PromptLayers, promptCacheKey } from "./prompt-layers.js";
47
47
  import { loadCatalog, checkModel, clampEffort, catalogCost, findModel, tokenPriceOf } from "./catalog.js";
@@ -4694,8 +4694,12 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4694
4694
  // resuming around.
4695
4695
  if (step.status !== "pending")
4696
4696
  continue;
4697
- const aDir = path.join(pDir, "agents", step.agent);
4698
- if (!fs.existsSync(path.join(aDir, "agent.md"))) {
4697
+ // One folder under agents/, never a path (30 Oct 2026): an ad-hoc
4698
+ // run's agent came from the URL, and `../../<tenant>/…` joined here
4699
+ // ran another account's agent. Refused as not found, before the
4700
+ // existence check could answer for a path outside.
4701
+ const aDir = isOneSegment(step.agent) ? path.join(pDir, "agents", step.agent) : null;
4702
+ if (!aDir || !fs.existsSync(path.join(aDir, "agent.md"))) {
4699
4703
  step.status = "failed";
4700
4704
  step.events.push({
4701
4705
  t: new Date().toISOString(),
@@ -4800,15 +4804,18 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4800
4804
  if (needsApproval.length) {
4801
4805
  for (const step of needsApproval) {
4802
4806
  step.status = "awaiting-approval";
4807
+ const t = new Date().toISOString();
4803
4808
  step.events.push({
4804
- t: new Date().toISOString(),
4809
+ t,
4805
4810
  type: "info",
4806
4811
  // ask: is the same gate carrying a question — the answer
4807
4812
  // arrives as the approval note and rides into the prompt.
4813
+ // An event URL is this step's and this park's alone (30 Oct
4814
+ // 2026): the index and this line's `t` are in its token.
4808
4815
  text: step.ask
4809
4816
  ? `waiting for an answer: ${step.ask}`
4810
4817
  : step.waitFor === "event"
4811
- ? `waiting for an external event — release it with POST ${eventUrl(tenant, workspace, run.id)}`
4818
+ ? `waiting for an external event — release it with POST ${eventUrl(tenant, workspace, run.id, { step: run.steps.indexOf(step), at: t })}`
4812
4819
  : "waiting for approval before running",
4813
4820
  });
4814
4821
  }
@@ -4918,8 +4925,9 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
4918
4925
  // step of the flow (isSplicedRecord). It gets no delegateJob of its
4919
4926
  // own: depth one.
4920
4927
  const delegateFor = (parent) => async (agent, job, cancelled) => {
4921
- const dir = path.join(pDir, "agents", agent);
4922
- if (!fs.existsSync(path.join(dir, "agent.md")))
4928
+ // One folder under agents/, never a path — as the group check above.
4929
+ const dir = isOneSegment(agent) ? path.join(pDir, "agents", agent) : null;
4930
+ if (!dir || !fs.existsSync(path.join(dir, "agent.md")))
4923
4931
  return { status: "failed", result: `${agent} is not an agent in this workspace` };
4924
4932
  // A stable id on both records: an index into run.steps moves when a
4925
4933
  // rescue or a fan-out is spliced in before the child.
@@ -5335,8 +5343,8 @@ function driveRunInner(tenant, workspace, run, modelOverride, tags, opts) {
5335
5343
  const rescueStep = async (step) => {
5336
5344
  if (!step.onFail)
5337
5345
  return;
5338
- const rescuerDir = path.join(pDir, "agents", step.onFail);
5339
- if (!fs.existsSync(path.join(rescuerDir, "agent.md"))) {
5346
+ const rescuerDir = isOneSegment(step.onFail) ? path.join(pDir, "agents", step.onFail) : null;
5347
+ if (!rescuerDir || !fs.existsSync(path.join(rescuerDir, "agent.md"))) {
5340
5348
  step.events.push({
5341
5349
  t: new Date().toISOString(),
5342
5350
  type: "error",
package/dist/src/scan.js CHANGED
@@ -202,6 +202,27 @@ export const SCAN_RULES = [
202
202
  title: "A required frontmatter field is missing",
203
203
  help: "A skill with no description is never loaded: the model chooses skills by their descriptions.",
204
204
  },
205
+ {
206
+ // 30 Oct 2026: check and the scheduler read a schedule with the same
207
+ // cronCanFire (core cron.ts), so check says what the scheduler does.
208
+ id: "config/cron-never-fires",
209
+ category: "config",
210
+ level: "warn",
211
+ title: "A schedule that names a day no month has",
212
+ help: "`0 0 30 2 *` is a valid cron — every field is in range — but no February has a 30th, so the flow never runs and the " +
213
+ "scheduler never lists a next run for it. Pick a day every named month has, or add a weekday (either then matches).",
214
+ },
215
+ {
216
+ // 30 Oct 2026: a signing secret is looked up in the vault by name.
217
+ id: "secret/signing-secret-value",
218
+ category: "secret",
219
+ level: "warn",
220
+ title: "A signing_secret: that holds a value, not a secret's name",
221
+ help: "A flow's `signing_secret:` and `notify: signing_secret:` name a vault entry (`WEBHOOK_SIGNING_KEY`, or `${WEBHOOK_SIGNING_KEY}`). " +
222
+ "Anything else is looked up as a name and never found — every signed delivery is refused, every notification goes unsigned — " +
223
+ "and the value itself is deployed and versioned in plain text. Put it in the vault (`foldrun secrets set NAME`), write the name, and rotate it.",
224
+ owasp: ["ASI03"],
225
+ },
205
226
  {
206
227
  id: "check/ignore-needs-reason",
207
228
  category: "config",
@@ -31,8 +31,15 @@ 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
+ /** An ACCOUNT secret limited to these workspaces (GitHub's "selected
35
+ * repositories" for an organisation secret). Absent: every workspace may
36
+ * use it, as before. Every other workspace is answered as if it were not
37
+ * set (getSecret). Metadata like `sites`: in the clear, and in the seal
38
+ * of a bound record. Never on a workspace's own record. */
39
+ workspaces?: string[];
34
40
  /** 2: sealed with the record's place as AAD — account, scope, name, kind,
35
- * key and sites (vaultAad). Absent: the older, unbound form. */
41
+ * key, sites and, when limited, workspaces (vaultAad). Absent: the older,
42
+ * unbound form. */
36
43
  v?: 2;
37
44
  }
38
45
  type SecretsFile = Record<string, StoredSecret>;
@@ -44,7 +51,30 @@ declare const writeBound: () => boolean;
44
51
  */
45
52
  export declare function mutateVaultFile(file: string, change: (all: SecretsFile) => SecretsFile | null): SecretsFile;
46
53
  export declare const SECRET_NAME: RegExp;
47
- export declare function setSecret(tenant: string, name: string, value: string, workspace?: string, kind?: SecretKind): void;
54
+ /**
55
+ * How a `signing_secret:` is shown in an answer, a log line or a delivery
56
+ * record: the name it holds, and only a name (30 Oct 2026). It is meant to
57
+ * name a vault entry, but nothing stopped the value itself being written
58
+ * there, and notify's test send echoed it back to the dashboard as "signed
59
+ * with <it>". Anything that is not a secret name is described, never
60
+ * repeated (foldrun check flags it as secret/signing-secret-value).
61
+ */
62
+ export declare function signingSecretShown(name: string): string;
63
+ /**
64
+ * Which workspaces an ACCOUNT secret may be used in, given with the value so
65
+ * both are sealed in one locked write (30 Oct 2026): a new secret created
66
+ * and limited in two calls was open to every workspace between them, and
67
+ * stayed open if the second failed. `workspaces` undefined keeps what the
68
+ * record has; null opens it to every workspace; a list limits it.
69
+ * `exists` is asked about each name under the vault lock, so a workspace
70
+ * deleted meanwhile is not granted (the delete takes its name off every
71
+ * list under the same lock — retargetSecretWorkspaces).
72
+ */
73
+ export interface SecretLimit {
74
+ workspaces?: string[] | null;
75
+ exists?: (workspace: string) => boolean;
76
+ }
77
+ export declare function setSecret(tenant: string, name: string, value: string, workspace?: string, kind?: SecretKind, limit?: SecretLimit): void;
48
78
  /**
49
79
  * Bind a secret to sites, or unbind it (an empty list). The secret must
50
80
  * exist in that scope. Sites are `api.example.com` or `*.example.com` — no
@@ -71,11 +101,61 @@ export declare function decryptValue(tenant: string, rec: {
71
101
  data: string;
72
102
  k?: "tenant";
73
103
  } | undefined): string | null;
74
- /** Nearest wins: a workspace secret shadows an account one of the same name. */
104
+ /** Nearest wins: a workspace secret shadows an account one of the same name.
105
+ * An account secret limited to some workspaces (setSecretWorkspaces) is
106
+ * absent for every other — the answer a secret that was never set gets, on
107
+ * every path that resolves one for a workspace: runs, tool tests, notify,
108
+ * the egress proxy, MCP servers. Without a workspace it is the account's
109
+ * own use (its model key, its mail sender), which no list limits. */
75
110
  export declare function getSecret(tenant: string, name: string, workspace?: string): {
76
111
  value: string;
77
112
  scope: SecretScope;
78
113
  } | null;
114
+ /** May the ACCOUNT's secret `name` be used for this workspace? True when it
115
+ * is not limited (or not set); for callers that read the account's copy on
116
+ * a workspace's behalf without getSecret's workspace-first lookup — the
117
+ * account's mail sender for a workspace's notifications (notify.ts). */
118
+ export declare function accountSecretUsableIn(tenant: string, name: string, workspace: string): boolean;
119
+ /**
120
+ * Limit an account secret to some workspaces, or lift the limit (null).
121
+ * An empty list means no workspace may use it. Returns the list as stored,
122
+ * sorted, or null.
123
+ *
124
+ * `exists` is asked about each name under the vault lock (the API passes
125
+ * "is there such a workspace"), so it cannot grant a name a delete has just
126
+ * taken off every list. `ifWorkspaces`, when given, is the list the caller
127
+ * saw (null: every workspace): a list changed since is refused (409), so
128
+ * two admins editing it at once do not silently overwrite each other.
129
+ *
130
+ * The record is sealed (v2) whatever it was, whatever
131
+ * FOLDRUN_VAULT_WRITE_BOUND says: a list in the clear on an unbound record
132
+ * could be stripped on disk to open the secret to every workspace; sealed,
133
+ * a stripped list makes it unreadable. Every reader since 29 Oct 2026 reads v2.
134
+ */
135
+ export declare function setSecretWorkspaces(tenant: string, name: string, workspaces: string[] | null, opts?: {
136
+ exists?: (workspace: string) => boolean;
137
+ ifWorkspaces?: string[] | null;
138
+ }): string[] | null;
139
+ /**
140
+ * A workspace is being renamed (to: its new name) or deleted (to: null):
141
+ * every account secret limited to it follows. Without this a rename quietly
142
+ * cut the workspace off its secrets, and a delete left the name on the list
143
+ * for the next workspace given it.
144
+ *
145
+ * `failed` names the records that hold the name but will not open with this
146
+ * install's keys: they are left exactly as they are, so the old name is
147
+ * STILL on their list — a key restored later would bring the grant back.
148
+ * The caller must not report the rename or delete as clean while any are
149
+ * left (the API refuses it, naming them: set them again, or delete them).
150
+ *
151
+ * Names, not ids: a grant naming a workspace's immutable id would survive a
152
+ * rename untouched and could never pass to a new workspace given an old
153
+ * name — the stronger design, not built yet (30 Oct 2026).
154
+ */
155
+ export declare function retargetSecretWorkspaces(tenant: string, from: string, to: string | null): {
156
+ changed: string[];
157
+ failed: string[];
158
+ };
79
159
  export declare function deleteSecret(tenant: string, name: string, workspace?: string): void;
80
160
  export interface SecretEntry {
81
161
  name: string;
@@ -89,6 +169,8 @@ export interface SecretEntry {
89
169
  /** The sites it is bound to — the proxy fills it for these and no other,
90
170
  * and it never enters a sandbox. Absent when unbound. */
91
171
  sites?: string[];
172
+ /** An account secret limited to these workspaces; absent: every one. */
173
+ workspaces?: string[];
92
174
  }
93
175
  export declare function listSecrets(tenant: string, workspace?: string): SecretEntry[];
94
176
  export declare function resolveSecrets(tenant: string, names: string[], workspace?: string): {
@@ -154,7 +236,7 @@ export interface OAuth2Config {
154
236
  export declare const OAUTH_REFRESH_TRIES = 3;
155
237
  /** Wait before try n (1-based retry): 1s, 3s — FOLDRUN_OAUTH_RETRY_MS scales it for tests. */
156
238
  export declare function oauthRetryDelayMs(n: number): number;
157
- export declare function setOAuth2Secret(tenant: string, name: string, config: OAuth2Config, workspace?: string): void;
239
+ export declare function setOAuth2Secret(tenant: string, name: string, config: OAuth2Config, workspace?: string, limit?: SecretLimit): void;
158
240
  export declare function isOAuth2Value(value: string): boolean;
159
241
  /**
160
242
  * The cache key for a live token: SHA-256 over the kind, the account and the
@@ -205,8 +287,8 @@ export interface ServiceAccountConfig {
205
287
  * a Workspace admin grants it. Absent, the account acts as itself. */
206
288
  subject?: string;
207
289
  }
208
- export declare function setServiceAccountSecret(tenant: string, name: string, config: ServiceAccountConfig, workspace?: string): void;
209
- export declare function setFileSecret(tenant: string, name: string, content: string, workspace?: string): void;
290
+ export declare function setServiceAccountSecret(tenant: string, name: string, config: ServiceAccountConfig, workspace?: string, limit?: SecretLimit): void;
291
+ export declare function setFileSecret(tenant: string, name: string, content: string, workspace?: string, limit?: SecretLimit): void;
210
292
  export declare function isFileValue(value: string): boolean;
211
293
  export declare function fileContent(value: string): string;
212
294
  export interface SshConfig {
@@ -217,7 +299,7 @@ export interface SshConfig {
217
299
  private_key?: string;
218
300
  password?: string;
219
301
  }
220
- export declare function setSshSecret(tenant: string, name: string, config: SshConfig, workspace?: string): void;
302
+ export declare function setSshSecret(tenant: string, name: string, config: SshConfig, workspace?: string, limit?: SecretLimit): void;
221
303
  export declare function isSshValue(value: string): boolean;
222
304
  export declare function sshConfigOf(value: string): SshConfig;
223
305
  /** A client certificate for mutual TLS: presented by the egress proxy, on its
@@ -232,14 +314,14 @@ export interface ClientCertConfig {
232
314
  passphrase?: string;
233
315
  export?: boolean;
234
316
  }
235
- export declare function setClientCertSecret(tenant: string, name: string, config: ClientCertConfig, workspace?: string): void;
317
+ export declare function setClientCertSecret(tenant: string, name: string, config: ClientCertConfig, workspace?: string, limit?: SecretLimit): void;
236
318
  /** A keyless cloud identity: no credential at all, only the audience the
237
319
  * platform's signed tokens are for. A step that declares it gets a token
238
320
  * file the sandbox driver keeps fresh through the egress proxy. */
239
321
  export interface IdentityConfig {
240
322
  audience: string;
241
323
  }
242
- export declare function setIdentitySecret(tenant: string, name: string, config: IdentityConfig, workspace?: string): void;
324
+ export declare function setIdentitySecret(tenant: string, name: string, config: IdentityConfig, workspace?: string, limit?: SecretLimit): void;
243
325
  export declare function isIdentityValue(value: string): boolean;
244
326
  export declare function identityOf(value: string): IdentityConfig;
245
327
  export declare function isClientCertValue(value: string): boolean;
@@ -260,7 +342,7 @@ export interface ApiConfig {
260
342
  /** `Authorization: Basic …` from a username and password (RFC 7617 §2):
261
343
  * UTF-8, no colon in the username, no control characters in either. */
262
344
  export declare function basicAuthorization(username: string, password: string): string;
263
- export declare function setApiSecret(tenant: string, name: string, config: ApiConfig, workspace?: string): void;
345
+ export declare function setApiSecret(tenant: string, name: string, config: ApiConfig, workspace?: string, limit?: SecretLimit): void;
264
346
  export declare function isApiValue(value: string): boolean;
265
347
  export declare function apiConfigOf(value: string): ApiConfig;
266
348
  /**