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