pi-studio 0.9.52 → 0.9.53

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/index.ts CHANGED
@@ -32,6 +32,7 @@ import { escapeStudioPdfLatexTextFragment } from "./shared/studio-pdf-escape.js"
32
32
  import { parseStudioLocalPreviewPage, parseStudioPdfLaunchTarget } from "./shared/studio-local-preview-path.js";
33
33
  import { resolveStudioPdfResourceFile } from "./shared/studio-pdf-resource.js";
34
34
  import { createStudioPandocHtmlResourceFlagResolver } from "./shared/studio-pandoc-resource-flag.js";
35
+ import { createStudioResourceGrantRegistry } from "./shared/studio-resource-grants.js";
35
36
  import { prepareStudioLatexForPandoc } from "./shared/studio-latex-pandoc-compat.js";
36
37
  import { isStudioCmuxSession, openStudioUrlInBrowser } from "./shared/studio-browser-launcher.js";
37
38
  import { buildStudioReplTmuxStartArgs } from "./shared/studio-repl-tmux.js";
@@ -75,6 +76,7 @@ import {
75
76
  } from "./shared/studio-side-question.js";
76
77
  import {
77
78
  STUDIO_SIDE_CONTEXT_MAX_OUTPUT_CHARS,
79
+ assertStudioSideQuestionRootStable,
78
80
  formatStudioSideQuestionContextMap,
79
81
  listStudioSideQuestionContext,
80
82
  readStudioSideQuestionContextText,
@@ -451,6 +453,8 @@ interface StudioSideQuestionToolDescriptor {
451
453
  gateway: boolean;
452
454
  }
453
455
 
456
+ type StudioSideQuestionContextRootInput = Pick<StudioSideQuestionContextInput, "sourcePath" | "resourceDir" | "gatherScope" | "contextPath">;
457
+
454
458
  interface StudioSideQuestionContextInput {
455
459
  focusKind: StudioSideQuestionFocusKind;
456
460
  focusLabel: string;
@@ -3103,12 +3107,13 @@ function buildStudioCompanionLabel(_label: string | undefined): string {
3103
3107
  }
3104
3108
 
3105
3109
  const STUDIO_HTML_PREVIEW_RESOURCE_MAX_BYTES = 25 * 1024 * 1024;
3106
- const STUDIO_HTML_PREVIEW_IMAGE_MIME_BY_EXT = new Map<string, string>([
3110
+ const STUDIO_HTML_PREVIEW_MEDIA_MIME_BY_EXT = new Map<string, string>([
3107
3111
  [".png", "image/png"],
3108
3112
  [".jpg", "image/jpeg"],
3109
3113
  [".jpeg", "image/jpeg"],
3110
3114
  [".gif", "image/gif"],
3111
3115
  [".webp", "image/webp"],
3116
+ [".pdf", "application/pdf"],
3112
3117
  ]);
3113
3118
  const STUDIO_LOCAL_LINK_TEXT_EXTENSIONS = new Set([
3114
3119
  ".md", ".markdown", ".mdx", ".qmd", ".txt", ".tex", ".latex", ".rst", ".adoc",
@@ -3133,9 +3138,37 @@ const STUDIO_FILE_BROWSER_IGNORED_DIRS = new Set([
3133
3138
 
3134
3139
  type StudioLocalPreviewResourceKind = "pdf" | "text" | "image" | "office" | "other";
3135
3140
  type StudioFileBrowserSortMode = "name" | "mtime-desc" | "mtime-asc" | "size-desc" | "size-asc";
3141
+ type StudioResourceGrantRegistry = ReturnType<typeof createStudioResourceGrantRegistry>;
3142
+
3143
+ class StudioResourceGrantRequiredError extends Error {
3144
+ readonly code = "studio-resource-grant-required";
3145
+ readonly filePath: string;
3146
+ readonly directoryPath: string;
3147
+ readonly resourceKind: StudioLocalPreviewResourceKind;
3148
+
3149
+ constructor(filePath: string, resourceKind: StudioLocalPreviewResourceKind) {
3150
+ super("This local resource is outside the locations currently available to Studio.");
3151
+ this.name = "StudioResourceGrantRequiredError";
3152
+ this.filePath = filePath;
3153
+ this.directoryPath = dirname(filePath);
3154
+ this.resourceKind = resourceKind;
3155
+ }
3156
+ }
3157
+
3158
+ class StudioDirectoryGrantRequiredError extends Error {
3159
+ readonly code = "studio-resource-directory-grant-required";
3160
+ readonly directoryPath: string;
3161
+
3162
+ constructor(directoryPath: string) {
3163
+ super("This folder is not available to the current Studio session.");
3164
+ this.name = "StudioDirectoryGrantRequiredError";
3165
+ this.directoryPath = directoryPath;
3166
+ }
3167
+ }
3136
3168
 
3137
3169
  interface StudioLocalPreviewResource {
3138
3170
  filePath: string;
3171
+ referencePath: string;
3139
3172
  label: string;
3140
3173
  extension: string;
3141
3174
  kind: StudioLocalPreviewResourceKind;
@@ -3154,6 +3187,11 @@ interface StudioFileBrowserEntry {
3154
3187
  hidden: boolean;
3155
3188
  }
3156
3189
 
3190
+ interface StudioFileBrowserLocation {
3191
+ path: string;
3192
+ label: string;
3193
+ }
3194
+
3157
3195
  function normalizeStudioFileBrowserSortMode(sort: string | null | undefined): StudioFileBrowserSortMode {
3158
3196
  const value = typeof sort === "string" ? sort.trim().toLowerCase() : "";
3159
3197
  if (value === "mtime-desc" || value === "modified-desc" || value === "newest") return "mtime-desc";
@@ -3177,25 +3215,34 @@ function compareStudioFileBrowserEntries(a: StudioFileBrowserEntry, b: StudioFil
3177
3215
  return compareStudioFileBrowserEntryNames(a, b);
3178
3216
  }
3179
3217
 
3180
- function resolveStudioPdfResourcePath(pdfPath: string | undefined, sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string): string {
3218
+ function resolveStudioPdfResourcePath(
3219
+ pdfPath: string | undefined,
3220
+ sourcePath: string | undefined,
3221
+ resourceDir: string | undefined,
3222
+ fallbackCwd: string,
3223
+ resourceGrants?: StudioResourceGrantRegistry,
3224
+ ): string {
3181
3225
  const rawPath = typeof pdfPath === "string" ? pdfPath.trim() : "";
3182
3226
  if (!rawPath) throw new Error("Missing PDF path.");
3183
3227
  if (/\0/.test(rawPath)) throw new Error("Invalid PDF path.");
3184
- if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
3228
+ if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^file:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
3185
3229
  throw new Error("Only local PDF paths are supported.");
3186
3230
  }
3187
3231
 
3188
3232
  const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
3189
- const cleanedPath = decodeStudioHtmlPreviewResourcePath(stripStudioHtmlPreviewResourceUrlSuffix(rawPath));
3233
+ const cleanedPath = decodeStudioLocalPreviewResourceReference(rawPath);
3190
3234
  const expandedPath = recoverLikelyDroppedLeadingSlashPath(expandHome(cleanedPath));
3191
3235
  const candidate = isAbsolute(expandedPath) ? expandedPath : resolve(context.baseDir, expandedPath);
3192
3236
  if (extname(candidate).toLowerCase() !== ".pdf") throw new Error("Only .pdf files can be embedded.");
3193
3237
 
3194
- const boundaryReal = realpathSync(context.boundaryDir);
3195
3238
  const candidateReal = realpathSync(candidate);
3196
- const rel = relative(boundaryReal, candidateReal);
3197
- if (rel.startsWith("..") || isAbsolute(rel)) {
3198
- throw new Error("PDF path must stay within the current Studio resource directory.");
3239
+ if (resourceGrants) {
3240
+ if (!resourceGrants.allows(candidateReal)) throw new StudioResourceGrantRequiredError(candidateReal, "pdf");
3241
+ } else {
3242
+ const boundaryReal = realpathSync(context.boundaryDir);
3243
+ if (!isPathInsideOrEqualDirectory(candidateReal, boundaryReal)) {
3244
+ throw new Error("PDF path must stay within the current Studio resource directory.");
3245
+ }
3199
3246
  }
3200
3247
 
3201
3248
  const stat = statSync(candidateReal);
@@ -3216,6 +3263,22 @@ function decodeStudioHtmlPreviewResourcePath(resourcePath: string): string {
3216
3263
  }
3217
3264
  }
3218
3265
 
3266
+ function decodeStudioLocalPreviewResourceReference(resourcePath: string): string {
3267
+ const raw = String(resourcePath ?? "").trim();
3268
+ if (!/^file:/i.test(raw)) {
3269
+ return decodeStudioHtmlPreviewResourcePath(stripStudioHtmlPreviewResourceUrlSuffix(raw));
3270
+ }
3271
+ try {
3272
+ const url = new URL(raw);
3273
+ if (url.protocol !== "file:") throw new Error("Not a file URL.");
3274
+ url.hash = "";
3275
+ url.search = "";
3276
+ return fileURLToPath(url);
3277
+ } catch {
3278
+ throw new Error("Invalid local file URL.");
3279
+ }
3280
+ }
3281
+
3219
3282
  function getStudioLocalPreviewResourceKind(extension: string, filePathOrName?: string): StudioLocalPreviewResourceKind {
3220
3283
  const ext = extension.toLowerCase();
3221
3284
  const name = basename(String(filePathOrName || "")).toLowerCase();
@@ -3231,60 +3294,128 @@ function resolveStudioLocalPreviewResourcePath(
3231
3294
  sourcePath: string | undefined,
3232
3295
  resourceDir: string | undefined,
3233
3296
  fallbackCwd: string,
3297
+ resourceGrants?: StudioResourceGrantRegistry,
3234
3298
  ): StudioLocalPreviewResource {
3235
3299
  const rawPath = typeof resourcePath === "string" ? resourcePath.trim() : "";
3236
3300
  if (!rawPath) throw new Error("Missing local resource path.");
3237
3301
  if (/\0/.test(rawPath)) throw new Error("Invalid local resource path.");
3238
3302
  if (/^\/\//.test(rawPath)) throw new Error("Network resources are not local Studio resources.");
3239
- if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
3240
- throw new Error("Only local relative resources are supported.");
3303
+ if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^file:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
3304
+ throw new Error("Only local file paths and file URLs are supported.");
3241
3305
  }
3242
3306
 
3243
3307
  const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
3244
- const cleanedPath = decodeStudioHtmlPreviewResourcePath(stripStudioHtmlPreviewResourceUrlSuffix(rawPath));
3308
+ const cleanedPath = decodeStudioLocalPreviewResourceReference(rawPath);
3245
3309
  if (!cleanedPath || cleanedPath.startsWith("#")) throw new Error("Missing local resource path.");
3246
3310
  const expandedPath = recoverLikelyDroppedLeadingSlashPath(expandHome(cleanedPath));
3247
3311
  const candidate = isAbsolute(expandedPath) ? expandedPath : resolve(context.baseDir, expandedPath);
3248
3312
  const extension = extname(candidate).toLowerCase();
3249
- const boundaryReal = realpathSync(context.boundaryDir);
3250
3313
  const candidateReal = realpathSync(candidate);
3251
- const rel = relative(boundaryReal, candidateReal);
3252
- if (rel.startsWith("..") || isAbsolute(rel)) {
3253
- throw new Error("Local resource path must stay within the current Studio resource directory.");
3254
- }
3255
-
3256
3314
  const stat = statSync(candidateReal);
3257
3315
  if (!stat.isFile()) throw new Error("Local resource path does not refer to a file.");
3316
+
3317
+ const kind = getStudioLocalPreviewResourceKind(extension, candidateReal);
3318
+ const matchingGrant = resourceGrants?.findGrant(candidateReal);
3319
+ let effectiveDirectory: string;
3320
+ if (resourceGrants) {
3321
+ if (!matchingGrant) throw new StudioResourceGrantRequiredError(candidateReal, kind);
3322
+ effectiveDirectory = matchingGrant.kind === "directory" ? matchingGrant.path : dirname(candidateReal);
3323
+ } else {
3324
+ const boundaryReal = realpathSync(context.boundaryDir);
3325
+ if (!isPathInsideOrEqualDirectory(candidateReal, boundaryReal)) {
3326
+ throw new Error("Local resource path must stay within the current Studio resource directory.");
3327
+ }
3328
+ effectiveDirectory = boundaryReal;
3329
+ }
3330
+ const relativePath = relative(effectiveDirectory, candidateReal);
3331
+ const useRelativeReference = isPathInsideOrEqualDirectory(candidateReal, effectiveDirectory);
3258
3332
  return {
3259
3333
  filePath: candidateReal,
3260
- label: rel && rel !== "" ? rel : basename(candidateReal),
3334
+ referencePath: useRelativeReference ? (relativePath || basename(candidateReal)) : candidateReal,
3335
+ label: useRelativeReference ? (relativePath || basename(candidateReal)) : basename(candidateReal),
3261
3336
  extension,
3262
- kind: getStudioLocalPreviewResourceKind(extension, candidateReal),
3337
+ kind,
3263
3338
  page: parseStudioLocalPreviewPage(rawPath),
3264
- resourceDir: boundaryReal,
3339
+ resourceDir: effectiveDirectory,
3265
3340
  };
3266
3341
  }
3267
3342
 
3343
+ function getStudioFileBrowserGrantLocations(resourceGrants: StudioResourceGrantRegistry): {
3344
+ locations: StudioFileBrowserLocation[];
3345
+ exactFiles: StudioFileBrowserEntry[];
3346
+ } {
3347
+ const grants = resourceGrants.snapshot();
3348
+ const directoryPaths: string[] = [];
3349
+ for (const grant of grants) {
3350
+ if (grant.kind !== "directory") continue;
3351
+ try {
3352
+ const currentReal = realpathSync(grant.path);
3353
+ if (currentReal === grant.path && statSync(currentReal).isDirectory()) directoryPaths.push(currentReal);
3354
+ } catch {
3355
+ // Missing or replaced directory grants are omitted rather than followed.
3356
+ }
3357
+ }
3358
+ const locations = directoryPaths
3359
+ .map((path) => ({ path, label: basename(path) || path }))
3360
+ .sort((a, b) => a.path.localeCompare(b.path));
3361
+ const exactFiles: StudioFileBrowserEntry[] = [];
3362
+ for (const grant of grants) {
3363
+ if (grant.kind !== "file") continue;
3364
+ if (directoryPaths.some((directoryPath) => isPathInsideOrEqualDirectory(grant.path, directoryPath))) continue;
3365
+ try {
3366
+ const currentReal = realpathSync(grant.path);
3367
+ if (currentReal !== grant.path) continue;
3368
+ const stat = statSync(currentReal);
3369
+ if (!stat.isFile()) continue;
3370
+ const name = basename(currentReal);
3371
+ const extension = extname(currentReal).toLowerCase();
3372
+ exactFiles.push({
3373
+ name,
3374
+ path: currentReal,
3375
+ type: "file",
3376
+ extension,
3377
+ kind: getStudioLocalPreviewResourceKind(extension, currentReal),
3378
+ size: stat.size,
3379
+ mtimeMs: stat.mtimeMs,
3380
+ hidden: name.startsWith("."),
3381
+ });
3382
+ } catch {
3383
+ // Missing exact-file grants remain bounded but are omitted from the Files view.
3384
+ }
3385
+ }
3386
+ exactFiles.sort((a, b) => compareStudioFileBrowserEntries(a, b, "name"));
3387
+ return { locations, exactFiles };
3388
+ }
3389
+
3268
3390
  function resolveStudioFileBrowserDirectory(
3269
3391
  dirPath: string | undefined,
3392
+ rootPath: string | undefined,
3270
3393
  sourcePath: string | undefined,
3271
3394
  resourceDir: string | undefined,
3272
3395
  fallbackCwd: string,
3396
+ resourceGrants: StudioResourceGrantRegistry,
3273
3397
  ): { rootDir: string; currentDir: string; relativeDir: string; parentDir: string | null } {
3274
3398
  const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
3275
- const rootReal = realpathSync(context.boundaryDir);
3399
+ const rawRoot = typeof rootPath === "string" ? rootPath.trim() : "";
3400
+ const expandedRoot = recoverLikelyDroppedLeadingSlashPath(expandHome(rawRoot || context.boundaryDir));
3401
+ const requestedRoot = isAbsolute(expandedRoot) ? expandedRoot : resolve(fallbackCwd, expandedRoot);
3402
+ const requestedRootReal = realpathSync(requestedRoot);
3403
+ if (!statSync(requestedRootReal).isDirectory()) throw new Error("File browser root does not refer to a directory.");
3404
+ const matchingRootGrant = resourceGrants.findGrant(requestedRootReal, { cwd: fallbackCwd });
3405
+ if (!matchingRootGrant || matchingRootGrant.kind !== "directory") {
3406
+ throw new StudioDirectoryGrantRequiredError(requestedRootReal);
3407
+ }
3408
+ const rootReal = matchingRootGrant.path;
3276
3409
  const rawDir = typeof dirPath === "string" ? dirPath.trim() : "";
3277
- const baseDir = context.baseDir;
3410
+ const expandedDir = recoverLikelyDroppedLeadingSlashPath(expandHome(rawDir));
3278
3411
  const requested = rawDir
3279
- ? (isAbsolute(recoverLikelyDroppedLeadingSlashPath(expandHome(rawDir)))
3280
- ? recoverLikelyDroppedLeadingSlashPath(expandHome(rawDir))
3281
- : resolve(baseDir, recoverLikelyDroppedLeadingSlashPath(expandHome(rawDir))))
3282
- : baseDir;
3412
+ ? (isAbsolute(expandedDir) ? expandedDir : resolve(rootReal, expandedDir))
3413
+ : rootReal;
3283
3414
  const currentReal = realpathSync(requested);
3284
3415
  const currentStat = statSync(currentReal);
3285
3416
  if (!currentStat.isDirectory()) throw new Error("File browser path does not refer to a directory.");
3286
3417
  if (!isPathInsideOrEqualDirectory(currentReal, rootReal)) {
3287
- throw new Error("File browser path must stay within the current Studio resource directory.");
3418
+ throw new Error("File browser path must stay within an allowed Studio folder.");
3288
3419
  }
3289
3420
  const parent = dirname(currentReal);
3290
3421
  const parentDir = parent !== currentReal && isPathInsideOrEqualDirectory(parent, rootReal) ? parent : null;
@@ -3294,12 +3425,14 @@ function resolveStudioFileBrowserDirectory(
3294
3425
 
3295
3426
  function listStudioFileBrowserDirectory(
3296
3427
  dirPath: string | undefined,
3428
+ rootPath: string | undefined,
3297
3429
  sourcePath: string | undefined,
3298
3430
  resourceDir: string | undefined,
3299
3431
  fallbackCwd: string,
3432
+ resourceGrants: StudioResourceGrantRegistry,
3300
3433
  sortMode?: string | null,
3301
- ): { rootDir: string; currentDir: string; relativeDir: string; parentDir: string | null; entries: StudioFileBrowserEntry[]; omitted: number; omittedIgnored: number; sort: StudioFileBrowserSortMode } {
3302
- const context = resolveStudioFileBrowserDirectory(dirPath, sourcePath, resourceDir, fallbackCwd);
3434
+ ): { rootDir: string; currentDir: string; relativeDir: string; parentDir: string | null; entries: StudioFileBrowserEntry[]; exactFiles: StudioFileBrowserEntry[]; locations: StudioFileBrowserLocation[]; omitted: number; omittedIgnored: number; sort: StudioFileBrowserSortMode } {
3435
+ const context = resolveStudioFileBrowserDirectory(dirPath, rootPath, sourcePath, resourceDir, fallbackCwd, resourceGrants);
3303
3436
  const sort = normalizeStudioFileBrowserSortMode(sortMode);
3304
3437
  const entries: StudioFileBrowserEntry[] = [];
3305
3438
  let omitted = 0;
@@ -3342,7 +3475,7 @@ function listStudioFileBrowserDirectory(
3342
3475
  entries.sort((a, b) => compareStudioFileBrowserEntries(a, b, sort));
3343
3476
  const limitedEntries = entries.slice(0, STUDIO_FILE_BROWSER_MAX_ENTRIES);
3344
3477
  omitted += Math.max(0, entries.length - limitedEntries.length);
3345
- return { ...context, entries: limitedEntries, omitted, omittedIgnored, sort };
3478
+ return { ...context, ...getStudioFileBrowserGrantLocations(resourceGrants), entries: limitedEntries, omitted, omittedIgnored, sort };
3346
3479
  }
3347
3480
 
3348
3481
  function resolveStudioHtmlPreviewResourcePath(
@@ -3350,27 +3483,32 @@ function resolveStudioHtmlPreviewResourcePath(
3350
3483
  sourcePath: string | undefined,
3351
3484
  resourceDir: string | undefined,
3352
3485
  fallbackCwd: string,
3486
+ resourceGrants?: StudioResourceGrantRegistry,
3353
3487
  ): { filePath: string; mimeType: string } {
3354
3488
  const rawPath = typeof resourcePath === "string" ? resourcePath.trim() : "";
3355
3489
  if (!rawPath) throw new Error("Missing HTML preview resource path.");
3356
3490
  if (/\0/.test(rawPath)) throw new Error("Invalid HTML preview resource path.");
3357
- if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
3491
+ if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^file:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
3358
3492
  throw new Error("Only local HTML preview resources are supported.");
3359
3493
  }
3360
3494
 
3361
3495
  const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
3362
- const cleanedPath = decodeStudioHtmlPreviewResourcePath(stripStudioHtmlPreviewResourceUrlSuffix(rawPath));
3496
+ const cleanedPath = decodeStudioLocalPreviewResourceReference(rawPath);
3363
3497
  const expandedPath = recoverLikelyDroppedLeadingSlashPath(expandHome(cleanedPath));
3364
3498
  const candidate = isAbsolute(expandedPath) ? expandedPath : resolve(context.baseDir, expandedPath);
3365
3499
  const ext = extname(candidate).toLowerCase();
3366
- const mimeType = STUDIO_HTML_PREVIEW_IMAGE_MIME_BY_EXT.get(ext);
3367
- if (!mimeType) throw new Error("Only local PNG, JPEG, GIF, and WebP images can be embedded in HTML previews.");
3500
+ const mimeType = STUDIO_HTML_PREVIEW_MEDIA_MIME_BY_EXT.get(ext);
3501
+ if (!mimeType) throw new Error("Only local PNG, JPEG, GIF, WebP, and PDF media can be embedded in Studio previews.");
3368
3502
 
3369
- const boundaryReal = realpathSync(context.boundaryDir);
3370
3503
  const candidateReal = realpathSync(candidate);
3371
- const rel = relative(boundaryReal, candidateReal);
3372
- if (rel.startsWith("..") || isAbsolute(rel)) {
3373
- throw new Error("HTML preview resource path must stay within the current Studio resource directory.");
3504
+ const kind: StudioLocalPreviewResourceKind = ext === ".pdf" ? "pdf" : "image";
3505
+ if (resourceGrants) {
3506
+ if (!resourceGrants.allows(candidateReal)) throw new StudioResourceGrantRequiredError(candidateReal, kind);
3507
+ } else {
3508
+ const boundaryReal = realpathSync(context.boundaryDir);
3509
+ if (!isPathInsideOrEqualDirectory(candidateReal, boundaryReal)) {
3510
+ throw new Error("HTML preview resource path must stay within the current Studio resource directory.");
3511
+ }
3374
3512
  }
3375
3513
 
3376
3514
  const stat = statSync(candidateReal);
@@ -3381,6 +3519,39 @@ function resolveStudioHtmlPreviewResourcePath(
3381
3519
  return { filePath: candidateReal, mimeType };
3382
3520
  }
3383
3521
 
3522
+ function resolveStudioAuthorizedPreviewRenderContext(
3523
+ sourcePath: string | undefined,
3524
+ resourceDir: string | undefined,
3525
+ fallbackCwd: string,
3526
+ resourceGrants: StudioResourceGrantRegistry,
3527
+ ): { resourcePath?: string; sourcePath?: string } {
3528
+ const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
3529
+ let resourcePath: string;
3530
+ try {
3531
+ resourcePath = realpathSync(context.baseDir);
3532
+ if (!statSync(resourcePath).isDirectory()) return {};
3533
+ } catch {
3534
+ return {};
3535
+ }
3536
+ const directoryGrant = resourceGrants.findGrant(resourcePath, { cwd: fallbackCwd });
3537
+ if (!directoryGrant || directoryGrant.kind !== "directory") return {};
3538
+
3539
+ const rawSource = typeof sourcePath === "string" ? sourcePath.trim() : "";
3540
+ if (!rawSource) return { resourcePath };
3541
+ try {
3542
+ const expandedSource = recoverLikelyDroppedLeadingSlashPath(expandHome(rawSource));
3543
+ const requestedSource = isAbsolute(expandedSource) ? expandedSource : resolve(fallbackCwd, expandedSource);
3544
+ const canonicalSource = realpathSync(requestedSource);
3545
+ const sourceGrant = resourceGrants.findGrant(dirname(canonicalSource), { cwd: fallbackCwd });
3546
+ if (statSync(canonicalSource).isFile() && sourceGrant?.kind === "directory") {
3547
+ return { resourcePath, sourcePath: canonicalSource };
3548
+ }
3549
+ } catch {
3550
+ // The submitted Markdown can still render without file-adjacent metadata.
3551
+ }
3552
+ return { resourcePath };
3553
+ }
3554
+
3384
3555
  function resolveStudioPandocWorkingDir(baseDir: string | undefined): string | undefined {
3385
3556
  const normalized = typeof baseDir === "string" ? baseDir.trim() : "";
3386
3557
  if (!normalized) return undefined;
@@ -5191,11 +5362,34 @@ function hasStudioYamlHeaderIncludes(markdown: string): boolean {
5191
5362
  return /^\s*header-includes\s*:/im.test(split.frontMatter);
5192
5363
  }
5193
5364
 
5365
+ function encodeStudioLocalFileUrlForPandoc(resourceUrl: string): string {
5366
+ try {
5367
+ const url = new URL(resourceUrl);
5368
+ if (url.protocol !== "file:" || (url.hostname && url.hostname !== "localhost")) return resourceUrl;
5369
+ const suffix = `${url.search}${url.hash}`;
5370
+ url.search = "";
5371
+ url.hash = "";
5372
+ const localPath = fileURLToPath(url);
5373
+ return encodeURI(localPath).replace(/\?/g, "%3F").replace(/#/g, "%23") + suffix;
5374
+ } catch {
5375
+ return resourceUrl;
5376
+ }
5377
+ }
5378
+
5379
+ function normalizeStudioMarkdownFileUrlDestinationsForPandoc(markdown: string): string {
5380
+ return transformStudioMarkdownOutsideFences(markdown, (segment: string) => (
5381
+ segment.replace(/(\]\(\s*<?)(file:\/\/(?:localhost)?\/[^<>\s)]+)/gi, (_match, prefix: string, resourceUrl: string) => (
5382
+ `${prefix}${encodeStudioLocalFileUrlForPandoc(resourceUrl)}`
5383
+ ))
5384
+ ));
5385
+ }
5386
+
5194
5387
  function prepareStudioMarkdownForPandoc(markdown: string, options?: { preserveLiteralLatexCommands?: boolean }): string {
5195
5388
  const shouldPreserveLiteralLatexCommands = options?.preserveLiteralLatexCommands !== false;
5196
5389
  return mapStudioMarkdownBodyPreservingYamlFrontMatter(markdown, (body) => {
5197
5390
  const normalizedFences = normalizeStudioMarkdownSmartFences(body);
5198
- const normalizedMath = normalizeMathDelimiters(normalizedFences);
5391
+ const normalizedFileUrls = normalizeStudioMarkdownFileUrlDestinationsForPandoc(normalizedFences);
5392
+ const normalizedMath = normalizeMathDelimiters(normalizedFileUrls);
5199
5393
  const latexReady = shouldPreserveLiteralLatexCommands
5200
5394
  ? preserveLiteralLatexCommandsInMarkdown(normalizedMath)
5201
5395
  : normalizedMath;
@@ -6413,7 +6607,13 @@ function preprocessStudioLatexFootnotemarksForPreview(latex: string): string {
6413
6607
  });
6414
6608
  }
6415
6609
 
6416
- async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolean, resourcePath?: string, sourcePath?: string): Promise<string> {
6610
+ async function renderStudioMarkdownWithPandoc(
6611
+ markdown: string,
6612
+ isLatex?: boolean,
6613
+ resourcePath?: string,
6614
+ sourcePath?: string,
6615
+ options?: { embedResources?: boolean },
6616
+ ): Promise<string> {
6417
6617
  const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
6418
6618
  const pandocWorkingDir = resolveStudioPandocWorkingDir(resourcePath)
6419
6619
  ?? resolveStudioPandocWorkingDir(sourcePath ? dirname(sourcePath) : undefined);
@@ -6451,7 +6651,9 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
6451
6651
  await mkdir(htmlTemplateDir, { recursive: true });
6452
6652
  const htmlTemplatePath = join(htmlTemplateDir, "template.html");
6453
6653
  await writeFile(htmlTemplatePath, STUDIO_PANDOC_HTML_FRAGMENT_TEMPLATE, "utf-8");
6454
- if (resourcePath) args.push(await resolveStudioPandocHtmlResourceFlag(pandocCommand));
6654
+ if (resourcePath && options?.embedResources !== false) {
6655
+ args.push(await resolveStudioPandocHtmlResourceFlag(pandocCommand));
6656
+ }
6455
6657
  args.push("--standalone", `--template=${htmlTemplatePath}`);
6456
6658
  }
6457
6659
  const normalizedMarkdown = isLatex
@@ -7599,6 +7801,29 @@ function respondStudioPendingError(res: ServerResponse, status: number, text: st
7599
7801
  res.end(text);
7600
7802
  }
7601
7803
 
7804
+ function respondStudioResourceGrantRequiredJson(res: ServerResponse, error: StudioResourceGrantRequiredError): void {
7805
+ respondJson(res, 403, {
7806
+ ok: false,
7807
+ code: error.code,
7808
+ error: error.message,
7809
+ path: error.filePath,
7810
+ directoryPath: error.directoryPath,
7811
+ label: basename(error.filePath),
7812
+ resourceKind: error.resourceKind,
7813
+ });
7814
+ }
7815
+
7816
+ function respondStudioDirectoryGrantRequiredJson(res: ServerResponse, error: StudioDirectoryGrantRequiredError): void {
7817
+ respondJson(res, 403, {
7818
+ ok: false,
7819
+ code: error.code,
7820
+ error: error.message,
7821
+ path: error.directoryPath,
7822
+ directoryPath: error.directoryPath,
7823
+ label: basename(error.directoryPath) || error.directoryPath,
7824
+ });
7825
+ }
7826
+
7602
7827
  function respondPdfFile(req: IncomingMessage, res: ServerResponse, filePath: string): void {
7603
7828
  const method = (req.method ?? "GET").toUpperCase();
7604
7829
  if (method !== "GET" && method !== "HEAD") {
@@ -7661,7 +7886,7 @@ function sanitizeStudioPreviewBlockLine(value: string): string {
7661
7886
 
7662
7887
  function buildStudioLocalResourcePreviewDocument(resource: StudioLocalPreviewResource, options?: { watchPdf?: boolean }): InitialStudioDocument {
7663
7888
  const label = basename(resource.filePath) || resource.label || "local preview";
7664
- const resourcePath = resource.label || basename(resource.filePath) || resource.filePath;
7889
+ const resourcePath = resource.referencePath || resource.label || basename(resource.filePath) || resource.filePath;
7665
7890
  const title = sanitizeStudioPreviewBlockLine(label);
7666
7891
  let text = "";
7667
7892
  if (resource.kind === "pdf") {
@@ -7720,7 +7945,14 @@ async function convertStudioOfficeDocumentToMarkdown(resource: StudioLocalPrevie
7720
7945
  return { text: `${note}\n\n${body}\n`, label };
7721
7946
  }
7722
7947
 
7723
- async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResponse, requestUrl: URL, resource: StudioLocalPreviewResource, serverState: StudioServerState): Promise<void> {
7948
+ async function respondLocalPreviewLinkJson(
7949
+ req: IncomingMessage,
7950
+ res: ServerResponse,
7951
+ requestUrl: URL,
7952
+ resource: StudioLocalPreviewResource,
7953
+ serverState: StudioServerState,
7954
+ resourceGrants?: StudioResourceGrantRegistry,
7955
+ ): Promise<void> {
7724
7956
  const method = (req.method ?? "GET").toUpperCase();
7725
7957
  if (method !== "GET" && method !== "HEAD") {
7726
7958
  res.setHeader("Allow", "GET, HEAD");
@@ -7770,6 +8002,7 @@ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResp
7770
8002
  let document: InitialStudioDocument;
7771
8003
  let responseText = "";
7772
8004
  let converted = false;
8005
+ let documentResourceDir = resource.resourceDir;
7773
8006
  if (resource.kind === "office") {
7774
8007
  let conversion: { text: string; label: string };
7775
8008
  try {
@@ -7793,12 +8026,18 @@ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResp
7793
8026
  return;
7794
8027
  }
7795
8028
  responseText = file.text;
8029
+ try {
8030
+ const documentGrant = resourceGrants?.grantDocument(file.resolvedPath, { source: "document" });
8031
+ if (documentGrant?.path) documentResourceDir = documentGrant.path;
8032
+ } catch {
8033
+ // The exact-file grant remains usable if the bounded registry cannot add the document directory.
8034
+ }
7796
8035
  document = {
7797
8036
  text: file.text,
7798
8037
  label: resource.label || file.label,
7799
8038
  source: "file",
7800
8039
  path: file.resolvedPath,
7801
- resourceDir: resource.resourceDir,
8040
+ resourceDir: documentResourceDir,
7802
8041
  };
7803
8042
  }
7804
8043
  if (action === "document") {
@@ -7807,7 +8046,7 @@ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResp
7807
8046
  text: responseText,
7808
8047
  label: document.label,
7809
8048
  converted,
7810
- resourceDir: resource.resourceDir,
8049
+ resourceDir: documentResourceDir,
7811
8050
  });
7812
8051
  return;
7813
8052
  }
@@ -7816,6 +8055,7 @@ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResp
7816
8055
  respondJson(res, 200, {
7817
8056
  ...basePayload,
7818
8057
  converted,
8058
+ resourceDir: documentResourceDir,
7819
8059
  relativeUrl: buildStudioRelativeUrl(serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true }),
7820
8060
  });
7821
8061
  }
@@ -7906,7 +8146,61 @@ async function handleImportStudioFileCopyRequest(req: IncomingMessage, res: Serv
7906
8146
  });
7907
8147
  }
7908
8148
 
7909
- async function handleRevealLocalPreviewResourceRequest(req: IncomingMessage, res: ServerResponse, studioCwd: string): Promise<void> {
8149
+ async function handleStudioResourceGrantRequest(
8150
+ req: IncomingMessage,
8151
+ res: ServerResponse,
8152
+ resourceGrants: StudioResourceGrantRegistry,
8153
+ studioCwd: string,
8154
+ ): Promise<void> {
8155
+ const method = (req.method ?? "GET").toUpperCase();
8156
+ if (method !== "POST") {
8157
+ res.setHeader("Allow", "POST");
8158
+ respondJson(res, 405, { ok: false, error: "Method not allowed. Use POST." });
8159
+ return;
8160
+ }
8161
+
8162
+ const rawBody = await readRequestBody(req, REQUEST_BODY_MAX_BYTES);
8163
+ let payload: Record<string, unknown> = {};
8164
+ try {
8165
+ payload = rawBody ? JSON.parse(rawBody) : {};
8166
+ } catch {
8167
+ respondJson(res, 400, { ok: false, error: "Invalid JSON body." });
8168
+ return;
8169
+ }
8170
+
8171
+ const grantKind = payload.grantKind === "directory" ? "directory" : payload.grantKind === "file" ? "file" : "";
8172
+ const path = typeof payload.path === "string" ? payload.path.trim() : "";
8173
+ if (!grantKind || !path) {
8174
+ respondJson(res, 400, { ok: false, error: "Choose an exact file or folder to allow." });
8175
+ return;
8176
+ }
8177
+
8178
+ try {
8179
+ const grant = grantKind === "directory"
8180
+ ? resourceGrants.grantDirectory(path, { cwd: studioCwd, source: "explicit-directory" })
8181
+ : resourceGrants.grantFile(path, { cwd: studioCwd, source: "explicit-file" });
8182
+ respondJson(res, 200, {
8183
+ ok: true,
8184
+ grant,
8185
+ resourceGrants: resourceGrants.snapshot(),
8186
+ message: grantKind === "directory"
8187
+ ? `Allowed this folder for the current Studio session: ${grant.path}`
8188
+ : `Allowed this file for the current Studio session: ${grant.path}`,
8189
+ });
8190
+ } catch (error) {
8191
+ respondJson(res, 400, {
8192
+ ok: false,
8193
+ error: `Could not allow this ${grantKind}: ${error instanceof Error ? error.message : String(error)}`,
8194
+ });
8195
+ }
8196
+ }
8197
+
8198
+ async function handleRevealLocalPreviewResourceRequest(
8199
+ req: IncomingMessage,
8200
+ res: ServerResponse,
8201
+ studioCwd: string,
8202
+ resourceGrants?: StudioResourceGrantRegistry,
8203
+ ): Promise<void> {
7910
8204
  const method = (req.method ?? "GET").toUpperCase();
7911
8205
  if (method !== "POST") {
7912
8206
  res.setHeader("Allow", "POST");
@@ -7929,6 +8223,7 @@ async function handleRevealLocalPreviewResourceRequest(req: IncomingMessage, res
7929
8223
  typeof payload.sourcePath === "string" ? payload.sourcePath : undefined,
7930
8224
  typeof payload.resourceDir === "string" ? payload.resourceDir : undefined,
7931
8225
  studioCwd,
8226
+ resourceGrants,
7932
8227
  );
7933
8228
  const result = revealStudioLocalFile(resource.filePath);
7934
8229
  if (!result.ok) {
@@ -7937,11 +8232,20 @@ async function handleRevealLocalPreviewResourceRequest(req: IncomingMessage, res
7937
8232
  }
7938
8233
  respondJson(res, 200, { ok: true, message: result.message, path: resource.filePath, label: resource.label });
7939
8234
  } catch (error) {
8235
+ if (error instanceof StudioResourceGrantRequiredError) {
8236
+ respondStudioResourceGrantRequiredJson(res, error);
8237
+ return;
8238
+ }
7940
8239
  respondJson(res, 404, { ok: false, error: `Local resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
7941
8240
  }
7942
8241
  }
7943
8242
 
7944
- async function handleOpenLocalPreviewResourceRequest(req: IncomingMessage, res: ServerResponse, studioCwd: string): Promise<void> {
8243
+ async function handleOpenLocalPreviewResourceRequest(
8244
+ req: IncomingMessage,
8245
+ res: ServerResponse,
8246
+ studioCwd: string,
8247
+ resourceGrants?: StudioResourceGrantRegistry,
8248
+ ): Promise<void> {
7945
8249
  const method = (req.method ?? "GET").toUpperCase();
7946
8250
  if (method !== "POST") {
7947
8251
  res.setHeader("Allow", "POST");
@@ -7968,6 +8272,7 @@ async function handleOpenLocalPreviewResourceRequest(req: IncomingMessage, res:
7968
8272
  typeof payload.sourcePath === "string" ? payload.sourcePath : undefined,
7969
8273
  typeof payload.resourceDir === "string" ? payload.resourceDir : undefined,
7970
8274
  studioCwd,
8275
+ resourceGrants,
7971
8276
  );
7972
8277
  if (resource.kind !== "pdf") {
7973
8278
  respondJson(res, 400, { ok: false, error: "Only local PDF previews can be opened in the system PDF viewer." });
@@ -7981,6 +8286,10 @@ async function handleOpenLocalPreviewResourceRequest(req: IncomingMessage, res:
7981
8286
  label: resource.label,
7982
8287
  });
7983
8288
  } catch (error) {
8289
+ if (error instanceof StudioResourceGrantRequiredError) {
8290
+ respondStudioResourceGrantRequiredJson(res, error);
8291
+ return;
8292
+ }
7984
8293
  respondJson(res, 404, { ok: false, error: `Local PDF unavailable: ${error instanceof Error ? error.message : String(error)}` });
7985
8294
  }
7986
8295
  }
@@ -8006,7 +8315,12 @@ function openPathInDefaultViewer(path: string): Promise<void> {
8006
8315
  });
8007
8316
  }
8008
8317
 
8009
- async function handleOpenStudioFileBrowserDirectoryRequest(req: IncomingMessage, res: ServerResponse, studioCwd: string): Promise<void> {
8318
+ async function handleOpenStudioFileBrowserDirectoryRequest(
8319
+ req: IncomingMessage,
8320
+ res: ServerResponse,
8321
+ studioCwd: string,
8322
+ resourceGrants: StudioResourceGrantRegistry,
8323
+ ): Promise<void> {
8010
8324
  const method = (req.method ?? "GET").toUpperCase();
8011
8325
  if (method !== "POST") {
8012
8326
  res.setHeader("Allow", "POST");
@@ -8030,13 +8344,25 @@ async function handleOpenStudioFileBrowserDirectoryRequest(req: IncomingMessage,
8030
8344
  try {
8031
8345
  const directory = resolveStudioFileBrowserDirectory(
8032
8346
  typeof payload.dir === "string" ? payload.dir : undefined,
8347
+ typeof payload.root === "string" ? payload.root : undefined,
8033
8348
  typeof payload.sourcePath === "string" ? payload.sourcePath : undefined,
8034
8349
  typeof payload.resourceDir === "string" ? payload.resourceDir : undefined,
8035
8350
  studioCwd,
8351
+ resourceGrants,
8036
8352
  );
8037
8353
  await openPathInDefaultViewer(directory.currentDir);
8038
8354
  respondJson(res, 200, { ok: true, message: "Opened folder in file manager.", path: directory.currentDir, rootDir: directory.rootDir });
8039
8355
  } catch (error) {
8356
+ if (error instanceof StudioDirectoryGrantRequiredError) {
8357
+ respondJson(res, 403, {
8358
+ ok: false,
8359
+ code: error.code,
8360
+ error: error.message,
8361
+ path: error.directoryPath,
8362
+ directoryPath: error.directoryPath,
8363
+ });
8364
+ return;
8365
+ }
8040
8366
  respondJson(res, 404, { ok: false, error: `Could not open file-browser folder: ${error instanceof Error ? error.message : String(error)}` });
8041
8367
  }
8042
8368
  }
@@ -8504,10 +8830,11 @@ async function searchStudioSideQuestionLocalContext(
8504
8830
  const query = String(queryInput || "").trim();
8505
8831
  if (!query) throw new Error("Local context search query is empty.");
8506
8832
  if (query.length > 500) throw new Error("Local context search query is too long.");
8833
+ const stableRoot = assertStudioSideQuestionRootStable(contextRoot);
8507
8834
  const target = options.path
8508
- ? resolveStudioSideQuestionPath(contextRoot, options.path, { directory: true }).path
8509
- : contextRoot;
8510
- const targetRelative = relative(contextRoot, target) || ".";
8835
+ ? resolveStudioSideQuestionPath(stableRoot, options.path, { directory: true }).path
8836
+ : stableRoot;
8837
+ const targetRelative = relative(stableRoot, target) || ".";
8511
8838
  const maxResults = Math.max(1, Math.min(200, Math.floor(Number(options.maxResults) || 60)));
8512
8839
  const args = [
8513
8840
  "--fixed-strings",
@@ -8527,7 +8854,7 @@ async function searchStudioSideQuestionLocalContext(
8527
8854
  args.push("--", query, targetRelative);
8528
8855
  try {
8529
8856
  const result = await runStudioSubprocess("rg", args, {
8530
- cwd: contextRoot,
8857
+ cwd: stableRoot,
8531
8858
  signal: options.signal,
8532
8859
  timeoutMs: 20_000,
8533
8860
  stdoutMaxBytes: 250_000,
@@ -8541,11 +8868,11 @@ async function searchStudioSideQuestionLocalContext(
8541
8868
  if (!match) return [];
8542
8869
  return [{ path: match[1].split("\\").join("/"), line: Number(match[2]), text: match[3].trim().slice(0, 1_000) }];
8543
8870
  }).slice(0, maxResults);
8544
- return { root: contextRoot, query, results: matches, truncated: result.stdoutTruncated || matches.length >= maxResults };
8871
+ return { root: stableRoot, query, results: matches, truncated: result.stdoutTruncated || matches.length >= maxResults };
8545
8872
  } catch (error) {
8546
8873
  if (options.signal?.aborted) throw new Error("Local context search was cancelled.");
8547
8874
  if (!(error instanceof Error) || !error.message.includes("__PI_STUDIO_RG_NOT_FOUND__")) throw error;
8548
- return searchStudioSideQuestionContext(contextRoot, query, {
8875
+ return searchStudioSideQuestionContext(stableRoot, query, {
8549
8876
  path: targetRelative,
8550
8877
  caseSensitive: options.caseSensitive,
8551
8878
  maxResults,
@@ -8647,7 +8974,12 @@ function formatStudioSideQuestionGitSnapshotResult(
8647
8974
  };
8648
8975
  }
8649
8976
 
8650
- function createStudioSideQuestionTools(contextRoot: string, webEnabled: boolean, gitSnapshot: StudioSideQuestionGitSnapshot | null = null) {
8977
+ function createStudioSideQuestionTools(
8978
+ contextRoot: string,
8979
+ webEnabled: boolean,
8980
+ gitSnapshot: StudioSideQuestionGitSnapshot | null = null,
8981
+ assertContextRoot?: () => string,
8982
+ ) {
8651
8983
  const mapTool = defineTool({
8652
8984
  name: "studio_context_map",
8653
8985
  label: "Map local context",
@@ -8658,6 +8990,7 @@ function createStudioSideQuestionTools(contextRoot: string, webEnabled: boolean,
8658
8990
  }),
8659
8991
  async execute(_toolCallId, params, signal) {
8660
8992
  if (signal?.aborted) throw new Error("Context map was cancelled.");
8993
+ if (assertContextRoot) assertContextRoot();
8661
8994
  const target = params.path
8662
8995
  ? resolveStudioSideQuestionPath(contextRoot, params.path, { directory: true }).path
8663
8996
  : contextRoot;
@@ -8680,6 +9013,7 @@ function createStudioSideQuestionTools(contextRoot: string, webEnabled: boolean,
8680
9013
  }),
8681
9014
  async execute(_toolCallId, params, signal) {
8682
9015
  if (signal?.aborted) throw new Error("Context read was cancelled.");
9016
+ if (assertContextRoot) assertContextRoot();
8683
9017
  const result = readStudioSideQuestionContextText(contextRoot, params.path, { offset: params.offset, limit: params.limit, maxChars: STUDIO_SIDE_CONTEXT_MAX_OUTPUT_CHARS });
8684
9018
  const extractableResult = result as { path: string; relativePath: string; extension?: string };
8685
9019
  const content: { text: string; startLine: number; endLine: number; totalLines: number; truncated: boolean } = result.requiresExtraction
@@ -8703,6 +9037,7 @@ function createStudioSideQuestionTools(contextRoot: string, webEnabled: boolean,
8703
9037
  maxResults: Type.Optional(Type.Integer({ minimum: 1, maximum: 200 })),
8704
9038
  }),
8705
9039
  async execute(_toolCallId, params, signal) {
9040
+ if (assertContextRoot) assertContextRoot();
8706
9041
  const result = await searchStudioSideQuestionLocalContext(contextRoot, params.query, {
8707
9042
  path: params.path,
8708
9043
  caseSensitive: params.caseSensitive,
@@ -11671,7 +12006,7 @@ ${cssVarsBlock}
11671
12006
  <section id="rightPane">
11672
12007
  <div id="rightSectionHeader" class="section-header">
11673
12008
  <div class="section-header-main">
11674
- <select id="rightViewSelect" aria-label="Response view mode" title="Right pane view mode. F7 cycles when the right pane is active; Cmd/Ctrl+Alt+1–8 switches directly between all right-pane views. Cmd/Ctrl+Alt+P/E/W/Q keep their mnemonic Preview/Editor Preview/Working/Side questions shortcuts.">
12009
+ <select id="rightViewSelect" aria-label="Response view mode" title="Right pane view mode. F7 cycles when the right pane is active; Cmd/Ctrl+Alt+1–8 switches directly between all right-pane views. Cmd/Ctrl+Alt+P/E/W/F/Q keep mnemonic shortcuts for Preview, Editor Preview, Working, Files, and Side questions.">
11675
12010
  <option value="markdown">Response (Raw)</option>
11676
12011
  <option value="preview" selected>Response (Preview)</option>
11677
12012
  <option value="editor-preview">Editor (Preview)</option>
@@ -11780,6 +12115,7 @@ ${cssVarsBlock}
11780
12115
  <div><dt>Cmd/Ctrl+Alt+P</dt><dd>Switch the right pane directly to Response Preview; in editor-only views, Editor Preview</dd></div>
11781
12116
  <div><dt>Cmd/Ctrl+Alt+E</dt><dd>Switch the right pane directly to Editor Preview</dd></div>
11782
12117
  <div><dt>Cmd/Ctrl+Alt+W</dt><dd>Switch the right pane directly to Working</dd></div>
12118
+ <div><dt>Cmd/Ctrl+Alt+F</dt><dd>Switch the right pane directly to Files</dd></div>
11783
12119
  <div><dt>Cmd/Ctrl+Alt+Q</dt><dd>Switch the right pane directly to Side questions</dd></div>
11784
12120
  <div><dt>F8</dt><dd>Focus editor text</dd></div>
11785
12121
  <div><dt>Shift+F8</dt><dd>Focus right-pane content</dd></div>
@@ -11876,6 +12212,7 @@ ${cssVarsBlock}
11876
12212
  export default function (pi: ExtensionAPI) {
11877
12213
  let serverState: StudioServerState | null = null;
11878
12214
  const studioWorkspaceStateStore = createStudioWorkspaceStateStore();
12215
+ const studioResourceGrantRegistry = createStudioResourceGrantRegistry();
11879
12216
  let activeRequest: ActiveStudioRequest | null = null;
11880
12217
  let studioDirectRunChain: StudioDirectRunChain | null = null;
11881
12218
  let queuedStudioDirectRequests: QueuedStudioDirectRequest[] = [];
@@ -11936,6 +12273,23 @@ export default function (pi: ExtensionAPI) {
11936
12273
  actionRequestId: null,
11937
12274
  };
11938
12275
 
12276
+ const recordStudioDocumentResourceGrants = (document: InitialStudioDocument, cwd = studioCwd) => {
12277
+ if (document.path) {
12278
+ try {
12279
+ studioResourceGrantRegistry.grantDocument(document.path, { cwd, source: "document" });
12280
+ } catch {
12281
+ // A failed grant must not broaden access or prevent an otherwise valid launch.
12282
+ }
12283
+ }
12284
+ if (document.resourceDir) {
12285
+ try {
12286
+ studioResourceGrantRegistry.grantDirectory(document.resourceDir, { cwd, source: "workspace" });
12287
+ } catch {
12288
+ // A failed workspace grant must not broaden access or prevent an otherwise valid launch.
12289
+ }
12290
+ }
12291
+ };
12292
+
11939
12293
  const selectStudioReplSessionForTool = (params: { sessionName?: string; target?: string }): { session: StudioReplSessionInfo | null; error?: string; sessions: StudioReplSessionInfo[] } => {
11940
12294
  const state = listStudioReplSessions();
11941
12295
  const sessions = state.sessions;
@@ -13604,7 +13958,7 @@ export default function (pi: ExtensionAPI) {
13604
13958
  await closeStudioSideQuestionRuntime(runtime);
13605
13959
  };
13606
13960
 
13607
- const resolveStudioSideQuestionContextRoot = (context: StudioSideQuestionContextInput): string => {
13961
+ const resolveStudioSideQuestionContextRoot = (context: StudioSideQuestionContextRootInput): string => {
13608
13962
  if (context.gatherScope === "none") return "";
13609
13963
  const sourcePath = resolveStudioQuizContextPath(context.sourcePath, studioCwd);
13610
13964
  const resourceDir = resolveStudioQuizContextPath(context.resourceDir, studioCwd);
@@ -13627,6 +13981,51 @@ export default function (pi: ExtensionAPI) {
13627
13981
  return resolveStudioSideQuestionRoot(root, studioCwd);
13628
13982
  };
13629
13983
 
13984
+ const assertStudioSideQuestionContextRootAuthorized = (contextRoot: string): string => {
13985
+ if (!contextRoot) return "";
13986
+ const stableRoot = assertStudioSideQuestionRootStable(contextRoot);
13987
+ const grant = studioResourceGrantRegistry.findGrant(stableRoot, { cwd: studioCwd });
13988
+ if (!grant || grant.kind !== "directory") throw new StudioDirectoryGrantRequiredError(stableRoot);
13989
+ return stableRoot;
13990
+ };
13991
+
13992
+ const resolveAuthorizedStudioSideQuestionContextRoot = (context: StudioSideQuestionContextRootInput): string => (
13993
+ assertStudioSideQuestionContextRootAuthorized(resolveStudioSideQuestionContextRoot(context))
13994
+ );
13995
+
13996
+ const handleStudioSideQuestionContextRootRequest = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
13997
+ const method = (req.method ?? "GET").toUpperCase();
13998
+ if (method !== "POST") {
13999
+ res.setHeader("Allow", "POST");
14000
+ respondJson(res, 405, { ok: false, error: "Method not allowed. Use POST." });
14001
+ return;
14002
+ }
14003
+ const rawBody = await readRequestBody(req, REQUEST_BODY_MAX_BYTES);
14004
+ let payload: Record<string, unknown> = {};
14005
+ try {
14006
+ payload = rawBody ? JSON.parse(rawBody) as Record<string, unknown> : {};
14007
+ } catch {
14008
+ respondJson(res, 400, { ok: false, error: "Invalid JSON body." });
14009
+ return;
14010
+ }
14011
+ const context: StudioSideQuestionContextRootInput = {
14012
+ gatherScope: normalizeStudioSideQuestionGatherScope(payload.gatherScope) as StudioSideQuestionGatherScope,
14013
+ sourcePath: typeof payload.sourcePath === "string" ? payload.sourcePath.slice(0, 16_384) : undefined,
14014
+ resourceDir: typeof payload.resourceDir === "string" ? payload.resourceDir.slice(0, 16_384) : undefined,
14015
+ contextPath: typeof payload.contextPath === "string" ? payload.contextPath.slice(0, 16_384) : undefined,
14016
+ };
14017
+ try {
14018
+ const contextRoot = resolveAuthorizedStudioSideQuestionContextRoot(context);
14019
+ respondJson(res, 200, { ok: true, contextRoot, authorized: Boolean(contextRoot) });
14020
+ } catch (error) {
14021
+ if (error instanceof StudioDirectoryGrantRequiredError) {
14022
+ respondStudioDirectoryGrantRequiredJson(res, error);
14023
+ return;
14024
+ }
14025
+ respondJson(res, 400, { ok: false, error: `Side-question context unavailable: ${error instanceof Error ? error.message : String(error)}` });
14026
+ }
14027
+ };
14028
+
13630
14029
  const describeStudioSideQuestionActivity = (toolName: string, args: unknown): string => {
13631
14030
  const value = args && typeof args === "object" ? args as Record<string, unknown> : {};
13632
14031
  if (toolName === "studio_context_read") return `Reading ${String(value.path || "local context")}`;
@@ -13729,19 +14128,20 @@ export default function (pi: ExtensionAPI) {
13729
14128
  const createStudioSideQuestionRuntime = async (context: StudioSideQuestionContextInput): Promise<StudioSideQuestionRuntime> => {
13730
14129
  const ctx = lastCommandCtx;
13731
14130
  if (!ctx) throw new Error("No active Studio command context is available. Re-open Studio and try again.");
13732
- const model = latestModelRequestCtx?.model ?? ctx.model;
13733
- if (!model) throw new Error("No active Pi model is available for side questions.");
13734
- await resolveStudioModelRequestAuth({ model, modelRegistry: ctx.modelRegistry }, model);
13735
- const contextRoot = resolveStudioSideQuestionContextRoot(context);
14131
+ const contextRoot = resolveAuthorizedStudioSideQuestionContextRoot(context);
13736
14132
  if (context.gitContext && context.gatherScope !== "repo") {
13737
14133
  throw new Error("Git context requires Related files to be set to Repository.");
13738
14134
  }
14135
+ const model = latestModelRequestCtx?.model ?? ctx.model;
14136
+ if (!model) throw new Error("No active Pi model is available for side questions.");
14137
+ await resolveStudioModelRequestAuth({ model, modelRegistry: ctx.modelRegistry }, model);
13739
14138
  const gitSnapshot = context.gitContext
13740
14139
  ? await captureStudioSideQuestionGitSnapshot(contextRoot, { runGit: runStudioSideQuestionGitCommand }) as StudioSideQuestionGitSnapshot
13741
14140
  : null;
13742
14141
  const webAvailable = Boolean(String(process.env.BRAVE_API_KEY || "").trim());
13743
14142
  const webEnabled = context.webSearch && webAvailable;
13744
- const { tools, toolNames } = createStudioSideQuestionTools(contextRoot || studioCwd, webEnabled, gitSnapshot);
14143
+ const assertContextRoot = contextRoot ? () => assertStudioSideQuestionContextRootAuthorized(contextRoot) : undefined;
14144
+ const { tools, toolNames } = createStudioSideQuestionTools(contextRoot || studioCwd, webEnabled, gitSnapshot, assertContextRoot);
13745
14145
  const localActiveToolNames = context.gatherScope === "none" ? (webEnabled ? ["studio_web_search"] : []) : toolNames;
13746
14146
  const toolSelection = selectStudioSideQuestionTools(getStudioSideQuestionToolCatalog(), context.toolIds);
13747
14147
  if (toolSelection.missing.length > 0) {
@@ -13924,6 +14324,7 @@ export default function (pi: ExtensionAPI) {
13924
14324
  sendStudioSideQuestionState();
13925
14325
  try {
13926
14326
  const context = msg.context;
14327
+ if (runtime.contextRoot) assertStudioSideQuestionContextRootAuthorized(runtime.contextRoot);
13927
14328
  let prompt: string;
13928
14329
  if (isFollowUp) {
13929
14330
  prompt = buildStudioSideQuestionFollowUpPrompt(msg.question);
@@ -14040,6 +14441,7 @@ export default function (pi: ExtensionAPI) {
14040
14441
  responseHistory: studioResponseHistory,
14041
14442
  traceState: studioTraceState,
14042
14443
  initialDocument: initialStudioDocument,
14444
+ resourceGrants: studioResourceGrantRegistry.snapshot(),
14043
14445
  quartoPreview: getStudioQuartoPreviewSnapshot(),
14044
14446
  sideQuestion: getStudioSideQuestionPublicState(),
14045
14447
  webSearchAvailable: Boolean(String(process.env.BRAVE_API_KEY || "").trim()),
@@ -14996,6 +15398,7 @@ export default function (pi: ExtensionAPI) {
14996
15398
  path: result.resolvedPath,
14997
15399
  resourceDir: dirname(result.resolvedPath),
14998
15400
  };
15401
+ recordStudioDocumentResourceGrants(initialStudioDocument);
14999
15402
 
15000
15403
  sendToClient(client, {
15001
15404
  type: "saved",
@@ -15088,6 +15491,7 @@ export default function (pi: ExtensionAPI) {
15088
15491
  };
15089
15492
  if (!requestedPath || initialStudioDocument?.path === refreshed.resolvedPath) {
15090
15493
  initialStudioDocument = refreshedDocument;
15494
+ recordStudioDocumentResourceGrants(refreshedDocument);
15091
15495
  }
15092
15496
 
15093
15497
  sendToClient(client, {
@@ -15592,14 +15996,25 @@ export default function (pi: ExtensionAPI) {
15592
15996
  parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { editorLanguage?: unknown }).editorLanguage === "string"
15593
15997
  ? (parsedBody as { editorLanguage: string }).editorLanguage
15594
15998
  : "";
15595
- const resourcePath = resolveStudioBaseDir(sourcePath || undefined, userResourceDir || undefined, studioCwd);
15999
+ const authorizedContext = resolveStudioAuthorizedPreviewRenderContext(
16000
+ sourcePath || undefined,
16001
+ userResourceDir || undefined,
16002
+ studioCwd,
16003
+ studioResourceGrantRegistry,
16004
+ );
15596
16005
  const editorPreviewLanguage = normalizeStudioEditorLanguage(requestedEditorLanguage);
15597
16006
  const isLatex = editorPreviewLanguage === "latex"
15598
16007
  || (
15599
16008
  (editorPreviewLanguage === undefined || editorPreviewLanguage === "markdown")
15600
16009
  && isLikelyStandaloneLatexPreview(markdown)
15601
16010
  );
15602
- const html = await renderStudioMarkdownWithPandoc(markdown, isLatex, resourcePath, sourcePath || undefined);
16011
+ const html = await renderStudioMarkdownWithPandoc(
16012
+ markdown,
16013
+ isLatex,
16014
+ authorizedContext.resourcePath,
16015
+ authorizedContext.sourcePath,
16016
+ { embedResources: false },
16017
+ );
15603
16018
  respondJson(res, 200, { ok: true, html, renderer: "pandoc" });
15604
16019
  } catch (error) {
15605
16020
  const message = error instanceof Error ? error.message : String(error);
@@ -16342,13 +16757,31 @@ export default function (pi: ExtensionAPI) {
16342
16757
  try {
16343
16758
  const listing = listStudioFileBrowserDirectory(
16344
16759
  requestUrl.searchParams.get("dir") ?? undefined,
16760
+ requestUrl.searchParams.get("root") ?? undefined,
16345
16761
  requestUrl.searchParams.get("sourcePath") ?? undefined,
16346
16762
  requestUrl.searchParams.get("resourceDir") ?? undefined,
16347
16763
  studioCwd,
16764
+ studioResourceGrantRegistry,
16348
16765
  requestUrl.searchParams.get("sort") ?? undefined,
16349
16766
  );
16350
- respondJson(res, 200, { ok: true, ...listing, entries: method === "HEAD" ? [] : listing.entries });
16767
+ respondJson(res, 200, {
16768
+ ok: true,
16769
+ ...listing,
16770
+ entries: method === "HEAD" ? [] : listing.entries,
16771
+ exactFiles: method === "HEAD" ? [] : listing.exactFiles,
16772
+ });
16351
16773
  } catch (error) {
16774
+ if (error instanceof StudioDirectoryGrantRequiredError) {
16775
+ respondJson(res, 403, {
16776
+ ok: false,
16777
+ code: error.code,
16778
+ error: error.message,
16779
+ path: error.directoryPath,
16780
+ directoryPath: error.directoryPath,
16781
+ ...getStudioFileBrowserGrantLocations(studioResourceGrantRegistry),
16782
+ });
16783
+ return;
16784
+ }
16352
16785
  respondJson(res, 404, { ok: false, error: `File browser unavailable: ${error instanceof Error ? error.message : String(error)}` });
16353
16786
  }
16354
16787
  return;
@@ -16361,7 +16794,7 @@ export default function (pi: ExtensionAPI) {
16361
16794
  return;
16362
16795
  }
16363
16796
 
16364
- void handleOpenStudioFileBrowserDirectoryRequest(req, res, studioCwd).catch((error) => {
16797
+ void handleOpenStudioFileBrowserDirectoryRequest(req, res, studioCwd, studioResourceGrantRegistry).catch((error) => {
16365
16798
  respondJson(res, 500, { ok: false, error: `Open folder failed: ${error instanceof Error ? error.message : String(error)}` });
16366
16799
  });
16367
16800
  return;
@@ -16380,6 +16813,32 @@ export default function (pi: ExtensionAPI) {
16380
16813
  return;
16381
16814
  }
16382
16815
 
16816
+ if (requestUrl.pathname === "/resource-grants") {
16817
+ const token = requestUrl.searchParams.get("token") ?? "";
16818
+ if (token !== serverState.token) {
16819
+ respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
16820
+ return;
16821
+ }
16822
+
16823
+ void handleStudioResourceGrantRequest(req, res, studioResourceGrantRegistry, studioCwd).catch((error) => {
16824
+ respondJson(res, 500, { ok: false, error: `Resource grant failed: ${error instanceof Error ? error.message : String(error)}` });
16825
+ });
16826
+ return;
16827
+ }
16828
+
16829
+ if (requestUrl.pathname === "/side-question-context-root") {
16830
+ const token = requestUrl.searchParams.get("token") ?? "";
16831
+ if (token !== serverState.token) {
16832
+ respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
16833
+ return;
16834
+ }
16835
+
16836
+ void handleStudioSideQuestionContextRootRequest(req, res).catch((error) => {
16837
+ respondJson(res, 500, { ok: false, error: `Side-question context check failed: ${error instanceof Error ? error.message : String(error)}` });
16838
+ });
16839
+ return;
16840
+ }
16841
+
16383
16842
  if (requestUrl.pathname === "/local-preview-link") {
16384
16843
  const token = requestUrl.searchParams.get("token") ?? "";
16385
16844
  if (token !== serverState.token) {
@@ -16394,9 +16853,14 @@ export default function (pi: ExtensionAPI) {
16394
16853
  requestUrl.searchParams.get("sourcePath") ?? undefined,
16395
16854
  requestUrl.searchParams.get("resourceDir") ?? undefined,
16396
16855
  studioCwd,
16856
+ studioResourceGrantRegistry,
16397
16857
  );
16398
- await respondLocalPreviewLinkJson(req, res, requestUrl, resource, serverState);
16858
+ await respondLocalPreviewLinkJson(req, res, requestUrl, resource, serverState, studioResourceGrantRegistry);
16399
16859
  } catch (error) {
16860
+ if (error instanceof StudioResourceGrantRequiredError) {
16861
+ respondStudioResourceGrantRequiredJson(res, error);
16862
+ return;
16863
+ }
16400
16864
  respondJson(res, 404, { ok: false, error: `Local resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
16401
16865
  }
16402
16866
  })();
@@ -16410,7 +16874,7 @@ export default function (pi: ExtensionAPI) {
16410
16874
  return;
16411
16875
  }
16412
16876
 
16413
- void handleRevealLocalPreviewResourceRequest(req, res, studioCwd).catch((error) => {
16877
+ void handleRevealLocalPreviewResourceRequest(req, res, studioCwd, studioResourceGrantRegistry).catch((error) => {
16414
16878
  respondJson(res, 500, { ok: false, error: `Reveal failed: ${error instanceof Error ? error.message : String(error)}` });
16415
16879
  });
16416
16880
  return;
@@ -16423,7 +16887,7 @@ export default function (pi: ExtensionAPI) {
16423
16887
  return;
16424
16888
  }
16425
16889
 
16426
- void handleOpenLocalPreviewResourceRequest(req, res, studioCwd).catch((error) => {
16890
+ void handleOpenLocalPreviewResourceRequest(req, res, studioCwd, studioResourceGrantRegistry).catch((error) => {
16427
16891
  respondJson(res, 500, { ok: false, error: `Open in system viewer failed: ${error instanceof Error ? error.message : String(error)}` });
16428
16892
  });
16429
16893
  return;
@@ -16442,9 +16906,14 @@ export default function (pi: ExtensionAPI) {
16442
16906
  requestUrl.searchParams.get("sourcePath") ?? undefined,
16443
16907
  requestUrl.searchParams.get("resourceDir") ?? undefined,
16444
16908
  studioCwd,
16909
+ studioResourceGrantRegistry,
16445
16910
  );
16446
16911
  respondPdfFile(req, res, filePath);
16447
16912
  } catch (error) {
16913
+ if (error instanceof StudioResourceGrantRequiredError) {
16914
+ respondStudioResourceGrantRequiredJson(res, error);
16915
+ return;
16916
+ }
16448
16917
  respondText(res, 404, `PDF resource unavailable: ${error instanceof Error ? error.message : String(error)}`);
16449
16918
  }
16450
16919
  return;
@@ -16463,10 +16932,15 @@ export default function (pi: ExtensionAPI) {
16463
16932
  requestUrl.searchParams.get("sourcePath") ?? undefined,
16464
16933
  requestUrl.searchParams.get("resourceDir") ?? undefined,
16465
16934
  studioCwd,
16935
+ studioResourceGrantRegistry,
16466
16936
  );
16467
16937
  respondHtmlPreviewResourceJson(req, res, resource.filePath, resource.mimeType);
16468
16938
  } catch (error) {
16469
- respondJson(res, 404, { ok: false, error: `HTML preview resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
16939
+ if (error instanceof StudioResourceGrantRequiredError) {
16940
+ respondStudioResourceGrantRequiredJson(res, error);
16941
+ return;
16942
+ }
16943
+ respondJson(res, 404, { ok: false, error: `Studio preview resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
16470
16944
  }
16471
16945
  return;
16472
16946
  }
@@ -16675,6 +17149,7 @@ export default function (pi: ExtensionAPI) {
16675
17149
  state.server.close(() => resolve());
16676
17150
  });
16677
17151
  studioWorkspaceStateStore.clear();
17152
+ studioResourceGrantRegistry.clear();
16678
17153
  };
16679
17154
 
16680
17155
  const hydrateLatestAssistant = (entries: SessionEntry[]) => {
@@ -17489,6 +17964,7 @@ export default function (pi: ExtensionAPI) {
17489
17964
  ctx.ui.notify(`Failed to start Studio server${portText}: ${message}`, "error");
17490
17965
  return;
17491
17966
  }
17967
+ recordStudioDocumentResourceGrants(selected, ctx.cwd);
17492
17968
  const docId = selection.transient ? storeTransientStudioDocument(selected) : undefined;
17493
17969
  const url = buildStudioUrl(state.port, state.token, launchMode, selected, docId, {
17494
17970
  skipWorkspaceRestore: selection.skipWorkspaceRestore,
@@ -17983,6 +18459,7 @@ export default function (pi: ExtensionAPI) {
17983
18459
  path: file.resolvedPath,
17984
18460
  };
17985
18461
  initialStudioDocument = nextDoc;
18462
+ recordStudioDocumentResourceGrants(nextDoc, ctx.cwd);
17986
18463
 
17987
18464
  broadcastState();
17988
18465
  broadcastResponseHistory();