cc-viewer 1.9.14 → 1.9.16

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.
Files changed (57) hide show
  1. package/cli.js +1 -0
  2. package/concepts/ar/UltraPlan.md +9 -9
  3. package/concepts/da/UltraPlan.md +9 -9
  4. package/concepts/de/UltraPlan.md +9 -9
  5. package/concepts/en/UltraPlan.md +9 -9
  6. package/concepts/es/UltraPlan.md +9 -9
  7. package/concepts/fr/UltraPlan.md +9 -9
  8. package/concepts/it/UltraPlan.md +9 -9
  9. package/concepts/ja/UltraPlan.md +9 -9
  10. package/concepts/ko/UltraPlan.md +9 -9
  11. package/concepts/no/UltraPlan.md +9 -9
  12. package/concepts/pl/UltraPlan.md +9 -9
  13. package/concepts/pt-BR/UltraPlan.md +9 -9
  14. package/concepts/ru/UltraPlan.md +9 -9
  15. package/concepts/th/UltraPlan.md +9 -9
  16. package/concepts/tr/UltraPlan.md +9 -9
  17. package/concepts/uk/UltraPlan.md +9 -9
  18. package/concepts/zh/UltraPlan.md +9 -9
  19. package/concepts/zh-TW/UltraPlan.md +9 -9
  20. package/dist/assets/App-BvVdj099.css +1 -0
  21. package/dist/assets/App-CYdOioAz.js +2 -0
  22. package/dist/assets/{MdxEditorPanel-DI3B4gsM.js → MdxEditorPanel-CuCAe1OG.js} +1 -1
  23. package/dist/assets/Mobile-CfNYOgRa.js +1 -0
  24. package/dist/assets/{ProxyStatsModal-B8K0AtAq.js → ProxyStatsModal-Dhj4EVnh.js} +1 -1
  25. package/dist/assets/index-sgW8MlD3.js +2 -0
  26. package/dist/assets/seqResourceLoaders-B-5xGs9q.css +41 -0
  27. package/dist/assets/seqResourceLoaders-Cj0pdz-l.js +2 -0
  28. package/dist/index.html +1 -1
  29. package/node_modules/@ccv/core/src/contentFilter.js +3 -0
  30. package/node_modules/@ccv/core/src/queued-prompt.js +66 -0
  31. package/node_modules/@ccv/core/src/v2-transcript-normalizer.js +78 -14
  32. package/package.json +1 -1
  33. package/server/lib/ask/perm-bridge.js +4 -1
  34. package/server/lib/auto-approve.js +160 -0
  35. package/server/lib/chat-queue.js +350 -120
  36. package/server/lib/file-access-policy.js +24 -2
  37. package/server/lib/scheduled-task-executor.js +3 -1
  38. package/server/lib/sdk-manager.js +377 -76
  39. package/server/lib/v2/conversation-store.js +15 -11
  40. package/server/lib/v2/v2-writer.js +1 -1
  41. package/server/lib/view-root.js +4 -4
  42. package/server/pty-manager.js +8 -8
  43. package/server/routes/ask-perm.js +54 -3
  44. package/server/routes/files-content.js +352 -16
  45. package/server/routes/files-fs.js +122 -36
  46. package/server/routes/resume.js +1 -1
  47. package/server/routes/sdk-context.js +13 -2
  48. package/server/routes/skills.js +19 -8
  49. package/server/server.js +205 -34
  50. package/server/system-prompt-templates/presets/kimi-k3.md +1 -0
  51. package/ultraAgents/code-expert.json +1 -1
  52. package/dist/assets/App-BzV1ieDH.js +0 -2
  53. package/dist/assets/App-DU5jmGrx.css +0 -1
  54. package/dist/assets/Mobile-qBkAnXy3.js +0 -1
  55. package/dist/assets/index-CU0gt6m1.js +0 -2
  56. package/dist/assets/seqResourceLoaders-CxUFE36z.css +0 -41
  57. package/dist/assets/seqResourceLoaders-nGCs11zu.js +0 -2
@@ -1,26 +1,36 @@
1
1
  // Read-oriented file-access routes (moved verbatim from server.js handleRequest).
2
- import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync, realpathSync, createReadStream } from 'node:fs';
3
- import { join, basename, dirname, resolve, sep } from 'node:path';
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync, realpathSync, createReadStream, readdirSync } from 'node:fs';
3
+ import { join, basename, dirname, resolve, relative, sep } from 'node:path';
4
+ import { homedir } from 'node:os';
4
5
  import { isReadAllowed, reasonToStatus } from '../lib/file-access-policy.js';
5
- import { ERROR_STATUS_MAP } from '../lib/file-api.js';
6
- import { viewRootOrReply } from '../lib/view-root.js';
6
+ import { ERROR_STATUS_MAP, isPathContained } from '../lib/file-api.js';
7
+ import { listSkills } from '../lib/skills-api.js';
8
+ import { viewRootOrReply, resolveViewRoot } from '../lib/view-root.js';
9
+ import { findSdkSessionByInstance } from '../lib/sdk-session-index.js';
10
+ import { projectKeyForCwd } from '../lib/system-prompt-snapshots.js';
7
11
  import { loadWorkspaces } from '../workspace-registry.js';
8
12
  import { listLivePtys } from '../pty-manager.js';
9
13
  import { reportSwallowed } from '../lib/error-report.js';
10
14
  import { discoverClaudeMdCandidates, readCandidateById } from '../lib/claude-md-discovery.js';
11
15
  import { getClaudeConfigDir } from '../../findcc.js';
12
16
  import { _projectName } from '../interceptor.js';
17
+ import { isAdminReq } from '../lib/is-admin.js';
18
+ import { isSameOriginBrowserRequest } from '../lib/same-origin.js';
13
19
 
14
- // Multi-project (2026-10): project-scoped READ routes resolve the viewed
15
- // project via the shared viewRootOrReply (?project=). WRITE routes keep the
16
- // bound root. SYNCHRONOUS: these handlers were sync before multi-project, and
20
+ // Multi-project file operations use the same viewed root for reads and writes.
21
+ // Every file-browser mutation enforces realpath containment in that root;
22
+ // explicit editor sessions retain their existing allowlisted external paths.
17
23
  // their tests (and the dispatcher's sync-call convention) assert on res
18
24
  // immediately after the call — the override must not async-ify them.
19
- function _viewRootOrReply(req, res, parsedUrl) {
25
+ // Both reads and writes resolve the viewed project. Absolute editor paths are
26
+ // still governed by the allowlist; browser writes stay within the selected root.
27
+ function _viewRootOrReply(req, res, parsedUrl, bodyProject, bodyInstance) {
20
28
  return viewRootOrReply(req, res, parsedUrl, {
21
29
  boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
22
30
  loadWorkspaces,
23
31
  listLivePtys,
32
+ bodyProject,
33
+ bodyInstance,
24
34
  });
25
35
  }
26
36
 
@@ -167,6 +177,37 @@ function projectMemory(req, res, parsedUrl) {
167
177
  projectMemoryEntry(req, res, parsedUrl, respondJson);
168
178
  }
169
179
 
180
+ // The SDK context snapshot prices MEMORY.md itself, not every linked detail file.
181
+ // Supplement only the index's existing single-segment .md links with a transparent
182
+ // content-length estimate; never allocate the SDK total across unrelated files.
183
+ export function estimateLinkedMemoryTokens(content, dir) {
184
+ const estimates = {};
185
+ const MAX_BYTES = 512 * 1024;
186
+ const MAX_LINKS = 100;
187
+ if (typeof content !== 'string' || typeof dir !== 'string') return estimates;
188
+ const links = new Set();
189
+ for (const match of content.matchAll(/\]\(\s*([^)\s]+\.md)(?:[?#][^)\s]*)?(?:\s+"[^"]*")?\s*\)/gi)) {
190
+ let name;
191
+ try { name = decodeURIComponent(match[1].split('#')[0].split('?')[0]); } catch { continue; }
192
+ if (!name || name.includes('/') || name.includes('\\') || name.includes('\0') || name.startsWith('.')) continue;
193
+ links.add(name);
194
+ if (links.size >= MAX_LINKS) break;
195
+ }
196
+ let realDir;
197
+ try { realDir = realpathSync(dir); } catch { return estimates; }
198
+ const prefix = realDir.endsWith(sep) ? realDir : realDir + sep;
199
+ for (const name of links) {
200
+ try {
201
+ const realFile = realpathSync(join(realDir, name));
202
+ const stat = statSync(realFile);
203
+ if (!realFile.startsWith(prefix) || !stat.isFile() || stat.size > MAX_BYTES) continue;
204
+ if (!isReadAllowed(realFile).ok) continue;
205
+ estimates[name] = { tokens: Math.ceil(stat.size / 4), source: 'file-content-estimate' };
206
+ } catch { /* Missing/unreadable links remain without an estimate. */ }
207
+ }
208
+ return estimates;
209
+ }
210
+
170
211
  // The entry wraps BOTH the view-root resolution and the read in the endpoint's
171
212
  // single error surface (the searchParams-throw defense test asserts a 500 from
172
213
  // THIS catch, regardless of which stage throws).
@@ -202,7 +243,9 @@ function projectMemoryWithRoot(req, res, parsedUrl, respondJson, cwd) {
202
243
  if (!st.isFile()) return respondJson(400, { error: 'Not a file' });
203
244
  if (st.size > MAX_BYTES) return respondJson(413, { error: 'File too large' });
204
245
  const content = readFileSync(policy.real, 'utf-8');
205
- return respondJson(200, { exists: true, dir, indexPath, content });
246
+ // Only SDK mode consumes these estimates. PTY keeps the original cheap index read.
247
+ const memoryTokenEstimates = process.env.CCV_SDK_MODE === '1' ? estimateLinkedMemoryTokens(content, dir) : undefined;
248
+ return respondJson(200, { exists: true, dir, indexPath, content, memoryTokenEstimates });
206
249
  }
207
250
 
208
251
  // 明细文件: 仅接受单段 basename + .md 后缀
@@ -231,7 +274,7 @@ function projectMemoryWithRoot(req, res, parsedUrl, respondJson, cwd) {
231
274
  if (!st.isFile()) return respondJson(400, { error: 'Not a file' });
232
275
  if (st.size > MAX_BYTES) return respondJson(413, { error: 'File too large' });
233
276
  const content = readFileSync(realFile, 'utf-8');
234
- return respondJson(200, { name: fileParam, path: realFile, content });
277
+ return respondJson(200, { name: fileParam, path: realFile, content, tokens: Math.ceil(Buffer.byteLength(content, 'utf8') / 4), tokenSource: 'file-content-estimate' });
235
278
  } catch (err) {
236
279
  // 与 /api/file-content 一致:已知 errno(ENOENT/EACCES 等)走 ERROR_STATUS_MAP 映射;
237
280
  // 500 时不回显 err.message —— 可能含 ~/.claude/projects/<encoded>/memory/ 路径片段。
@@ -311,13 +354,51 @@ function fileRaw(req, res, parsedUrl) {
311
354
  const url = parsedUrl.pathname;
312
355
  const method = req.method;
313
356
  let reqPath;
314
- if (url.startsWith('/api/file-raw/')) {
357
+ let scopedProject = '';
358
+ let scopedInstance = '';
359
+ if (url.startsWith('/api/file-raw/view/')) {
360
+ const parts = url.slice('/api/file-raw/view/'.length).split('/');
361
+ let project = '';
362
+ let instance = '';
363
+ try {
364
+ project = decodeURIComponent(parts[0] || '');
365
+ instance = decodeURIComponent(parts[1] || '');
366
+ reqPath = decodeURIComponent(parts.slice(2).join('/'));
367
+ } catch {
368
+ res.writeHead(400, { 'Content-Type': 'application/json' });
369
+ res.end(JSON.stringify({ error: 'Invalid path' }));
370
+ return;
371
+ }
372
+ scopedProject = project;
373
+ scopedInstance = instance;
374
+ } else if (url.startsWith('/api/file-raw/')) {
315
375
  const tail = parsedUrl.pathname.slice('/api/file-raw/'.length);
316
376
  try { reqPath = decodeURIComponent(tail); } catch { reqPath = tail; }
317
377
  } else {
318
378
  reqPath = parsedUrl.searchParams.get('path');
319
379
  }
320
- const cwd = _viewRootOrReply(req, res, parsedUrl);
380
+ let resolvedView = null;
381
+ if (scopedProject) {
382
+ resolvedView = resolveViewRoot({
383
+ projectParam: scopedProject,
384
+ instanceParam: scopedInstance,
385
+ boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
386
+ loadWorkspaces,
387
+ listLivePtys,
388
+ });
389
+ // The path already pins its project. A stale/mismatched instance must not
390
+ // silently redirect it to the bound project or a different workspace.
391
+ const instanceRecord = scopedInstance ? findSdkSessionByInstance(scopedInstance) : null;
392
+ const instanceMatches = !scopedInstance || (instanceRecord
393
+ && (instanceRecord.projectName === scopedProject
394
+ || projectKeyForCwd(instanceRecord.cwd) === scopedProject));
395
+ if (!resolvedView.ok || (scopedInstance && !instanceMatches)) {
396
+ res.writeHead(resolvedView.ok ? 400 : resolvedView.status, { 'Content-Type': 'application/json' });
397
+ res.end(JSON.stringify({ error: resolvedView.ok ? 'Project scope mismatch' : resolvedView.error }));
398
+ return;
399
+ }
400
+ }
401
+ const cwd = resolvedView?.root || _viewRootOrReply(req, res, parsedUrl);
321
402
  if (!cwd) return;
322
403
  fileRawWithRoot(req, res, parsedUrl, reqPath, cwd, method);
323
404
  }
@@ -460,7 +541,11 @@ function fileDownloadWithRoot(req, res, parsedUrl, reqPath, cwd) {
460
541
  }
461
542
  }
462
543
 
463
- function fileContentPost(req, res) {
544
+ function fileContentPost(req, res, parsedUrl, isLocal) {
545
+ // The shared read root keeps its raw cwd for projectMemory's store-key
546
+ // encoding. Normalize only this mutation's containment comparisons.
547
+ let writeRoot;
548
+ const isInsideWriteRoot = (path) => isPathContained(path, writeRoot);
464
549
  const MAX_BODY = 5 * 1024 * 1024; // 5MB,与 GET 路由限制对齐
465
550
  let body = '';
466
551
  let overflow = false;
@@ -475,8 +560,10 @@ function fileContentPost(req, res) {
475
560
  return;
476
561
  }
477
562
  try {
478
- const { path: reqPath, content } = JSON.parse(body);
479
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
563
+ const { path: reqPath, content, editorSession } = JSON.parse(body);
564
+ const cwd = _viewRootOrReply(req, res, parsedUrl);
565
+ if (!cwd) return;
566
+ try { writeRoot = realpathSync(cwd); } catch { writeRoot = cwd; }
480
567
  if (!reqPath) {
481
568
  res.writeHead(400, { 'Content-Type': 'application/json' });
482
569
  res.end(JSON.stringify({ error: 'Invalid path' }));
@@ -489,7 +576,6 @@ function fileContentPost(req, res) {
489
576
  }
490
577
  const isAbs = /^([a-zA-Z]:[\\/]|[\\/])/.test(reqPath);
491
578
  const absPath = isAbs ? reqPath : resolve(cwd, reqPath);
492
-
493
579
  // 写路径同样走 policy(收敛 editorSession 后门):允许覆盖现有文件;
494
580
  // 新文件场景递归向上找最近存在的祖先目录,只要它在 allowlist 内即放行。
495
581
  // (旧实现只查 immediate parent,父目录也不存在时误拒嵌套新建。)
@@ -525,6 +611,20 @@ function fileContentPost(req, res) {
525
611
  res.end(JSON.stringify(body));
526
612
  return;
527
613
  }
614
+ // Check containment before creating any missing parent directories.
615
+ // Explicit $EDITOR sessions retain their allowlisted external paths.
616
+ // editorSession is client-claimed — require admin to honor it, otherwise
617
+ // any caller could use it as a write-root bypass (see /api/target-files).
618
+ if (editorSession && !isAdminReq(req, isLocal)) {
619
+ res.writeHead(403, { 'Content-Type': 'application/json' });
620
+ res.end(JSON.stringify({ error: 'Forbidden', reason: 'editor-session-requires-admin' }));
621
+ return;
622
+ }
623
+ if (!editorSession && !isInsideWriteRoot(ancestorPolicy.real)) {
624
+ res.writeHead(403, { 'Content-Type': 'application/json' });
625
+ res.end(JSON.stringify({ error: 'Forbidden', reason: 'outside-view-root' }));
626
+ return;
627
+ }
528
628
  // 在祖先 real 路径下重建目标。Denylist 已在祖先 prefix 层生效,
529
629
  // 这里相信 allowlist 祖先的合法性(项目目录 / ~/.claude/cc-viewer 等)。
530
630
  targetReal = join(ancestorPolicy.real, ...descent);
@@ -539,6 +639,16 @@ function fileContentPost(req, res) {
539
639
  return;
540
640
  }
541
641
 
642
+ if (editorSession && !isAdminReq(req, isLocal)) {
643
+ res.writeHead(403, { 'Content-Type': 'application/json' });
644
+ res.end(JSON.stringify({ error: 'Forbidden', reason: 'editor-session-requires-admin' }));
645
+ return;
646
+ }
647
+ if (!editorSession && existsSync(targetReal) && !isInsideWriteRoot(targetReal)) {
648
+ res.writeHead(403, { 'Content-Type': 'application/json' });
649
+ res.end(JSON.stringify({ error: 'Forbidden', reason: 'outside-view-root' }));
650
+ return;
651
+ }
542
652
  writeFileSync(targetReal, content, 'utf-8');
543
653
  const stat = statSync(targetReal);
544
654
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -552,6 +662,231 @@ function fileContentPost(req, res) {
552
662
  });
553
663
  }
554
664
 
665
+ function targetError(status, message) {
666
+ return Object.assign(new Error(message), { status });
667
+ }
668
+
669
+ // Map fs errno to a sensible HTTP status: missing → 404, permission → 403,
670
+ // symlink loop → 400 (treat as malformed), anything else → 404 (don't leak).
671
+ function errnoToStatus(err) {
672
+ const code = err?.code;
673
+ if (code === 'EACCES' || code === 'EPERM') return 403;
674
+ if (code === 'ELOOP') return 400;
675
+ return 404;
676
+ }
677
+
678
+ function realDirectory(path, label) {
679
+ let real;
680
+ try { real = realpathSync(path); } catch (err) { throw targetError(errnoToStatus(err), `${label} not found`); }
681
+ let st;
682
+ try { st = statSync(real); } catch (err) { throw targetError(errnoToStatus(err), `${label} not found`); }
683
+ if (!st.isDirectory()) throw targetError(400, `${label} is not a directory`);
684
+ return real;
685
+ }
686
+
687
+ function checkedDirectory(path, label) {
688
+ const real = realDirectory(path, label);
689
+ const policy = isReadAllowed(real);
690
+ if (!policy.ok) throw targetError(reasonToStatus(policy.reason), `${label} not readable`);
691
+ return policy.real;
692
+ }
693
+
694
+ function insidePath(path, root) {
695
+ return path === root || path.startsWith(root + sep);
696
+ }
697
+
698
+ function targetNavigation(targetReal, cwd) {
699
+ const projectReal = realDirectory(cwd, 'Project');
700
+ if (insidePath(targetReal, projectReal)) return { rootReal: projectReal, rootKind: 'project' };
701
+ // $HOME becomes the navigation root for home-resident targets (user skills,
702
+ // plugins, the global CLAUDE.md). Entries outside the allowlist (e.g. ~/.ssh,
703
+ // ~/.aws) are filtered per-entry by isReadAllowed, and sensitive paths inside
704
+ // ~/.claude are covered by file-access-policy's denylist. The /api/target-files
705
+ // handler additionally gates this endpoint behind isAdminReq + same-origin so
706
+ // a cross-site page cannot enumerate the user's home tree.
707
+ const homeReal = realDirectory(homedir(), 'Home');
708
+ if (insidePath(targetReal, homeReal)) {
709
+ checkedDirectory(targetReal, 'Directory');
710
+ return { rootReal: homeReal, rootKind: 'home' };
711
+ }
712
+ const configReal = realDirectory(getClaudeConfigDir(), 'Claude config');
713
+ if (insidePath(targetReal, configReal)) {
714
+ checkedDirectory(targetReal, 'Directory');
715
+ return { rootReal: realDirectory(dirname(configReal), 'Config root'), rootKind: 'config' };
716
+ }
717
+ // Allowlist fallback: targets that live inside another allowlisted root
718
+ // (registered workspaces, /tmp/cc-viewer-uploads, etc.) are reachable via
719
+ // /api/claude-md's discovery (which uses isReadAllowed), so we must not
720
+ // 403 them here. Use the target's own directory as the navigation root —
721
+ // no parent browsing, but at least the entry the user clicked actually opens.
722
+ const policy = isReadAllowed(targetReal);
723
+ if (!policy.ok) throw targetError(reasonToStatus(policy.reason), 'Target not readable');
724
+ return { rootReal: realDirectory(targetReal, 'Target'), rootKind: 'target' };
725
+ }
726
+
727
+ function targetScope(targetReal, cwd) {
728
+ const navigation = targetNavigation(targetReal, cwd);
729
+ return { ...navigation, targetPath: relative(navigation.rootReal, targetReal).split(sep).join('/') };
730
+ }
731
+
732
+ function targetRelPath(raw) {
733
+ if (raw == null) return null;
734
+ if (raw === '') return [];
735
+ if (typeof raw !== 'string' || raw.length > 2048 || raw.includes('\0') || raw.includes('\\')
736
+ || raw.startsWith('/') || /^[a-zA-Z]:/.test(raw)) {
737
+ throw targetError(400, 'Invalid path');
738
+ }
739
+ const segments = raw.split('/');
740
+ if (segments.some(segment => !segment || segment === '.' || segment === '..')) {
741
+ throw targetError(400, 'Invalid path');
742
+ }
743
+ // Hard cap on navigation depth: each segment triggers at least one
744
+ // synchronous readdir + per-entry realpath/stat in `ancestors`, and a 2KB
745
+ // free-form path could otherwise fan out into hundreds of syscalls per
746
+ // request. 32 is far deeper than any realistic skill / CLAUDE.md location.
747
+ if (segments.length > 32) throw targetError(400, 'Path too deep');
748
+ return segments;
749
+ }
750
+
751
+ function resolveTargetRoot(parsedUrl, cwd) {
752
+ const kind = parsedUrl.searchParams.get('kind');
753
+ if (kind === 'claude-md') {
754
+ const id = parsedUrl.searchParams.get('id') || '';
755
+ if (!/^[0-9a-f]{12}$/i.test(id)) throw targetError(400, 'Invalid id');
756
+ const candidate = discoverClaudeMdCandidates({
757
+ cwd,
758
+ claudeConfigDir: getClaudeConfigDir(),
759
+ isReadAllowedFn: isReadAllowed,
760
+ }).find(entry => entry.id === id);
761
+ if (!candidate || basename(candidate.realPath) !== 'CLAUDE.md') throw targetError(404, 'Target not found');
762
+ return { ...targetScope(realDirectory(dirname(candidate.realPath), 'Directory'), cwd), targetKind: 'claude-md' };
763
+ }
764
+ if (kind !== 'skill') throw targetError(400, 'Invalid target kind');
765
+ const source = parsedUrl.searchParams.get('source');
766
+ const targetPath = parsedUrl.searchParams.get('targetPath');
767
+ if (!['user', 'project', 'plugin'].includes(source)) throw targetError(400, 'Invalid skill source');
768
+ if (typeof targetPath !== 'string' || !targetPath || targetPath.length > 4096 || targetPath.includes('\0')) {
769
+ throw targetError(400, 'Invalid skill path');
770
+ }
771
+ const skill = listSkills({ projectDir: cwd })
772
+ .find(entry => entry.source === source && entry.path === targetPath);
773
+ if (!skill) throw targetError(404, 'Target not found');
774
+ const rootReal = realDirectory(skill.path, 'Directory');
775
+ const scope = targetScope(rootReal, cwd);
776
+ const anchor = source === 'project'
777
+ ? scope.rootReal
778
+ : dirname(dirname(skill.path));
779
+ const anchorReal = realDirectory(anchor, 'Skill root');
780
+ if (!insidePath(rootReal, anchorReal)) throw targetError(403, 'Target outside skill root');
781
+ // Plugin root must follow the configured Claude config dir, not the default
782
+ // homedir()/.claude — otherwise plugin skills break under CLAUDE_CONFIG_DIR.
783
+ if (source === 'plugin' && !insidePath(rootReal, realDirectory(join(getClaudeConfigDir(), 'plugins'), 'Plugin root'))) {
784
+ throw targetError(403, 'Target outside plugin root');
785
+ }
786
+ return { ...scope, targetKind: 'skill' };
787
+ }
788
+
789
+ function listTargetDirectory(rootReal, segments, deps, requireReadable = true) {
790
+ const dirReal = requireReadable
791
+ ? checkedDirectory(segments.length ? join(rootReal, ...segments) : rootReal, 'Directory')
792
+ : realDirectory(segments.length ? join(rootReal, ...segments) : rootReal, 'Directory');
793
+ if (!isPathContained(dirReal, rootReal)) throw targetError(403, 'Path outside target');
794
+ const ignored = deps?.IGNORED_PATTERNS || new Set();
795
+ const entries = [];
796
+ let dirents;
797
+ try {
798
+ dirents = readdirSync(dirReal, { withFileTypes: true });
799
+ } catch (err) {
800
+ // EACCES → 403 (permission), ENOENT/ENOTDIR → 404 (race with rename/delete),
801
+ // anything else falls back to 404 so we don't leak internal state via 500.
802
+ throw targetError(errnoToStatus(err), 'Directory not readable');
803
+ }
804
+ // Hard cap on returned entries: this is a synchronous loop with realpath +
805
+ // stat + isReadAllowed per entry, and the endpoint fans out via `ancestors`,
806
+ // so an unbounded listing of a huge dir (e.g. ~/.claude/projects/<hash>/)
807
+ // would block the event loop and return a giant JSON payload. 5000 is well
808
+ // past any realistic skill/CLAUDE.md folder.
809
+ const MAX_ENTRIES = 5000;
810
+ for (const entry of dirents) {
811
+ if (entries.length >= MAX_ENTRIES) break;
812
+ if (ignored.has(entry.name)) continue;
813
+ const fullPath = join(dirReal, entry.name);
814
+ let real;
815
+ try { real = realpathSync(fullPath); } catch (err) { reportSwallowed('targetFiles.entry.realpath', err); continue; }
816
+ if (!insidePath(real, rootReal)) continue;
817
+ const policy = isReadAllowed(real);
818
+ if (!policy.ok) continue;
819
+ let st;
820
+ try { st = statSync(policy.real); } catch (err) { reportSwallowed('targetFiles.entry.stat', err); continue; }
821
+ if (!st.isDirectory() && !st.isFile()) continue;
822
+ entries.push({ name: entry.name, type: st.isDirectory() ? 'directory' : 'file', absPath: policy.real });
823
+ }
824
+ entries.sort((a, b) => a.type !== b.type
825
+ ? (a.type === 'directory' ? -1 : 1)
826
+ : a.name.localeCompare(b.name));
827
+ return entries;
828
+ }
829
+
830
+ function targetFiles(req, res, parsedUrl, isLocal, deps) {
831
+ try {
832
+ // /api/target-files exposes directory listings under the user's home and
833
+ // ~/.claude (which contains transcripts, prompt history, env snapshots).
834
+ // Combined with the global `Access-Control-Allow-Origin: *` and loopback's
835
+ // no-auth path, a bare GET would let any web page enumerate the user's
836
+ // ~/.claude tree. Require admin AND a same-origin browser request.
837
+ if (!isAdminReq(req, isLocal) || !isSameOriginBrowserRequest(req, parsedUrl)) {
838
+ res.writeHead(403, { 'Content-Type': 'application/json' });
839
+ res.end(JSON.stringify({ error: 'forbidden' }));
840
+ return;
841
+ }
842
+ const cwd = _viewRootOrReply(req, res, parsedUrl);
843
+ if (!cwd) return;
844
+ const scope = resolveTargetRoot(parsedUrl, cwd);
845
+ const rawPath = parsedUrl.searchParams.get('path');
846
+ const requestedSegments = targetRelPath(rawPath);
847
+ const segments = requestedSegments === null ? scope.targetPath.split('/').filter(Boolean) : requestedSegments;
848
+ const entries = listTargetDirectory(scope.rootReal, segments, deps, segments.length > 0 || scope.rootKind !== 'home');
849
+ const rootEntries = requestedSegments === null && segments.length > 0
850
+ ? listTargetDirectory(scope.rootReal, [], deps, scope.rootKind === 'project')
851
+ : entries;
852
+ const targetEntries = requestedSegments === null
853
+ ? entries.filter(entry => insidePath(entry.absPath, join(scope.rootReal, ...segments)))
854
+ : entries;
855
+ const targetSegments = scope.targetPath ? scope.targetPath.split('/') : [];
856
+ const ancestors = [];
857
+ // Ancestors are the chain from the navigation root down to the directory
858
+ // being listed. When the caller passed no `path` that's the chain leading
859
+ // to the target itself; when they passed `path`, the chain is the requested
860
+ // segments. We deliberately DO NOT use path.relative(targetPath, rawPath)
861
+ // here — that helper happily produces "../.." for sibling branches, which
862
+ // would pollute the cache with pseudo-paths and re-list directories the
863
+ // user never navigated into.
864
+ const chainSegments = requestedSegments === null ? targetSegments : segments;
865
+ // Request-scoped memo: targetFiles lists the same directory multiple times
866
+ // (entries + ancestors chain often share prefixes), so we cache by dir key
867
+ // to avoid duplicate readdirSync storms on large directories.
868
+ const listMemo = new Map();
869
+ const listOnce = (segs, requireReadable) => {
870
+ const key = segs.join('/') + (requireReadable ? '|r' : '|n');
871
+ if (!listMemo.has(key)) listMemo.set(key, listTargetDirectory(scope.rootReal, segs, deps, requireReadable));
872
+ return listMemo.get(key);
873
+ };
874
+ const ancestorCount = Math.max(0, chainSegments.length - (requestedSegments === null ? 0 : 1));
875
+ for (let i = 0; i <= ancestorCount; i++) {
876
+ const path = chainSegments.slice(0, i).join('/');
877
+ ancestors.push({ path, entries: listOnce(path ? path.split('/') : [], false) });
878
+ }
879
+ const { rootReal, ...response } = scope;
880
+ res.writeHead(200, { 'Content-Type': 'application/json' });
881
+ res.end(JSON.stringify({ ...response, targetPath: segments.join('/'), entries: rootEntries, targetEntries, ancestors }));
882
+ } catch (err) {
883
+ const status = err.status || ERROR_STATUS_MAP[err.code] || 500;
884
+ if (status === 500) console.error('[api/target-files]', err);
885
+ res.writeHead(status, { 'Content-Type': 'application/json' });
886
+ res.end(JSON.stringify({ error: status === 500 ? 'Internal error' : err.message }));
887
+ }
888
+ }
889
+
555
890
  export const filesContentRoutes = [
556
891
  { method: 'GET', match: 'exact', path: '/api/plan-file', handler: planFile },
557
892
  { method: 'GET', match: 'exact', path: '/api/file-content', handler: fileContentGet },
@@ -560,4 +895,5 @@ export const filesContentRoutes = [
560
895
  { predicate: (url, method) => (url === '/api/file-raw' || url.startsWith('/api/file-raw/')) && (method === 'GET' || method === 'HEAD'), handler: fileRaw },
561
896
  { predicate: (url, method) => url === '/api/download-file' && (method === 'GET' || method === 'HEAD'), handler: fileDownload },
562
897
  { method: 'POST', match: 'exact', path: '/api/file-content', handler: fileContentPost },
898
+ { method: 'GET', match: 'exact', path: '/api/target-files', handler: targetFiles },
563
899
  ];