@foldrun/core 7.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.
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": "7.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/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