@foldrun/core 7.0.0 → 7.0.2

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,58 @@ 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
+ // Canonical the same way as the path: a workspace not made yet under a
2355
+ // linked ancestor (macOS /tmp → /private/tmp) is its nearest real
2356
+ // ancestor plus the rest, or a first write into it would read as leaving.
2357
+ const root = realLanding(dir) ?? path.resolve(dir);
2358
+ const at = realLanding(full, followLast);
2359
+ return at !== null && (at === root || at.startsWith(root + path.sep));
2360
+ }
2361
+ function assertLandsInside(dir, full, rel, followLast = true) {
2362
+ if (!landsInside(dir, full, followLast))
2363
+ throw new Error(`illegal path: ${rel} leads out of the workspace through a symlink`);
2364
+ }
2319
2365
  export function readWorkspaceFile(tenant, workspace, rel) {
2320
- const p = path.join(workspaceDir(tenant, workspace), assertEditablePath(rel));
2366
+ const dir = workspaceDir(tenant, workspace);
2367
+ const p = path.join(dir, assertEditablePath(rel));
2321
2368
  if (!fs.existsSync(p))
2322
2369
  throw new Error(`file not found: ${rel}`);
2370
+ assertLandsInside(dir, p, rel);
2323
2371
  return fs.readFileSync(p, "utf8");
2324
2372
  }
2325
2373
  export function writeWorkspaceFile(tenant, workspace, rel, content, meta = {}) {
@@ -2328,7 +2376,11 @@ export function writeWorkspaceFile(tenant, workspace, rel, content, meta = {}) {
2328
2376
  if (content.length > MAX_EDITABLE_FILE)
2329
2377
  throw new Error("file too large");
2330
2378
  matter(content); // reject files whose frontmatter doesn't parse
2331
- const p = path.join(workspaceDir(tenant, workspace), assertEditablePath(rel));
2379
+ const dir = workspaceDir(tenant, workspace);
2380
+ const p = path.join(dir, assertEditablePath(rel));
2381
+ // Before anything is created: mkdir below would otherwise make folders
2382
+ // through a linked directory too.
2383
+ assertLandsInside(dir, p, rel);
2332
2384
  const existed = fs.existsSync(p);
2333
2385
  const before = existed ? fs.readFileSync(p, "utf8") : null;
2334
2386
  fs.mkdirSync(path.dirname(p), { recursive: true });
@@ -2439,6 +2491,10 @@ export function renameWorkspaceFile(tenant, workspace, from, to, meta = {}) {
2439
2491
  throw new Error(`${from} does not exist`);
2440
2492
  if (fs.existsSync(dst))
2441
2493
  throw new Error(`${to} already exists`);
2494
+ // The link itself may be moved; what is moved must not come from, or go
2495
+ // to, outside the workspace through a linked folder.
2496
+ assertLandsInside(dir, src, from, false);
2497
+ assertLandsInside(dir, dst, to);
2442
2498
  fs.mkdirSync(path.dirname(dst), { recursive: true });
2443
2499
  const moved = deletionsUnder(workspaceDir(tenant, workspace), path.normalize(from).replaceAll("\\", "/"));
2444
2500
  fs.renameSync(src, dst);
@@ -2509,12 +2565,16 @@ export function deleteWorkspacePath(tenant, workspace, rel, meta = {}) {
2509
2565
  if (/^agents\/[a-z0-9_-]+$/.test(norm) ||
2510
2566
  /^(skills|tools)\/[a-z0-9_-]+$/.test(norm) ||
2511
2567
  /^agents\/[a-z0-9_-]+\/skills\/[a-z0-9_-]+$/.test(norm)) {
2568
+ // The last part may be a link — rmSync removes the link, not what it
2569
+ // names — but a linked folder ABOVE it would delete outside.
2570
+ assertLandsInside(dir, path.join(dir, norm), norm, false);
2512
2571
  const gone = deletionsUnder(dir, norm);
2513
2572
  fs.rmSync(path.join(dir, norm), { recursive: true, force: true });
2514
2573
  recordRevision(tenant, workspace, gone, { message: `deleted ${norm}/`, ...meta });
2515
2574
  return;
2516
2575
  }
2517
2576
  const p = path.join(dir, assertEditablePath(norm));
2577
+ assertLandsInside(dir, p, norm, false);
2518
2578
  const gone = deletionsUnder(dir, norm);
2519
2579
  fs.rmSync(p, { force: true });
2520
2580
  recordRevision(tenant, workspace, gone, meta);
@@ -2816,6 +2876,8 @@ export function runOutputPath(tenant, workspace, runId, agent, rel) {
2816
2876
  return null;
2817
2877
  if (!fs.existsSync(full) || !fs.statSync(full).isFile())
2818
2878
  return null;
2879
+ if (!landsInside(workspaceDir(tenant, workspace), full))
2880
+ return null;
2819
2881
  return full;
2820
2882
  }
2821
2883
  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.2",
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,56 @@ 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
+ // Canonical the same way as the path: a workspace not made yet under a
2822
+ // linked ancestor (macOS /tmp → /private/tmp) is its nearest real
2823
+ // ancestor plus the rest, or a first write into it would read as leaving.
2824
+ const root = realLanding(dir) ?? path.resolve(dir);
2825
+ const at = realLanding(full, followLast);
2826
+ return at !== null && (at === root || at.startsWith(root + path.sep));
2827
+ }
2828
+
2829
+ function assertLandsInside(dir: string, full: string, rel: string, followLast = true) {
2830
+ if (!landsInside(dir, full, followLast)) throw new Error(`illegal path: ${rel} leads out of the workspace through a symlink`);
2831
+ }
2832
+
2788
2833
  export function readWorkspaceFile(tenant: string, workspace: string, rel: string): string {
2789
- const p = path.join(workspaceDir(tenant, workspace), assertEditablePath(rel));
2834
+ const dir = workspaceDir(tenant, workspace);
2835
+ const p = path.join(dir, assertEditablePath(rel));
2790
2836
  if (!fs.existsSync(p)) throw new Error(`file not found: ${rel}`);
2837
+ assertLandsInside(dir, p, rel);
2791
2838
  return fs.readFileSync(p, "utf8");
2792
2839
  }
2793
2840
 
@@ -2808,7 +2855,11 @@ export function writeWorkspaceFile(
2808
2855
  // past 256 KB. Still a file a person edits, not a store for data.
2809
2856
  if (content.length > MAX_EDITABLE_FILE) throw new Error("file too large");
2810
2857
  matter(content); // reject files whose frontmatter doesn't parse
2811
- const p = path.join(workspaceDir(tenant, workspace), assertEditablePath(rel));
2858
+ const dir = workspaceDir(tenant, workspace);
2859
+ const p = path.join(dir, assertEditablePath(rel));
2860
+ // Before anything is created: mkdir below would otherwise make folders
2861
+ // through a linked directory too.
2862
+ assertLandsInside(dir, p, rel);
2812
2863
  const existed = fs.existsSync(p);
2813
2864
  const before = existed ? fs.readFileSync(p, "utf8") : null;
2814
2865
  fs.mkdirSync(path.dirname(p), { recursive: true });
@@ -2918,6 +2969,10 @@ export function renameWorkspaceFile(tenant: string, workspace: string, from: str
2918
2969
  const dst = path.join(dir, assertEditablePath(to));
2919
2970
  if (!fs.existsSync(src)) throw new Error(`${from} does not exist`);
2920
2971
  if (fs.existsSync(dst)) throw new Error(`${to} already exists`);
2972
+ // The link itself may be moved; what is moved must not come from, or go
2973
+ // to, outside the workspace through a linked folder.
2974
+ assertLandsInside(dir, src, from, false);
2975
+ assertLandsInside(dir, dst, to);
2921
2976
  fs.mkdirSync(path.dirname(dst), { recursive: true });
2922
2977
  const moved = deletionsUnder(workspaceDir(tenant, workspace), path.normalize(from).replaceAll("\\", "/"));
2923
2978
  fs.renameSync(src, dst);
@@ -2992,12 +3047,16 @@ export function deleteWorkspacePath(tenant: string, workspace: string, rel: stri
2992
3047
  if (/^agents\/[a-z0-9_-]+$/.test(norm) ||
2993
3048
  /^(skills|tools)\/[a-z0-9_-]+$/.test(norm) ||
2994
3049
  /^agents\/[a-z0-9_-]+\/skills\/[a-z0-9_-]+$/.test(norm)) {
3050
+ // The last part may be a link — rmSync removes the link, not what it
3051
+ // names — but a linked folder ABOVE it would delete outside.
3052
+ assertLandsInside(dir, path.join(dir, norm), norm, false);
2995
3053
  const gone = deletionsUnder(dir, norm);
2996
3054
  fs.rmSync(path.join(dir, norm), { recursive: true, force: true });
2997
3055
  recordRevision(tenant, workspace, gone, { message: `deleted ${norm}/`, ...meta });
2998
3056
  return;
2999
3057
  }
3000
3058
  const p = path.join(dir, assertEditablePath(norm));
3059
+ assertLandsInside(dir, p, norm, false);
3001
3060
  const gone = deletionsUnder(dir, norm);
3002
3061
  fs.rmSync(p, { force: true });
3003
3062
  recordRevision(tenant, workspace, gone, meta);
@@ -3836,6 +3895,7 @@ export function runOutputPath(
3836
3895
  const full = path.resolve(root, rel);
3837
3896
  if (full !== root && !full.startsWith(root + path.sep)) return null;
3838
3897
  if (!fs.existsSync(full) || !fs.statSync(full).isFile()) return null;
3898
+ if (!landsInside(workspaceDir(tenant, workspace), full)) return null;
3839
3899
  return full;
3840
3900
  }
3841
3901