paseo-plugin-genui 0.1.0 → 0.1.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/package.json +1 -1
- package/server/data-source.ts +94 -40
- package/shared/genui.test.ts +61 -1
package/package.json
CHANGED
package/server/data-source.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
-
import { mkdir, readdir, readFile,
|
|
1
|
+
import { constants, existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { mkdir, open, readdir, readFile, realpath } from "node:fs/promises";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -47,9 +47,15 @@ type FileContent =
|
|
|
47
47
|
|
|
48
48
|
type FileReader = (requested: string, maxBytes: number) => Promise<FileContent>;
|
|
49
49
|
|
|
50
|
+
const AGENT_REFUSAL = "only files in this agent's workspace, its thread storage or /tmp can be loaded";
|
|
51
|
+
const GALLERY_REFUSAL = "gallery specs can only load files in gallery/";
|
|
52
|
+
|
|
50
53
|
function contains(root: string, target: string): boolean {
|
|
51
|
-
const
|
|
52
|
-
|
|
54
|
+
const resolvedRoot = path.resolve(root);
|
|
55
|
+
const resolvedTarget = path.resolve(target);
|
|
56
|
+
if (resolvedRoot === resolvedTarget) return false;
|
|
57
|
+
const relative = path.relative(resolvedRoot, resolvedTarget);
|
|
58
|
+
return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
53
59
|
}
|
|
54
60
|
|
|
55
61
|
export function agentStorageDir(agentId: string): string {
|
|
@@ -79,9 +85,58 @@ export async function resolveAgentCwd(paseo: PaseoApi, agentId: string): Promise
|
|
|
79
85
|
}
|
|
80
86
|
}
|
|
81
87
|
|
|
82
|
-
|
|
88
|
+
interface Attempt {
|
|
89
|
+
readonly root: string;
|
|
90
|
+
readonly target: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type AttemptOutcome =
|
|
94
|
+
| { ok: true; content: string; encoding: "utf8" | "base64" }
|
|
95
|
+
| { ok: false; error: string; outsideRoot?: boolean };
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolves both `root` and `target` with `realpath` before checking containment,
|
|
99
|
+
* rejects any symlink or intermediate directory link that escapes `realRoot`,
|
|
100
|
+
* and reads from the validated canonical path with `O_NOFOLLOW`.
|
|
101
|
+
*/
|
|
102
|
+
async function readConfinedFile(
|
|
103
|
+
root: string,
|
|
104
|
+
target: string,
|
|
105
|
+
requested: string,
|
|
106
|
+
maxBytes: number,
|
|
107
|
+
refusalError: string,
|
|
108
|
+
): Promise<AttemptOutcome> {
|
|
109
|
+
if (!contains(root, target)) {
|
|
110
|
+
return { ok: false, error: refusalError, outsideRoot: true };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let realRoot: string;
|
|
114
|
+
try {
|
|
115
|
+
realRoot = await realpath(root);
|
|
116
|
+
} catch {
|
|
117
|
+
return { ok: false, error: refusalError };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let realTarget: string;
|
|
121
|
+
try {
|
|
122
|
+
realTarget = await realpath(target);
|
|
123
|
+
} catch (cause) {
|
|
124
|
+
const code = typeof cause === "object" && cause !== null && "code" in cause ? cause.code : null;
|
|
125
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
126
|
+
return { ok: false, error: "file not found" };
|
|
127
|
+
}
|
|
128
|
+
return { ok: false, error: cause instanceof Error ? cause.message : String(cause) };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!contains(realRoot, realTarget)) {
|
|
132
|
+
return { ok: false, error: refusalError, outsideRoot: true };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const openFlags = constants.O_RDONLY | (typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0);
|
|
136
|
+
let handle;
|
|
83
137
|
try {
|
|
84
|
-
|
|
138
|
+
handle = await open(realTarget, openFlags);
|
|
139
|
+
const info = await handle.stat();
|
|
85
140
|
if (!info.isFile()) return { ok: false, error: "not a regular file" };
|
|
86
141
|
if (info.size > maxBytes) {
|
|
87
142
|
return { ok: false, error: `${info.size} bytes is over the ${maxBytes}-byte limit` };
|
|
@@ -90,7 +145,7 @@ async function readLocalFile(target: string, requested: string, maxBytes: number
|
|
|
90
145
|
const binary =
|
|
91
146
|
(Object.hasOwn(IMAGE_TYPES, extension) && extension !== "svg") ||
|
|
92
147
|
Object.hasOwn(AUDIO_TYPES, extension);
|
|
93
|
-
const content = await readFile(
|
|
148
|
+
const content = await handle.readFile();
|
|
94
149
|
return binary
|
|
95
150
|
? { ok: true, content: content.toString("base64"), encoding: "base64" }
|
|
96
151
|
: { ok: true, content: content.toString("utf8"), encoding: "utf8" };
|
|
@@ -100,9 +155,20 @@ async function readLocalFile(target: string, requested: string, maxBytes: number
|
|
|
100
155
|
ok: false,
|
|
101
156
|
error: code === "ENOENT" ? "file not found" : cause instanceof Error ? cause.message : String(cause),
|
|
102
157
|
};
|
|
158
|
+
} finally {
|
|
159
|
+
await handle?.close().catch(() => undefined);
|
|
103
160
|
}
|
|
104
161
|
}
|
|
105
162
|
|
|
163
|
+
function tempAttempts(target: string): Attempt[] {
|
|
164
|
+
if (contains("/private/tmp", target)) return [{ root: "/private/tmp", target }];
|
|
165
|
+
if (!contains("/tmp", target)) return [];
|
|
166
|
+
return [
|
|
167
|
+
{ root: "/private/tmp", target: path.join("/private", target) },
|
|
168
|
+
{ root: "/tmp", target },
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
|
|
106
172
|
async function readAgentFile(
|
|
107
173
|
paseo: PaseoApi,
|
|
108
174
|
agentId: string,
|
|
@@ -113,50 +179,37 @@ async function readAgentFile(
|
|
|
113
179
|
await mkdir(storageRoot, { recursive: true }).catch(() => undefined);
|
|
114
180
|
const workspaceCwd = await resolveAgentCwd(paseo, agentId);
|
|
115
181
|
|
|
116
|
-
const
|
|
182
|
+
const inside = (root: string | null, target: string): Attempt[] =>
|
|
183
|
+
root !== null && contains(root, target) ? [{ root, target }] : [];
|
|
184
|
+
|
|
185
|
+
let attempts: Attempt[];
|
|
117
186
|
if (requested.startsWith(BB_STORAGE_PREFIX)) {
|
|
118
187
|
const rel = requested.slice(BB_STORAGE_PREFIX.length);
|
|
119
|
-
|
|
120
|
-
if (contains(storageRoot, target)) candidates.push(target);
|
|
121
|
-
const galleryTarget = path.resolve(galleryDir(), rel);
|
|
122
|
-
if (contains(galleryDir(), galleryTarget)) candidates.push(galleryTarget);
|
|
188
|
+
attempts = inside(storageRoot, path.resolve(storageRoot, rel));
|
|
123
189
|
} else if (requested.startsWith(PASEO_STORAGE_PREFIX)) {
|
|
124
190
|
const rel = requested.slice(PASEO_STORAGE_PREFIX.length);
|
|
125
|
-
|
|
126
|
-
if (contains(storageRoot, target)) candidates.push(target);
|
|
191
|
+
attempts = inside(storageRoot, path.resolve(storageRoot, rel));
|
|
127
192
|
} else if (path.isAbsolute(requested)) {
|
|
128
193
|
const target = path.resolve(requested);
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
(workspaceCwd !== null && contains(workspaceCwd, target)) ||
|
|
132
|
-
contains("/tmp", target) ||
|
|
133
|
-
contains("/private/tmp", target)
|
|
134
|
-
) {
|
|
135
|
-
candidates.push(target);
|
|
136
|
-
}
|
|
194
|
+
attempts = [...inside(storageRoot, target), ...inside(workspaceCwd, target)].slice(0, 1);
|
|
195
|
+
if (attempts.length === 0) attempts = tempAttempts(target);
|
|
137
196
|
} else {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const storageTarget = path.resolve(storageRoot, requested);
|
|
143
|
-
if (contains(storageRoot, storageTarget)) candidates.push(storageTarget);
|
|
144
|
-
const galleryTarget = path.resolve(galleryDir(), requested);
|
|
145
|
-
if (contains(galleryDir(), galleryTarget)) candidates.push(galleryTarget);
|
|
197
|
+
attempts = [
|
|
198
|
+
...inside(workspaceCwd, workspaceCwd !== null ? path.resolve(workspaceCwd, requested) : ""),
|
|
199
|
+
...inside(storageRoot, path.resolve(storageRoot, requested)),
|
|
200
|
+
];
|
|
146
201
|
}
|
|
147
202
|
|
|
148
|
-
if (
|
|
149
|
-
return {
|
|
150
|
-
ok: false,
|
|
151
|
-
error: "only files in this agent's workspace, its thread storage or /tmp can be loaded",
|
|
152
|
-
};
|
|
203
|
+
if (attempts.length === 0) {
|
|
204
|
+
return { ok: false, error: AGENT_REFUSAL };
|
|
153
205
|
}
|
|
154
206
|
|
|
155
207
|
let firstFailure: FileContent | undefined;
|
|
156
|
-
for (const
|
|
157
|
-
const result = await
|
|
208
|
+
for (const attempt of attempts) {
|
|
209
|
+
const result = await readConfinedFile(attempt.root, attempt.target, requested, maxBytes, AGENT_REFUSAL);
|
|
158
210
|
if (result.ok) return result;
|
|
159
|
-
|
|
211
|
+
if (result.outsideRoot) return { ok: false, error: AGENT_REFUSAL };
|
|
212
|
+
firstFailure ??= { ok: false, error: result.error };
|
|
160
213
|
}
|
|
161
214
|
return firstFailure!;
|
|
162
215
|
}
|
|
@@ -341,9 +394,10 @@ async function readGalleryFile(requested: string, maxBytes: number): Promise<Fil
|
|
|
341
394
|
: requested;
|
|
342
395
|
const target = path.resolve(root, clean);
|
|
343
396
|
if (path.isAbsolute(clean) || !contains(root, target)) {
|
|
344
|
-
return { ok: false, error:
|
|
397
|
+
return { ok: false, error: GALLERY_REFUSAL };
|
|
345
398
|
}
|
|
346
|
-
|
|
399
|
+
const result = await readConfinedFile(root, target, clean, maxBytes, GALLERY_REFUSAL);
|
|
400
|
+
return result.ok ? result : { ok: false, error: result.error };
|
|
347
401
|
}
|
|
348
402
|
|
|
349
403
|
export function loadGalleryFile(requested: string, { raw = false }: { raw?: boolean } = {}): Promise<LoadedData> {
|
package/shared/genui.test.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { mkdir, rm, symlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
1
3
|
import { describe, expect, it } from "vitest";
|
|
4
|
+
import type { PaseoApi } from "@getpaseo/client";
|
|
2
5
|
import {
|
|
3
6
|
collectLoads,
|
|
4
7
|
compile,
|
|
@@ -16,7 +19,14 @@ import {
|
|
|
16
19
|
UI_REFERENCE_MORE,
|
|
17
20
|
} from "./instructions";
|
|
18
21
|
import { splitTimelineMessage } from "./segment";
|
|
19
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
listGallery,
|
|
24
|
+
loadAgentAudio,
|
|
25
|
+
loadAgentFile,
|
|
26
|
+
loadAgentImage,
|
|
27
|
+
loadGalleryFile,
|
|
28
|
+
readGallerySpec,
|
|
29
|
+
} from "../server/data-source";
|
|
20
30
|
|
|
21
31
|
describe("paseo-plugin-genui core, instructions, and gallery", () => {
|
|
22
32
|
it("compiles every example in the agent instructions cleanly", () => {
|
|
@@ -119,4 +129,54 @@ describe("paseo-plugin-genui core, instructions, and gallery", () => {
|
|
|
119
129
|
expect(valid.ok).toBe(true);
|
|
120
130
|
expect(valid.report).toContain("No errors.");
|
|
121
131
|
});
|
|
132
|
+
|
|
133
|
+
it("refuses symlinks and intermediate directory links escaping the workspace root across data, image, and audio reads", async () => {
|
|
134
|
+
const testRoot = path.join("/tmp/opencode", `genui-symlink-test-${Date.now()}`);
|
|
135
|
+
const workspace = path.join(testRoot, "workspace");
|
|
136
|
+
const outside = path.join(testRoot, "outside");
|
|
137
|
+
await mkdir(workspace, { recursive: true });
|
|
138
|
+
await mkdir(outside, { recursive: true });
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const secretTxt = path.join(outside, "secret.txt");
|
|
142
|
+
const secretPng = path.join(outside, "secret.png");
|
|
143
|
+
const secretWav = path.join(outside, "secret.wav");
|
|
144
|
+
const insideTxt = path.join(workspace, "allowed.txt");
|
|
145
|
+
await writeFile(secretTxt, "top-secret-outside-line\n", "utf8");
|
|
146
|
+
await writeFile(secretPng, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
|
147
|
+
await writeFile(secretWav, Buffer.from("RIFF0000WAVEfmt "));
|
|
148
|
+
await writeFile(insideTxt, "inside-workspace-line\n", "utf8");
|
|
149
|
+
|
|
150
|
+
await symlink(secretTxt, path.join(workspace, "report.txt"));
|
|
151
|
+
await symlink(secretPng, path.join(workspace, "report.png"));
|
|
152
|
+
await symlink(secretWav, path.join(workspace, "report.wav"));
|
|
153
|
+
await symlink(outside, path.join(workspace, "linked-dir"));
|
|
154
|
+
|
|
155
|
+
const mockPaseo = {
|
|
156
|
+
agents: {
|
|
157
|
+
ref: () => ({
|
|
158
|
+
current: () => ({ cwd: workspace }),
|
|
159
|
+
refresh: async () => ({ agent: { cwd: workspace }, project: null }),
|
|
160
|
+
}),
|
|
161
|
+
},
|
|
162
|
+
} as unknown as PaseoApi;
|
|
163
|
+
|
|
164
|
+
const allowed = await loadAgentFile(mockPaseo, "agent_symlink_test", "allowed.txt", { raw: true });
|
|
165
|
+
expect(allowed).toEqual({ ok: true, value: "inside-workspace-line\n" });
|
|
166
|
+
|
|
167
|
+
const directSymlink = await loadAgentFile(mockPaseo, "agent_symlink_test", "report.txt", { raw: true });
|
|
168
|
+
expect(directSymlink.ok).toBe(false);
|
|
169
|
+
|
|
170
|
+
const dirSymlink = await loadAgentFile(mockPaseo, "agent_symlink_test", "linked-dir/secret.txt", { raw: true });
|
|
171
|
+
expect(dirSymlink.ok).toBe(false);
|
|
172
|
+
|
|
173
|
+
const imageSymlink = await loadAgentImage(mockPaseo, "agent_symlink_test", "report.png");
|
|
174
|
+
expect(imageSymlink.ok).toBe(false);
|
|
175
|
+
|
|
176
|
+
const audioSymlink = await loadAgentAudio(mockPaseo, "agent_symlink_test", "report.wav");
|
|
177
|
+
expect(audioSymlink.ok).toBe(false);
|
|
178
|
+
} finally {
|
|
179
|
+
await rm(testRoot, { recursive: true, force: true });
|
|
180
|
+
}
|
|
181
|
+
});
|
|
122
182
|
});
|