@foldrun/core 6.0.0 → 7.0.1

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.
@@ -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;
package/dist/src/store.js CHANGED
@@ -2316,10 +2316,61 @@ export function listWorkspaceFiles(tenant, workspace) {
2316
2316
  }
2317
2317
  return out.sort();
2318
2318
  }
2319
+ /**
2320
+ * Where a path really lands once every symlink on the way is followed — for
2321
+ * one not there yet, its nearest existing ancestor's real path with the rest
2322
+ * appended. null when a link on the way points nowhere. `followLast: false`
2323
+ * stops short of the last part: the link itself is what is removed or moved.
2324
+ */
2325
+ function realLanding(full, followLast = true) {
2326
+ const tail = followLast ? [] : [path.basename(full)];
2327
+ let probe = followLast ? full : path.dirname(full);
2328
+ for (;;) {
2329
+ try {
2330
+ fs.lstatSync(probe);
2331
+ }
2332
+ catch {
2333
+ const up = path.dirname(probe);
2334
+ if (up === probe)
2335
+ return null;
2336
+ tail.unshift(path.basename(probe));
2337
+ probe = up;
2338
+ continue;
2339
+ }
2340
+ try {
2341
+ return path.join(fs.realpathSync(probe), ...tail);
2342
+ }
2343
+ catch {
2344
+ return null; // a dangling link: where it would write is not knowable
2345
+ }
2346
+ }
2347
+ }
2348
+ /** Whether `full` stays in the workspace `dir` with symlinks followed (FOL-83).
2349
+ * The string checks (assertEditablePath, runOutputPath's prefix) read the
2350
+ * path as written; a link planted inside the workspace — `knowledge/x.md` →
2351
+ * /etc/passwd, or a folder → / — passed them, and reads, writes and the run
2352
+ * archive followed it out. */
2353
+ function landsInside(dir, full, followLast = true) {
2354
+ let root;
2355
+ try {
2356
+ root = fs.realpathSync(dir);
2357
+ }
2358
+ catch {
2359
+ root = path.resolve(dir);
2360
+ }
2361
+ const at = realLanding(full, followLast);
2362
+ return at !== null && (at === root || at.startsWith(root + path.sep));
2363
+ }
2364
+ function assertLandsInside(dir, full, rel, followLast = true) {
2365
+ if (!landsInside(dir, full, followLast))
2366
+ throw new Error(`illegal path: ${rel} leads out of the workspace through a symlink`);
2367
+ }
2319
2368
  export function readWorkspaceFile(tenant, workspace, rel) {
2320
- const p = path.join(workspaceDir(tenant, workspace), assertEditablePath(rel));
2369
+ const dir = workspaceDir(tenant, workspace);
2370
+ const p = path.join(dir, assertEditablePath(rel));
2321
2371
  if (!fs.existsSync(p))
2322
2372
  throw new Error(`file not found: ${rel}`);
2373
+ assertLandsInside(dir, p, rel);
2323
2374
  return fs.readFileSync(p, "utf8");
2324
2375
  }
2325
2376
  export function writeWorkspaceFile(tenant, workspace, rel, content, meta = {}) {
@@ -2328,7 +2379,11 @@ export function writeWorkspaceFile(tenant, workspace, rel, content, meta = {}) {
2328
2379
  if (content.length > MAX_EDITABLE_FILE)
2329
2380
  throw new Error("file too large");
2330
2381
  matter(content); // reject files whose frontmatter doesn't parse
2331
- const p = path.join(workspaceDir(tenant, workspace), assertEditablePath(rel));
2382
+ const dir = workspaceDir(tenant, workspace);
2383
+ const p = path.join(dir, assertEditablePath(rel));
2384
+ // Before anything is created: mkdir below would otherwise make folders
2385
+ // through a linked directory too.
2386
+ assertLandsInside(dir, p, rel);
2332
2387
  const existed = fs.existsSync(p);
2333
2388
  const before = existed ? fs.readFileSync(p, "utf8") : null;
2334
2389
  fs.mkdirSync(path.dirname(p), { recursive: true });
@@ -2439,6 +2494,10 @@ export function renameWorkspaceFile(tenant, workspace, from, to, meta = {}) {
2439
2494
  throw new Error(`${from} does not exist`);
2440
2495
  if (fs.existsSync(dst))
2441
2496
  throw new Error(`${to} already exists`);
2497
+ // The link itself may be moved; what is moved must not come from, or go
2498
+ // to, outside the workspace through a linked folder.
2499
+ assertLandsInside(dir, src, from, false);
2500
+ assertLandsInside(dir, dst, to);
2442
2501
  fs.mkdirSync(path.dirname(dst), { recursive: true });
2443
2502
  const moved = deletionsUnder(workspaceDir(tenant, workspace), path.normalize(from).replaceAll("\\", "/"));
2444
2503
  fs.renameSync(src, dst);
@@ -2509,12 +2568,16 @@ export function deleteWorkspacePath(tenant, workspace, rel, meta = {}) {
2509
2568
  if (/^agents\/[a-z0-9_-]+$/.test(norm) ||
2510
2569
  /^(skills|tools)\/[a-z0-9_-]+$/.test(norm) ||
2511
2570
  /^agents\/[a-z0-9_-]+\/skills\/[a-z0-9_-]+$/.test(norm)) {
2571
+ // The last part may be a link — rmSync removes the link, not what it
2572
+ // names — but a linked folder ABOVE it would delete outside.
2573
+ assertLandsInside(dir, path.join(dir, norm), norm, false);
2512
2574
  const gone = deletionsUnder(dir, norm);
2513
2575
  fs.rmSync(path.join(dir, norm), { recursive: true, force: true });
2514
2576
  recordRevision(tenant, workspace, gone, { message: `deleted ${norm}/`, ...meta });
2515
2577
  return;
2516
2578
  }
2517
2579
  const p = path.join(dir, assertEditablePath(norm));
2580
+ assertLandsInside(dir, p, norm, false);
2518
2581
  const gone = deletionsUnder(dir, norm);
2519
2582
  fs.rmSync(p, { force: true });
2520
2583
  recordRevision(tenant, workspace, gone, meta);
@@ -2816,6 +2879,8 @@ export function runOutputPath(tenant, workspace, runId, agent, rel) {
2816
2879
  return null;
2817
2880
  if (!fs.existsSync(full) || !fs.statSync(full).isFile())
2818
2881
  return null;
2882
+ if (!landsInside(workspaceDir(tenant, workspace), full))
2883
+ return null;
2819
2884
  return full;
2820
2885
  }
2821
2886
  export function readRun(tenant, workspace, runId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldrun/core",
3
- "version": "6.0.0",
3
+ "version": "7.0.1",
4
4
  "description": "Agents are just folders. The format, the runner, the checks.",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",
package/src/secrets.ts CHANGED
@@ -63,6 +63,12 @@ export type SecretScope = "account" | "workspace";
63
63
  export function installKeyMaterial(): string {
64
64
  const fromEnv = process.env.FOLDRUN_SECRET_KEY;
65
65
  if (fromEnv) return fromEnv;
66
+ // A process that must never hold the install key (the egress proxy) says
67
+ // so; without this, asking for it would quietly mint a NEW random key on
68
+ // the shared data volume — a second root key nobody chose.
69
+ if (process.env.FOLDRUN_INSTALL_KEY_ABSENT === "1") {
70
+ throw new Error("this process runs without the install key (FOLDRUN_INSTALL_KEY_ABSENT=1) — whatever asked for it belongs in the worker");
71
+ }
66
72
  fs.mkdirSync(dataRoot(), { recursive: true });
67
73
  if (!fs.existsSync(keyFile())) {
68
74
  fs.writeFileSync(keyFile(), crypto.randomBytes(32).toString("hex"), { mode: 0o600 });
@@ -105,10 +111,100 @@ interface StoredSecret {
105
111
  * these sites alone. Metadata, not a value, so it is stored in the clear
106
112
  * beside the ciphertext and survives the value being set again. */
107
113
  sites?: string[];
114
+ /** 2: sealed with the record's place as AAD — account, scope, name, kind,
115
+ * key and sites (vaultAad). Absent: the older, unbound form. */
116
+ v?: 2;
108
117
  }
109
118
 
110
119
  type SecretsFile = Record<string, StoredSecret>;
111
120
 
121
+ // ------------------------------------------------------------ binding
122
+ //
123
+ // A v2 record is sealed with WHERE it belongs as additional authenticated
124
+ // data: the account, the scope (account or which workspace), the name, its
125
+ // kind, which key sealed it, and the sites it is bound to. Before 29 Oct 2026
126
+ // (OWASP review) a record was bound to nothing: anyone who could write the
127
+ // data volume or a backup could move one secret's ciphertext under another
128
+ // name, scope or account, or rewrite its clear `sites` and have the egress
129
+ // proxy fill it for their own host. Now any of those makes it fail to open,
130
+ // which reads as "not set" — never as unbound.
131
+ //
132
+ // The place comes from the FILE the record is read from, never from fields
133
+ // inside it. Sites stay in clear beside the ciphertext (listing needs no
134
+ // key, and a pod from before v2 reads them there) and are authenticated by
135
+ // the AAD.
136
+ //
137
+ // Rolled out in steps: every release since reads v2; FOLDRUN_VAULT_WRITE_BOUND=1
138
+ // writes it and migrates existing records at boot; FOLDRUN_VAULT_REQUIRE_BOUND=1
139
+ // refuses the old form once none is left.
140
+
141
+ // The scope is account or workspace, not WHICH workspace: a workspace can be
142
+ // renamed (its folder moves with its vault), and binding the name would make
143
+ // every rename a re-seal racing other writers. What that leaves open — a
144
+ // ciphertext moved between two workspaces of the same account by someone who
145
+ // can already write the volume — is the residual, and stays inside one account.
146
+ function vaultAad(tenant: string, workspace: string | undefined, name: string, rec: Pick<StoredSecret, "kind" | "k" | "sites">): Buffer {
147
+ if (rec.sites !== undefined && (!Array.isArray(rec.sites) || rec.sites.some((x) => typeof x !== "string"))) {
148
+ throw new Error("malformed sites");
149
+ }
150
+ return Buffer.from(
151
+ JSON.stringify(["foldrun-vault/2", tenant, workspace ? "w" : "a", name, rec.kind ?? "", rec.k ?? "install", [...(rec.sites ?? [])].sort()]),
152
+ "utf8",
153
+ );
154
+ }
155
+ const writeBound = () => process.env.FOLDRUN_VAULT_WRITE_BOUND === "1";
156
+ const requireBound = () => process.env.FOLDRUN_VAULT_REQUIRE_BOUND === "1";
157
+
158
+ /** One record, sealed for its place. Bound (v2) when the install writes bound
159
+ * records or `bound` says so; the unbound form otherwise. */
160
+ function sealRecord(
161
+ tenant: string,
162
+ workspace: string | undefined,
163
+ name: string,
164
+ value: string,
165
+ meta: { kind?: SecretKind; sites?: string[]; updatedAt?: string },
166
+ bound = writeBound(),
167
+ ): StoredSecret {
168
+ const { key, mark } = keyFor(tenant);
169
+ const rec: StoredSecret = {
170
+ iv: "",
171
+ tag: "",
172
+ data: "",
173
+ updatedAt: meta.updatedAt ?? new Date().toISOString(),
174
+ ...(meta.kind ? { kind: meta.kind } : {}),
175
+ ...(mark ? { k: mark } : {}),
176
+ ...(meta.sites?.length ? { sites: [...meta.sites] } : {}),
177
+ };
178
+ const iv = crypto.randomBytes(12);
179
+ const cipher = crypto.createCipheriv("aes-256-gcm", key, iv, GCM);
180
+ if (bound) {
181
+ cipher.setAAD(vaultAad(tenant, workspace, name, rec));
182
+ rec.v = 2;
183
+ }
184
+ const enc = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
185
+ rec.iv = iv.toString("base64");
186
+ rec.tag = cipher.getAuthTag().toString("base64");
187
+ rec.data = enc.toString("base64");
188
+ return rec;
189
+ }
190
+
191
+ /** A record's value, opened for its place; null when it will not open. */
192
+ function openRecord(tenant: string, workspace: string | undefined, name: string, rec: StoredSecret | undefined): string | null {
193
+ if (!rec) return null;
194
+ if (rec.v === 2) {
195
+ // Metadata someone rewrote into a shape that is not a list fails closed,
196
+ // like a bad tag — never as an exception up the read path.
197
+ let aad: Buffer;
198
+ try {
199
+ aad = vaultAad(tenant, workspace, name, rec);
200
+ } catch {
201
+ return null;
202
+ }
203
+ return decrypt(tenant, rec, aad);
204
+ }
205
+ return requireBound() ? null : decrypt(tenant, rec);
206
+ }
207
+
112
208
  function read(tenant: string, workspace?: string): SecretsFile {
113
209
  return readFile(secretsFile(tenant, workspace));
114
210
  }
@@ -199,18 +295,15 @@ export function setSecret(
199
295
  throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
200
296
  }
201
297
  if (value.length > 8192) throw new Error("secret too long");
202
- const sealed = encryptValue(tenant, value);
203
298
  mutateVaultFile(secretsFile(tenant, workspace), (all) => {
204
299
  // A new value keeps the sites the old one was bound to: binding is a
205
300
  // property of the credential's use, not of its current value, and a
206
301
  // rotation that silently unbound it would put the new value in pods.
302
+ // Sealed here, under the lock: a bound record's AAD names those sites.
207
303
  const sites = all[name]?.sites;
208
- all[name] = {
209
- ...sealed,
210
- updatedAt: new Date().toISOString(),
211
- ...(kind ? { kind } : {}),
212
- ...(sites?.length ? { sites } : {}),
213
- };
304
+ // Once bound, always bound: a re-save with the switch off must not
305
+ // quietly drop the record's authentication.
306
+ all[name] = sealRecord(tenant, workspace, name, value, { kind, sites }, all[name]?.v === 2 || writeBound());
214
307
  return all;
215
308
  });
216
309
  }
@@ -241,6 +334,15 @@ export function setSecretSites(tenant: string, name: string, sites: string[], wo
241
334
  if (rec.kind === "client-cert" && clean.some((s) => s.startsWith("*."))) {
242
335
  throw new Error(`${name} is a client certificate — bind it to exact hosts, not a wildcard`);
243
336
  }
337
+ // A bound record names its sites in its seal: open it with the old
338
+ // binding and seal it again with the new one. One that will not open is
339
+ // refused, never relabelled.
340
+ if (rec.v === 2) {
341
+ const value = openRecord(tenant, workspace, name, rec);
342
+ if (value === null) throw new Error(`${name} cannot be read with this install's keys — set its value again before binding it`);
343
+ all[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: clean, updatedAt: rec.updatedAt }, true);
344
+ return all;
345
+ }
244
346
  if (clean.length) rec.sites = clean;
245
347
  else delete rec.sites;
246
348
  return all;
@@ -259,7 +361,11 @@ export function secretSites(tenant: string, names: string[], workspace?: string)
259
361
  const own = workspace ? read(tenant, workspace) : {};
260
362
  const account = read(tenant);
261
363
  for (const name of names) {
262
- const rec = own[name] ?? account[name];
364
+ // The record getSecret would take the VALUE from — the workspace's only
365
+ // when it opens, else the account's. Picking by presence paired an
366
+ // account value with a workspace record's (unauthenticated) sites.
367
+ const ownRec = own[name];
368
+ const rec = ownRec && openRecord(tenant, workspace, name, ownRec) !== null ? ownRec : account[name];
263
369
  if (rec?.sites?.length) out[name] = [...rec.sites];
264
370
  }
265
371
  return out;
@@ -303,8 +409,10 @@ export function decryptValue(
303
409
  return decrypt(tenant, rec as StoredSecret | undefined);
304
410
  }
305
411
 
306
- function decrypt(tenant: string, rec: StoredSecret | undefined): string | null {
412
+ function decrypt(tenant: string, rec: StoredSecret | undefined, aad?: Buffer): string | null {
307
413
  if (!rec) return null;
414
+ // A bound record opens only with its binding: one without is not a guess.
415
+ if (rec.v === 2 && !aad) return null;
308
416
  try {
309
417
  // The record says which key wrote it. An unmarked one predates per-account
310
418
  // keys and is under the install key; guessing either way would turn a
@@ -334,6 +442,7 @@ function decrypt(tenant: string, rec: StoredSecret | undefined): string | null {
334
442
  GCM,
335
443
  );
336
444
  decipher.setAuthTag(Buffer.from(rec.tag, "base64"));
445
+ if (aad) decipher.setAAD(aad);
337
446
  return Buffer.concat([
338
447
  decipher.update(Buffer.from(rec.data, "base64")),
339
448
  decipher.final(),
@@ -350,10 +459,10 @@ export function getSecret(
350
459
  workspace?: string,
351
460
  ): { value: string; scope: SecretScope } | null {
352
461
  if (workspace) {
353
- const own = decrypt(tenant, read(tenant, workspace)[name]);
462
+ const own = openRecord(tenant, workspace, name, read(tenant, workspace)[name]);
354
463
  if (own !== null) return { value: own, scope: "workspace" };
355
464
  }
356
- const shared = decrypt(tenant, read(tenant)[name]);
465
+ const shared = openRecord(tenant, undefined, name, read(tenant)[name]);
357
466
  return shared === null ? null : { value: shared, scope: "account" };
358
467
  }
359
468
 
@@ -490,7 +599,7 @@ export function rotateMasterKey(
490
599
  const rewritten: RotationResult["rewritten"] = [];
491
600
  const unreadable: string[] = [];
492
601
 
493
- for (const { file } of secretFiles(tenant)) {
602
+ for (const { file, workspace } of secretFiles(tenant)) {
494
603
  let count = 0;
495
604
  mutateVaultFile(file, (all) => {
496
605
  const next: SecretsFile = {};
@@ -505,10 +614,14 @@ export function rotateMasterKey(
505
614
  next[name] = rec;
506
615
  continue;
507
616
  }
617
+ // A bound record keeps its binding: same place, same AAD.
618
+ let aad: Buffer | null = null;
508
619
  let plain: string;
509
620
  try {
621
+ if (rec.v === 2) aad = vaultAad(tenant, workspace, name, rec);
510
622
  const d = crypto.createDecipheriv("aes-256-gcm", oldKey, Buffer.from(rec.iv, "base64"), GCM);
511
623
  d.setAuthTag(Buffer.from(rec.tag, "base64"));
624
+ if (aad) d.setAAD(aad);
512
625
  plain = Buffer.concat([d.update(Buffer.from(rec.data, "base64")), d.final()]).toString("utf8");
513
626
  } catch {
514
627
  unreadable.push(name);
@@ -517,6 +630,7 @@ export function rotateMasterKey(
517
630
  }
518
631
  const iv = crypto.randomBytes(12);
519
632
  const c = crypto.createCipheriv("aes-256-gcm", newKey, iv, GCM);
633
+ if (aad) c.setAAD(aad);
520
634
  const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);
521
635
  next[name] = {
522
636
  iv: iv.toString("base64"),
@@ -526,6 +640,10 @@ export function rotateMasterKey(
526
640
  // Kept: without it an oauth2 secret stops being refreshed after a
527
641
  // rotation, and a file/ssh/api secret stops being materialised.
528
642
  ...(rec.kind ? { kind: rec.kind } : {}),
643
+ // Kept too (it was dropped until 29 Oct 2026): a rotation that
644
+ // unbinds every bound secret puts their values into pods.
645
+ ...(rec.sites?.length ? { sites: rec.sites } : {}),
646
+ ...(aad ? { v: 2 as const } : {}),
529
647
  };
530
648
  count++;
531
649
  }
@@ -637,8 +755,11 @@ export function replaceOAuth2RefreshToken(tenant: string, name: string, workspac
637
755
  let replaced = false;
638
756
  mutateVaultFile(secretsFile(tenant, scope), (all) => {
639
757
  const rec = all[name];
640
- if (rec && decrypt(tenant, rec) === usedValue) {
641
- all[name] = { ...rec, ...encryptValue(tenant, next), updatedAt: new Date().toISOString() };
758
+ if (rec && openRecord(tenant, scope, name, rec) === usedValue) {
759
+ // Built whole, not spread over the old record: a spread kept a stale
760
+ // `k: "tenant"` when this seal fell back to the install key, and the
761
+ // record then opened with neither.
762
+ all[name] = sealRecord(tenant, scope, name, next, { kind: rec.kind, sites: rec.sites }, rec.v === 2 || writeBound());
642
763
  replaced = true;
643
764
  }
644
765
  return all;
@@ -747,7 +868,15 @@ export async function materializeSecrets(
747
868
  env: Record<string, string>,
748
869
  /** Whose secrets these are, when the caller knows: lets the platform keep
749
870
  * each OAuth connection's health and quote a reconnect link on failure. */
750
- ctx?: { tenant: string; workspace?: string },
871
+ ctx?: {
872
+ tenant: string;
873
+ workspace?: string;
874
+ /** A rotated refresh token goes here INSTEAD of the vault: a caller that
875
+ * cannot open the vault (the egress proxy without the install key)
876
+ * keeps it itself and has it written by a process that can. `next` is
877
+ * the stored value with the new refresh token. */
878
+ onRotate?: (name: string, usedValue: string, next: string) => unknown;
879
+ },
751
880
  ): Promise<Record<string, string>> {
752
881
  const out: Record<string, string> = { ...env };
753
882
  for (const [name, value] of Object.entries(env)) {
@@ -769,7 +898,8 @@ export async function materializeSecrets(
769
898
  // the next run does not refresh again.
770
899
  const next = OAUTH2_PREFIX + JSON.stringify({ ...config, refresh_token: live.rotated });
771
900
  try {
772
- if (ctx) replaceOAuth2RefreshToken(ctx.tenant, name, ctx.workspace, value, live.rotated);
901
+ if (ctx?.onRotate) await ctx.onRotate(name, value, next);
902
+ else if (ctx) replaceOAuth2RefreshToken(ctx.tenant, name, ctx.workspace, value, live.rotated);
773
903
  } catch {
774
904
  // The access token is good either way; the next refresh says if not.
775
905
  }
@@ -1028,7 +1158,7 @@ export function setClientCertSecret(tenant: string, name: string, config: Client
1028
1158
  ...(config.export ? { export: true } : {}),
1029
1159
  };
1030
1160
  if (!SECRET_NAME.test(name)) throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
1031
- const sealed = encryptValue(tenant, CLIENT_CERT_PREFIX + JSON.stringify(stored));
1161
+ const plainCert = CLIENT_CERT_PREFIX + JSON.stringify(stored);
1032
1162
  // Written under the vault's own lock, with the binding checked in the same
1033
1163
  // step: a value set over a secret keeps its site binding, and a wildcard
1034
1164
  // one would present the certificate to a whole domain.
@@ -1037,7 +1167,7 @@ export function setClientCertSecret(tenant: string, name: string, config: Client
1037
1167
  if (sites?.some((site) => site.startsWith("*."))) {
1038
1168
  throw new Error(`${name} is bound to ${sites.join(", ")} — a client certificate is bound to exact hosts; unbind the wildcard first`);
1039
1169
  }
1040
- all[name] = { ...sealed, updatedAt: new Date().toISOString(), kind: "client-cert", ...(sites?.length ? { sites } : {}) };
1170
+ all[name] = sealRecord(tenant, workspace, name, plainCert, { kind: "client-cert", sites }, all[name]?.v === 2 || writeBound());
1041
1171
  return all;
1042
1172
  });
1043
1173
  }
@@ -1143,6 +1273,44 @@ export function apiConfigOf(value: string): ApiConfig {
1143
1273
  }
1144
1274
 
1145
1275
 
1276
+ /**
1277
+ * Seal every unbound record of an account for its place (v2). Run at boot
1278
+ * once the install writes bound records (FOLDRUN_VAULT_WRITE_BOUND=1), and by
1279
+ * scripts/seal-vault-bindings.mjs. Per file, all or nothing: every unbound
1280
+ * value is opened first, and a file holding one that will not open is left
1281
+ * exactly as it is and reported. Idempotent: bound records are skipped.
1282
+ */
1283
+ export function sealVaultBindings(tenant: string): { sealed: number; unreadable: string[] } {
1284
+ assertSafeName(tenant, "tenant");
1285
+ let sealed = 0;
1286
+ const unreadable: string[] = [];
1287
+ for (const { file, workspace } of secretFiles(tenant)) {
1288
+ mutateVaultFile(file, (all) => {
1289
+ const plain = new Map<string, string>();
1290
+ const bad: string[] = [];
1291
+ for (const [name, rec] of Object.entries(all)) {
1292
+ if (rec.v === 2) continue;
1293
+ const value = decrypt(tenant, rec);
1294
+ if (value === null) bad.push(workspace ? `${workspace}/${name}` : name);
1295
+ else plain.set(name, value);
1296
+ }
1297
+ unreadable.push(...bad);
1298
+ if (plain.size === 0 || bad.length) return null;
1299
+ const next: SecretsFile = { ...all };
1300
+ for (const [name, value] of plain) {
1301
+ const rec = all[name];
1302
+ next[name] = sealRecord(tenant, workspace, name, value, { kind: rec.kind, sites: rec.sites, updatedAt: rec.updatedAt }, true);
1303
+ }
1304
+ sealed += plain.size;
1305
+ return next;
1306
+ });
1307
+ }
1308
+ return { sealed, unreadable };
1309
+ }
1310
+
1311
+ /** Whether this install writes bound vault records (and migrates at boot). */
1312
+ export const vaultWritesBound = writeBound;
1313
+
1146
1314
  /**
1147
1315
  * Move an account's secrets from the install key onto its own key.
1148
1316
  *
@@ -1158,7 +1326,7 @@ export function rewrapTenantSecrets(tenant: string): { moved: number; unreadable
1158
1326
  let moved = 0;
1159
1327
  const unreadable: string[] = [];
1160
1328
 
1161
- for (const { file } of secretFiles(tenant)) {
1329
+ for (const { file, workspace } of secretFiles(tenant)) {
1162
1330
  // Under the vault's lock: every pod runs this at boot, and a save made
1163
1331
  // between our read and our write must not be lost.
1164
1332
  mutateVaultFile(file, (all) => {
@@ -1166,7 +1334,7 @@ export function rewrapTenantSecrets(tenant: string): { moved: number; unreadable
1166
1334
  const unreadableHere: string[] = [];
1167
1335
  for (const [name, rec] of Object.entries(all)) {
1168
1336
  if (rec.k === "tenant") continue; // already moved
1169
- const value = decrypt(tenant, rec);
1337
+ const value = openRecord(tenant, workspace, name, rec);
1170
1338
  if (value === null) {
1171
1339
  unreadableHere.push(name);
1172
1340
  continue;
@@ -1180,12 +1348,7 @@ export function rewrapTenantSecrets(tenant: string): { moved: number; unreadable
1180
1348
  if (plain.size === 0 || unreadableHere.length) return null;
1181
1349
  const next: SecretsFile = { ...all };
1182
1350
  for (const [name, value] of plain) {
1183
- next[name] = {
1184
- ...encryptValue(tenant, value),
1185
- updatedAt: all[name].updatedAt,
1186
- ...(all[name].kind ? { kind: all[name].kind } : {}),
1187
- ...(all[name].sites?.length ? { sites: all[name].sites } : {}),
1188
- };
1351
+ next[name] = sealRecord(tenant, workspace, name, value, { kind: all[name].kind, sites: all[name].sites, updatedAt: all[name].updatedAt }, all[name].v === 2 || writeBound());
1189
1352
  }
1190
1353
  moved += plain.size;
1191
1354
  return next;
package/src/store.ts CHANGED
@@ -2785,9 +2785,58 @@ export function listWorkspaceFiles(tenant: string, workspace: string): string[]
2785
2785
  return out.sort();
2786
2786
  }
2787
2787
 
2788
+ /**
2789
+ * Where a path really lands once every symlink on the way is followed — for
2790
+ * one not there yet, its nearest existing ancestor's real path with the rest
2791
+ * appended. null when a link on the way points nowhere. `followLast: false`
2792
+ * stops short of the last part: the link itself is what is removed or moved.
2793
+ */
2794
+ function realLanding(full: string, followLast = true): string | null {
2795
+ const tail: string[] = followLast ? [] : [path.basename(full)];
2796
+ let probe = followLast ? full : path.dirname(full);
2797
+ for (;;) {
2798
+ try {
2799
+ fs.lstatSync(probe);
2800
+ } catch {
2801
+ const up = path.dirname(probe);
2802
+ if (up === probe) return null;
2803
+ tail.unshift(path.basename(probe));
2804
+ probe = up;
2805
+ continue;
2806
+ }
2807
+ try {
2808
+ return path.join(fs.realpathSync(probe), ...tail);
2809
+ } catch {
2810
+ return null; // a dangling link: where it would write is not knowable
2811
+ }
2812
+ }
2813
+ }
2814
+
2815
+ /** Whether `full` stays in the workspace `dir` with symlinks followed (FOL-83).
2816
+ * The string checks (assertEditablePath, runOutputPath's prefix) read the
2817
+ * path as written; a link planted inside the workspace — `knowledge/x.md` →
2818
+ * /etc/passwd, or a folder → / — passed them, and reads, writes and the run
2819
+ * archive followed it out. */
2820
+ function landsInside(dir: string, full: string, followLast = true): boolean {
2821
+ let root: string;
2822
+ try {
2823
+ root = fs.realpathSync(dir);
2824
+ } catch {
2825
+ root = path.resolve(dir);
2826
+ }
2827
+ const at = realLanding(full, followLast);
2828
+ return at !== null && (at === root || at.startsWith(root + path.sep));
2829
+ }
2830
+
2831
+ function assertLandsInside(dir: string, full: string, rel: string, followLast = true) {
2832
+ if (!landsInside(dir, full, followLast)) throw new Error(`illegal path: ${rel} leads out of the workspace through a symlink`);
2833
+ }
2834
+
2788
2835
  export function readWorkspaceFile(tenant: string, workspace: string, rel: string): string {
2789
- const p = path.join(workspaceDir(tenant, workspace), assertEditablePath(rel));
2836
+ const dir = workspaceDir(tenant, workspace);
2837
+ const p = path.join(dir, assertEditablePath(rel));
2790
2838
  if (!fs.existsSync(p)) throw new Error(`file not found: ${rel}`);
2839
+ assertLandsInside(dir, p, rel);
2791
2840
  return fs.readFileSync(p, "utf8");
2792
2841
  }
2793
2842
 
@@ -2808,7 +2857,11 @@ export function writeWorkspaceFile(
2808
2857
  // past 256 KB. Still a file a person edits, not a store for data.
2809
2858
  if (content.length > MAX_EDITABLE_FILE) throw new Error("file too large");
2810
2859
  matter(content); // reject files whose frontmatter doesn't parse
2811
- const p = path.join(workspaceDir(tenant, workspace), assertEditablePath(rel));
2860
+ const dir = workspaceDir(tenant, workspace);
2861
+ const p = path.join(dir, assertEditablePath(rel));
2862
+ // Before anything is created: mkdir below would otherwise make folders
2863
+ // through a linked directory too.
2864
+ assertLandsInside(dir, p, rel);
2812
2865
  const existed = fs.existsSync(p);
2813
2866
  const before = existed ? fs.readFileSync(p, "utf8") : null;
2814
2867
  fs.mkdirSync(path.dirname(p), { recursive: true });
@@ -2918,6 +2971,10 @@ export function renameWorkspaceFile(tenant: string, workspace: string, from: str
2918
2971
  const dst = path.join(dir, assertEditablePath(to));
2919
2972
  if (!fs.existsSync(src)) throw new Error(`${from} does not exist`);
2920
2973
  if (fs.existsSync(dst)) throw new Error(`${to} already exists`);
2974
+ // The link itself may be moved; what is moved must not come from, or go
2975
+ // to, outside the workspace through a linked folder.
2976
+ assertLandsInside(dir, src, from, false);
2977
+ assertLandsInside(dir, dst, to);
2921
2978
  fs.mkdirSync(path.dirname(dst), { recursive: true });
2922
2979
  const moved = deletionsUnder(workspaceDir(tenant, workspace), path.normalize(from).replaceAll("\\", "/"));
2923
2980
  fs.renameSync(src, dst);
@@ -2992,12 +3049,16 @@ export function deleteWorkspacePath(tenant: string, workspace: string, rel: stri
2992
3049
  if (/^agents\/[a-z0-9_-]+$/.test(norm) ||
2993
3050
  /^(skills|tools)\/[a-z0-9_-]+$/.test(norm) ||
2994
3051
  /^agents\/[a-z0-9_-]+\/skills\/[a-z0-9_-]+$/.test(norm)) {
3052
+ // The last part may be a link — rmSync removes the link, not what it
3053
+ // names — but a linked folder ABOVE it would delete outside.
3054
+ assertLandsInside(dir, path.join(dir, norm), norm, false);
2995
3055
  const gone = deletionsUnder(dir, norm);
2996
3056
  fs.rmSync(path.join(dir, norm), { recursive: true, force: true });
2997
3057
  recordRevision(tenant, workspace, gone, { message: `deleted ${norm}/`, ...meta });
2998
3058
  return;
2999
3059
  }
3000
3060
  const p = path.join(dir, assertEditablePath(norm));
3061
+ assertLandsInside(dir, p, norm, false);
3001
3062
  const gone = deletionsUnder(dir, norm);
3002
3063
  fs.rmSync(p, { force: true });
3003
3064
  recordRevision(tenant, workspace, gone, meta);
@@ -3836,6 +3897,7 @@ export function runOutputPath(
3836
3897
  const full = path.resolve(root, rel);
3837
3898
  if (full !== root && !full.startsWith(root + path.sep)) return null;
3838
3899
  if (!fs.existsSync(full) || !fs.statSync(full).isFile()) return null;
3900
+ if (!landsInside(workspaceDir(tenant, workspace), full)) return null;
3839
3901
  return full;
3840
3902
  }
3841
3903