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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paseo-plugin-genui",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Paseo plugin: agents answer with charts, diagrams, maps, plans and forms rendered inline in the chat.",
5
5
  "keywords": [
6
6
  "paseo",
@@ -1,5 +1,5 @@
1
- import { existsSync, readFileSync, readdirSync } from "node:fs";
2
- import { mkdir, readdir, readFile, stat } from "node:fs/promises";
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 relative = path.relative(path.resolve(root), path.resolve(target));
52
- return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
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
- async function readLocalFile(target: string, requested: string, maxBytes: number): Promise<FileContent> {
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
- const info = await stat(target);
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(target);
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 candidates: string[] = [];
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
- const target = path.resolve(storageRoot, rel);
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
- const target = path.resolve(storageRoot, rel);
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
- if (
130
- contains(storageRoot, target) ||
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
- if (workspaceCwd !== null) {
139
- const target = path.resolve(workspaceCwd, requested);
140
- if (contains(workspaceCwd, target)) candidates.push(target);
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 (candidates.length === 0) {
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 candidate of candidates) {
157
- const result = await readLocalFile(candidate, requested, maxBytes);
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
- firstFailure ??= result;
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: "gallery specs can only load files in gallery/" };
397
+ return { ok: false, error: GALLERY_REFUSAL };
345
398
  }
346
- return readLocalFile(target, clean, maxBytes);
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> {
@@ -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 { listGallery, loadGalleryFile, readGallerySpec } from "../server/data-source";
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
  });