@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 +64 -2
- package/package.json +1 -1
- package/src/store.ts +62 -2
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
|
|
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
|
|
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
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
|
|
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
|
|
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
|
|