cc-viewer 1.9.15 → 1.9.17

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 (49) 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-BlNbziAQ.js +2 -0
  21. package/dist/assets/App-BvVdj099.css +1 -0
  22. package/dist/assets/{MdxEditorPanel-DRLFmdU5.js → MdxEditorPanel-C9cr-WBH.js} +1 -1
  23. package/dist/assets/Mobile-CDQPYYQo.js +1 -0
  24. package/dist/assets/{ProxyStatsModal-Ba1E1lu4.js → ProxyStatsModal-BEqrTR-F.js} +1 -1
  25. package/dist/assets/index-B4J-uwYG.js +2 -0
  26. package/dist/assets/seqResourceLoaders-B-5xGs9q.css +41 -0
  27. package/dist/assets/seqResourceLoaders-DaZ8fZXu.js +2 -0
  28. package/dist/index.html +1 -1
  29. package/package.json +1 -1
  30. package/server/lib/ask/perm-bridge.js +4 -1
  31. package/server/lib/auto-approve.js +160 -0
  32. package/server/lib/file-access-policy.js +132 -5
  33. package/server/lib/os-open.js +66 -0
  34. package/server/lib/sdk-manager.js +96 -9
  35. package/server/lib/target-scope.js +174 -0
  36. package/server/routes/ask-perm.js +54 -3
  37. package/server/routes/files-content.js +174 -8
  38. package/server/routes/files-fs.js +8 -54
  39. package/server/routes/skills.js +19 -8
  40. package/server/routes/target-files-fs.js +332 -0
  41. package/server/server.js +89 -14
  42. package/server/system-prompt-templates/presets/kimi-k3.md +1 -0
  43. package/ultraAgents/code-expert.json +1 -1
  44. package/dist/assets/App-BCDnm2Lh.js +0 -2
  45. package/dist/assets/App-CCnPkf2_.css +0 -1
  46. package/dist/assets/Mobile-C8VJqBwa.js +0 -1
  47. package/dist/assets/index-Sp3JYYKo.js +0 -2
  48. package/dist/assets/seqResourceLoaders-BUfjTaCV.css +0 -41
  49. package/dist/assets/seqResourceLoaders-CHmtPDFO.js +0 -2
@@ -0,0 +1,174 @@
1
+ // Shared resolution of a "target" (CLAUDE.md / skill folder) into the
2
+ // navigation root the file browser walks, plus the path validation both the
3
+ // read route (/api/target-files in routes/files-content.js) and the write
4
+ // routes (/api/target-* in routes/target-files-fs.js) rely on.
5
+ //
6
+ // These helpers were moved verbatim out of routes/files-content.js so the new
7
+ // target write routes resolve the EXACT same root as the read route — never
8
+ // trust a client-supplied absolute root, always re-derive it from the target
9
+ // identity (kind + id, or source + targetPath) via resolveTargetRoot.
10
+ //
11
+ // Layering: this is L1 (lib). It must NOT import from routes/ — routes import
12
+ // lib, never the reverse (enforced by pnpm run verify:boundaries).
13
+ import { realpathSync, statSync } from 'node:fs';
14
+ import { basename, dirname, join, relative, sep } from 'node:path';
15
+ import { homedir } from 'node:os';
16
+ import { isReadAllowed, reasonToStatus } from './file-access-policy.js';
17
+ import { listSkills } from './skills-api.js';
18
+ import { discoverClaudeMdCandidates } from './claude-md-discovery.js';
19
+ import { getClaudeConfigDir } from '../../findcc.js';
20
+
21
+ export function targetError(status, message) {
22
+ return Object.assign(new Error(message), { status });
23
+ }
24
+
25
+ // Map fs errno to a sensible HTTP status: missing → 404, permission → 403,
26
+ // symlink loop → 400 (treat as malformed), anything else → 404 (don't leak).
27
+ export function errnoToStatus(err) {
28
+ const code = err?.code;
29
+ if (code === 'EACCES' || code === 'EPERM') return 403;
30
+ if (code === 'ELOOP') return 400;
31
+ return 404;
32
+ }
33
+
34
+ export function realDirectory(path, label) {
35
+ let real;
36
+ try { real = realpathSync(path); } catch (err) { throw targetError(errnoToStatus(err), `${label} not found`); }
37
+ let st;
38
+ try { st = statSync(real); } catch (err) { throw targetError(errnoToStatus(err), `${label} not found`); }
39
+ if (!st.isDirectory()) throw targetError(400, `${label} is not a directory`);
40
+ return real;
41
+ }
42
+
43
+ export function checkedDirectory(path, label) {
44
+ const real = realDirectory(path, label);
45
+ const policy = isReadAllowed(real);
46
+ if (!policy.ok) throw targetError(reasonToStatus(policy.reason), `${label} not readable`);
47
+ return policy.real;
48
+ }
49
+
50
+ export function insidePath(path, root) {
51
+ return path === root || path.startsWith(root + sep);
52
+ }
53
+
54
+ export function targetNavigation(targetReal, cwd) {
55
+ const projectReal = realDirectory(cwd, 'Project');
56
+ if (insidePath(targetReal, projectReal)) return { rootReal: projectReal, rootKind: 'project' };
57
+ // $HOME becomes the navigation root for home-resident targets (user skills,
58
+ // plugins, the global CLAUDE.md). Entries outside the allowlist (e.g. ~/.ssh,
59
+ // ~/.aws) are filtered per-entry by isReadAllowed, and sensitive paths inside
60
+ // ~/.claude are covered by file-access-policy's denylist. The /api/target-files
61
+ // handler additionally gates this endpoint behind isAdminReq + same-origin so
62
+ // a cross-site page cannot enumerate the user's home tree.
63
+ const homeReal = realDirectory(homedir(), 'Home');
64
+ if (insidePath(targetReal, homeReal)) {
65
+ checkedDirectory(targetReal, 'Directory');
66
+ return { rootReal: homeReal, rootKind: 'home' };
67
+ }
68
+ const configReal = realDirectory(getClaudeConfigDir(), 'Claude config');
69
+ if (insidePath(targetReal, configReal)) {
70
+ checkedDirectory(targetReal, 'Directory');
71
+ return { rootReal: realDirectory(dirname(configReal), 'Config root'), rootKind: 'config' };
72
+ }
73
+ // Allowlist fallback: targets that live inside another allowlisted root
74
+ // (registered workspaces, /tmp/cc-viewer-uploads, etc.) are reachable via
75
+ // /api/claude-md's discovery (which uses isReadAllowed), so we must not
76
+ // 403 them here. Use the target's own directory as the navigation root —
77
+ // no parent browsing, but at least the entry the user clicked actually opens.
78
+ const policy = isReadAllowed(targetReal);
79
+ if (!policy.ok) throw targetError(reasonToStatus(policy.reason), 'Target not readable');
80
+ return { rootReal: realDirectory(targetReal, 'Target'), rootKind: 'target' };
81
+ }
82
+
83
+ export function targetScope(targetReal, cwd) {
84
+ const navigation = targetNavigation(targetReal, cwd);
85
+ return { ...navigation, targetPath: relative(navigation.rootReal, targetReal).split(sep).join('/') };
86
+ }
87
+
88
+ export function targetRelPath(raw) {
89
+ if (raw == null) return null;
90
+ if (raw === '') return [];
91
+ if (typeof raw !== 'string' || raw.length > 2048 || raw.includes('\0') || raw.includes('\\')
92
+ || raw.startsWith('/') || /^[a-zA-Z]:/.test(raw)) {
93
+ throw targetError(400, 'Invalid path');
94
+ }
95
+ const segments = raw.split('/');
96
+ if (segments.some(segment => !segment || segment === '.' || segment === '..')) {
97
+ throw targetError(400, 'Invalid path');
98
+ }
99
+ // Hard cap on navigation depth: each segment triggers at least one
100
+ // synchronous readdir + per-entry realpath/stat in `ancestors`, and a 2KB
101
+ // free-form path could otherwise fan out into hundreds of syscalls per
102
+ // request. 32 is far deeper than any realistic skill / CLAUDE.md location.
103
+ if (segments.length > 32) throw targetError(400, 'Path too deep');
104
+ return segments;
105
+ }
106
+
107
+ export function resolveTargetRoot(parsedUrl, cwd) {
108
+ const kind = parsedUrl.searchParams.get('kind');
109
+ if (kind === 'claude-md') {
110
+ const id = parsedUrl.searchParams.get('id') || '';
111
+ if (!/^[0-9a-f]{12}$/i.test(id)) throw targetError(400, 'Invalid id');
112
+ const candidate = discoverClaudeMdCandidates({
113
+ cwd,
114
+ claudeConfigDir: getClaudeConfigDir(),
115
+ isReadAllowedFn: isReadAllowed,
116
+ }).find(entry => entry.id === id);
117
+ if (!candidate || basename(candidate.realPath) !== 'CLAUDE.md') throw targetError(404, 'Target not found');
118
+ return { ...targetScope(realDirectory(dirname(candidate.realPath), 'Directory'), cwd), targetKind: 'claude-md' };
119
+ }
120
+ if (kind !== 'skill') throw targetError(400, 'Invalid target kind');
121
+ const source = parsedUrl.searchParams.get('source');
122
+ const targetPath = parsedUrl.searchParams.get('targetPath');
123
+ if (!['user', 'project', 'plugin'].includes(source)) throw targetError(400, 'Invalid skill source');
124
+ if (typeof targetPath !== 'string' || !targetPath || targetPath.length > 4096 || targetPath.includes('\0')) {
125
+ throw targetError(400, 'Invalid skill path');
126
+ }
127
+ const skill = listSkills({ projectDir: cwd })
128
+ .find(entry => entry.source === source && entry.path === targetPath);
129
+ if (!skill) throw targetError(404, 'Target not found');
130
+ const rootReal = realDirectory(skill.path, 'Directory');
131
+ const scope = targetScope(rootReal, cwd);
132
+ const anchor = source === 'project'
133
+ ? scope.rootReal
134
+ : dirname(dirname(skill.path));
135
+ const anchorReal = realDirectory(anchor, 'Skill root');
136
+ if (!insidePath(rootReal, anchorReal)) throw targetError(403, 'Target outside skill root');
137
+ // Plugin root must follow the configured Claude config dir, not the default
138
+ // homedir()/.claude — otherwise plugin skills break under CLAUDE_CONFIG_DIR.
139
+ if (source === 'plugin' && !insidePath(rootReal, realDirectory(join(getClaudeConfigDir(), 'plugins'), 'Plugin root'))) {
140
+ throw targetError(403, 'Target outside plugin root');
141
+ }
142
+ return { ...scope, targetKind: 'skill' };
143
+ }
144
+
145
+ // ===========================================================================
146
+ // Write-side scope resolution (used only by the /api/target-* write routes).
147
+ // ===========================================================================
148
+
149
+ // SECURITY / INTENTIONAL DESIGN (do NOT tighten in review without product sign-off):
150
+ // the writable area for a target browser is the WHOLE navigation root returned by
151
+ // targetNavigation (project / home / config / target), NOT just the target's own
152
+ // subtree. This is a deliberate product decision — the user navigates the browser
153
+ // up to ancestors (e.g. from a skill folder to ~ or ~/.claude) and expects the
154
+ // context menu to keep working there. The sensitive-file / sensitive-directory
155
+ // backstop for that wider surface is isWriteAllowed (lib/file-access-policy.js),
156
+ // which every mutation runs through; containment in the navigation root plus
157
+ // isWriteAllowed is the boundary, not the target subtree.
158
+ export function resolveTargetWriteScope(parsedUrl, cwd) {
159
+ // Re-derive the target identity server-side (never trust a client-sent root).
160
+ // The writable surface is the WHOLE navigation root (scope.rootReal) — the
161
+ // target's own directory is NOT separately resolved here because the product
162
+ // decision is to allow writes anywhere in the navigation root, with the
163
+ // sensitive-file backstop living in isWriteAllowed (see file-access-policy.js).
164
+ return resolveTargetRoot(parsedUrl, cwd);
165
+ }
166
+
167
+ // targetRelPath treats null as "caller omitted path" (the read route uses that
168
+ // to default to the target's own directory). The write routes always need an
169
+ // explicit path, so this is the required variant: omitted → 400.
170
+ export function requiredTargetRelPath(raw) {
171
+ const segs = targetRelPath(raw);
172
+ if (segs === null) throw targetError(400, 'Missing path');
173
+ return segs;
174
+ }
@@ -7,6 +7,8 @@ import { _projectName } from '../interceptor.js';
7
7
  import { readPrefsRaw } from '../lib/prefs-store.js';
8
8
  import { getCurrentProjectKey, hasFork, resolveScoped } from '../lib/project-prefs.js';
9
9
  import { reportSwallowed } from '../lib/error-report.js';
10
+ import { computeAutoApprove, createAutoApproveState } from '../lib/auto-approve.js';
11
+ import { isPublishCommand } from '../lib/approval-policy.js';
10
12
 
11
13
  function pendingAsks(req, res, parsedUrl, isLocal, deps) {
12
14
  try {
@@ -394,7 +396,7 @@ function permHook(req, res, parsedUrl, isLocal, deps) {
394
396
  return;
395
397
  }
396
398
  try {
397
- const { toolName, input } = JSON.parse(body);
399
+ const { toolName, input, cwd } = JSON.parse(body);
398
400
  if (!toolName) {
399
401
  res.writeHead(400, { 'Content-Type': 'application/json' });
400
402
  res.end(JSON.stringify({ error: 'Missing toolName' }));
@@ -407,6 +409,7 @@ function permHook(req, res, parsedUrl, isLocal, deps) {
407
409
  const oldest = deps.pendingPermHooks.get(oldestId);
408
410
  if (oldest) {
409
411
  clearTimeout(oldest.timer);
412
+ if (oldest.auto?.timer) clearTimeout(oldest.auto.timer);
410
413
  try { if (!oldest.res.headersSent) { oldest.res.writeHead(429, { 'Content-Type': 'application/json' }); oldest.res.end(JSON.stringify({ error: 'Too many concurrent requests' })); } } catch {}
411
414
  deps.pendingPermHooks.delete(oldestId);
412
415
  }
@@ -431,6 +434,7 @@ function permHook(req, res, parsedUrl, isLocal, deps) {
431
434
  const timer = setTimeout(() => {
432
435
  const entry = deps.pendingPermHooks.get(id);
433
436
  if (entry) {
437
+ if (entry.auto?.timer) clearTimeout(entry.auto.timer);
434
438
  deps.pendingPermHooks.delete(id);
435
439
  try {
436
440
  if (!entry.res.headersSent) {
@@ -447,11 +451,33 @@ function permHook(req, res, parsedUrl, isLocal, deps) {
447
451
  }
448
452
  }, PERM_HOOK_TIMEOUT);
449
453
 
450
- deps.pendingPermHooks.set(id, { toolName, input, res, timer, createdAt: Date.now() });
454
+ // Server-side auto-approve countdown (same semantics as the SDK path in
455
+ // sdk-manager.js): armed from request arrival; the server answers the hook
456
+ // itself at deadline + 1s buffer when no client did. autoCancelled is set by
457
+ // the perm-hook-auto-cancel WS handler (the approval then stays pending for
458
+ // a manual decision) and replayed to fresh clients.
459
+ // npm publish is a hard safety floor: always wait for a human decision, never
460
+ // the server-side auto-approve fallback (SDK path parity: sdk-manager.js).
461
+ const autoMeta = isPublishCommand(toolName, input)
462
+ ? { enabled: false }
463
+ : computeAutoApprove('perm', typeof cwd === 'string' && cwd ? cwd : undefined, { file: deps.getPrefsFile?.() });
464
+
465
+ const hookEntry = {
466
+ toolName, input, res, timer, createdAt: Date.now(),
467
+ autoCancelled: false,
468
+ auto: null,
469
+ };
470
+ deps.pendingPermHooks.set(id, hookEntry);
451
471
 
452
472
  // Broadcast to all terminal WS clients
453
473
  if (deps.terminalWss) {
454
- const pmsg = JSON.stringify({ type: 'perm-hook-pending', id, toolName, input });
474
+ const pmsg = JSON.stringify({
475
+ type: 'perm-hook-pending', id, toolName, input,
476
+ // Always stamp autoApproveSeconds (0 = server says OFF) so a client whose
477
+ // global pref disagrees with the fork-resolved value never re-arms locally.
478
+ autoApproveSeconds: autoMeta.enabled ? autoMeta.seconds : 0,
479
+ ...(autoMeta.enabled ? { autoApproveAt: autoMeta.autoApproveAt } : {}),
480
+ });
455
481
  deps.terminalWss.clients.forEach((client) => {
456
482
  if (client.readyState === 1) {
457
483
  try { client.send(pmsg); } catch {}
@@ -459,11 +485,36 @@ function permHook(req, res, parsedUrl, isLocal, deps) {
459
485
  });
460
486
  }
461
487
 
488
+ // Arm AFTER the entry is in the map AND the pending frame is out: instant (-1)
489
+ // fires synchronously and resolves through the entry, so clients observe the
490
+ // order pending → resolved (never a ghost panel for an already-answered hook).
491
+ if (autoMeta.enabled) {
492
+ hookEntry.auto = createAutoApproveState(autoMeta, () => {
493
+ const entry = deps.pendingPermHooks.get(id);
494
+ if (!entry || entry.autoCancelled) return;
495
+ clearTimeout(entry.timer);
496
+ deps.pendingPermHooks.delete(id);
497
+ try {
498
+ if (!entry.res.headersSent) {
499
+ entry.res.writeHead(200, { 'Content-Type': 'application/json' });
500
+ entry.res.end(JSON.stringify({ decision: 'allow' }));
501
+ }
502
+ } catch {}
503
+ if (deps.terminalWss) {
504
+ const rmsg = JSON.stringify({ type: 'perm-hook-resolved', id, reason: 'auto-approved' });
505
+ deps.terminalWss.clients.forEach((c) => {
506
+ if (c.readyState === 1) try { c.send(rmsg); } catch {}
507
+ });
508
+ }
509
+ });
510
+ }
511
+
462
512
  // Handle perm-bridge.js disconnection
463
513
  res.on('close', () => {
464
514
  const entry = deps.pendingPermHooks.get(id);
465
515
  if (entry) {
466
516
  clearTimeout(entry.timer);
517
+ if (entry.auto?.timer) clearTimeout(entry.auto.timer);
467
518
  deps.pendingPermHooks.delete(id);
468
519
  if (deps.terminalWss) {
469
520
  const tmsg = JSON.stringify({ type: 'perm-hook-timeout', id });
@@ -1,7 +1,7 @@
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';
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync, realpathSync, createReadStream, readdirSync } from 'node:fs';
3
3
  import { join, basename, dirname, resolve, sep } from 'node:path';
4
- import { isReadAllowed, reasonToStatus } from '../lib/file-access-policy.js';
4
+ import { isReadAllowed, isWriteAllowed, reasonToStatus } from '../lib/file-access-policy.js';
5
5
  import { ERROR_STATUS_MAP, isPathContained } from '../lib/file-api.js';
6
6
  import { viewRootOrReply, resolveViewRoot } from '../lib/view-root.js';
7
7
  import { findSdkSessionByInstance } from '../lib/sdk-session-index.js';
@@ -12,6 +12,12 @@ import { reportSwallowed } from '../lib/error-report.js';
12
12
  import { discoverClaudeMdCandidates, readCandidateById } from '../lib/claude-md-discovery.js';
13
13
  import { getClaudeConfigDir } from '../../findcc.js';
14
14
  import { _projectName } from '../interceptor.js';
15
+ import { isAdminReq } from '../lib/is-admin.js';
16
+ import { isSameOriginBrowserRequest } from '../lib/same-origin.js';
17
+ import {
18
+ targetError, errnoToStatus, realDirectory, checkedDirectory, insidePath,
19
+ targetRelPath, resolveTargetRoot,
20
+ } from '../lib/target-scope.js';
15
21
 
16
22
  // Multi-project file operations use the same viewed root for reads and writes.
17
23
  // Every file-browser mutation enforces realpath containment in that root;
@@ -537,7 +543,7 @@ function fileDownloadWithRoot(req, res, parsedUrl, reqPath, cwd) {
537
543
  }
538
544
  }
539
545
 
540
- function fileContentPost(req, res, parsedUrl) {
546
+ function fileContentPost(req, res, parsedUrl, isLocal) {
541
547
  // The shared read root keeps its raw cwd for projectMemory's store-key
542
548
  // encoding. Normalize only this mutation's containment comparisons.
543
549
  let writeRoot;
@@ -559,7 +565,36 @@ function fileContentPost(req, res, parsedUrl) {
559
565
  const { path: reqPath, content, editorSession } = JSON.parse(body);
560
566
  const cwd = _viewRootOrReply(req, res, parsedUrl);
561
567
  if (!cwd) return;
562
- try { writeRoot = realpathSync(cwd); } catch { writeRoot = cwd; }
568
+ // Target mode (the CLAUDE.md / skill browser modal) edits files that live in
569
+ // the target's NAVIGATION ROOT, which is frequently OUTSIDE the viewed project
570
+ // (~/.claude, $HOME). When the request carries a target identity
571
+ // (kind + id / source + targetPath, same as /api/target-files), resolve the
572
+ // write root to that navigation root instead of the project view root, so
573
+ // isInsideWriteRoot no longer 403s the file the user is explicitly editing.
574
+ // The target root is re-derived server-side (never trusted from the client),
575
+ // and the isWriteAllowed policy below still runs unchanged.
576
+ // SECURITY / INTENTIONAL DESIGN (do NOT tighten in review without product
577
+ // sign-off): in target mode the write policy is the WHOLE navigation root,
578
+ // and writes go through isWriteAllowed (NOT isReadAllowed) — so the LOG_DIR
579
+ // tree, ~/.claude credentials/settings, ~/.ssh, and the config-backup tree
580
+ // stay unwritable even though the wider surface is editable. This path is
581
+ // gated to admin + same-origin browser requests, the same bar as
582
+ // /api/target-files, because it can reach the user's home tree. A relative
583
+ // path in target mode is anchored at the navigation root, not the project.
584
+ const targetKind = parsedUrl.searchParams.get('kind');
585
+ let targetNavRoot = null;
586
+ if (targetKind) {
587
+ if (!isAdminReq(req, isLocal) || !isSameOriginBrowserRequest(req, parsedUrl)) {
588
+ res.writeHead(403, { 'Content-Type': 'application/json' });
589
+ res.end(JSON.stringify({ error: 'forbidden' }));
590
+ return;
591
+ }
592
+ const scope = resolveTargetRoot(parsedUrl, cwd);
593
+ writeRoot = scope.rootReal;
594
+ targetNavRoot = scope.rootReal;
595
+ } else {
596
+ try { writeRoot = realpathSync(cwd); } catch { writeRoot = cwd; }
597
+ }
563
598
  if (!reqPath) {
564
599
  res.writeHead(400, { 'Content-Type': 'application/json' });
565
600
  res.end(JSON.stringify({ error: 'Invalid path' }));
@@ -571,12 +606,18 @@ function fileContentPost(req, res, parsedUrl) {
571
606
  return;
572
607
  }
573
608
  const isAbs = /^([a-zA-Z]:[\\/]|[\\/])/.test(reqPath);
574
- const absPath = isAbs ? reqPath : resolve(cwd, reqPath);
609
+ // In target mode a relative path is anchored at the navigation root; in
610
+ // project mode at the project view root (unchanged legacy behavior).
611
+ const absPath = isAbs ? reqPath : resolve(targetNavRoot || cwd, reqPath);
575
612
  // 写路径同样走 policy(收敛 editorSession 后门):允许覆盖现有文件;
576
613
  // 新文件场景递归向上找最近存在的祖先目录,只要它在 allowlist 内即放行。
577
614
  // (旧实现只查 immediate parent,父目录也不存在时误拒嵌套新建。)
615
+ // Target mode uses the WRITE policy (isWriteAllowed) so the LOG_DIR tree,
616
+ // ~/.claude credentials and other sensitive targets stay unwritable even
617
+ // though the navigation root is editable; project mode keeps isReadAllowed.
618
+ const policyFn = targetNavRoot ? isWriteAllowed : isReadAllowed;
578
619
  let targetReal;
579
- const policy = isReadAllowed(absPath);
620
+ const policy = policyFn(absPath);
580
621
  if (policy.ok) {
581
622
  targetReal = policy.real;
582
623
  } else if (policy.reason === 'realpath-failed') {
@@ -609,6 +650,13 @@ function fileContentPost(req, res, parsedUrl) {
609
650
  }
610
651
  // Check containment before creating any missing parent directories.
611
652
  // Explicit $EDITOR sessions retain their allowlisted external paths.
653
+ // editorSession is client-claimed — require admin to honor it, otherwise
654
+ // any caller could use it as a write-root bypass (see /api/target-files).
655
+ if (editorSession && !isAdminReq(req, isLocal)) {
656
+ res.writeHead(403, { 'Content-Type': 'application/json' });
657
+ res.end(JSON.stringify({ error: 'Forbidden', reason: 'editor-session-requires-admin' }));
658
+ return;
659
+ }
612
660
  if (!editorSession && !isInsideWriteRoot(ancestorPolicy.real)) {
613
661
  res.writeHead(403, { 'Content-Type': 'application/json' });
614
662
  res.end(JSON.stringify({ error: 'Forbidden', reason: 'outside-view-root' }));
@@ -628,7 +676,21 @@ function fileContentPost(req, res, parsedUrl) {
628
676
  return;
629
677
  }
630
678
 
631
- if (!editorSession && existsSync(targetReal) && !isInsideWriteRoot(targetReal)) {
679
+ if (editorSession && !isAdminReq(req, isLocal)) {
680
+ res.writeHead(403, { 'Content-Type': 'application/json' });
681
+ res.end(JSON.stringify({ error: 'Forbidden', reason: 'editor-session-requires-admin' }));
682
+ return;
683
+ }
684
+ // Target mode: containment in the navigation root must hold whether the file
685
+ // exists OR is being created — otherwise a crafted relative path could write
686
+ // outside the navigation root (isWriteAllowed only bounds by the allowlist).
687
+ // Project mode keeps the legacy existing-file-only check.
688
+ if (!editorSession && targetNavRoot && !isInsideWriteRoot(targetReal)) {
689
+ res.writeHead(403, { 'Content-Type': 'application/json' });
690
+ res.end(JSON.stringify({ error: 'Forbidden', reason: 'outside-view-root' }));
691
+ return;
692
+ }
693
+ if (!editorSession && !targetNavRoot && existsSync(targetReal) && !isInsideWriteRoot(targetReal)) {
632
694
  res.writeHead(403, { 'Content-Type': 'application/json' });
633
695
  res.end(JSON.stringify({ error: 'Forbidden', reason: 'outside-view-root' }));
634
696
  return;
@@ -638,7 +700,9 @@ function fileContentPost(req, res, parsedUrl) {
638
700
  res.writeHead(200, { 'Content-Type': 'application/json' });
639
701
  res.end(JSON.stringify({ ok: true, size: stat.size }));
640
702
  } catch (err) {
641
- const status = ERROR_STATUS_MAP[err.code] || 500;
703
+ // targetError carries `.status` (400/403/404); fs errnos carry `.code`.
704
+ // Honor both so an invalid/stale target identity is not reported as 500.
705
+ const status = err.status || ERROR_STATUS_MAP[err.code] || 500;
642
706
  const message = status === 500 ? `Cannot save file: ${err.message}` : err.message;
643
707
  res.writeHead(status, { 'Content-Type': 'application/json' });
644
708
  res.end(JSON.stringify({ error: message }));
@@ -646,6 +710,107 @@ function fileContentPost(req, res, parsedUrl) {
646
710
  });
647
711
  }
648
712
 
713
+ function listTargetDirectory(rootReal, segments, deps, requireReadable = true) {
714
+ const dirReal = requireReadable
715
+ ? checkedDirectory(segments.length ? join(rootReal, ...segments) : rootReal, 'Directory')
716
+ : realDirectory(segments.length ? join(rootReal, ...segments) : rootReal, 'Directory');
717
+ if (!isPathContained(dirReal, rootReal)) throw targetError(403, 'Path outside target');
718
+ const ignored = deps?.IGNORED_PATTERNS || new Set();
719
+ const entries = [];
720
+ let dirents;
721
+ try {
722
+ dirents = readdirSync(dirReal, { withFileTypes: true });
723
+ } catch (err) {
724
+ // EACCES → 403 (permission), ENOENT/ENOTDIR → 404 (race with rename/delete),
725
+ // anything else falls back to 404 so we don't leak internal state via 500.
726
+ throw targetError(errnoToStatus(err), 'Directory not readable');
727
+ }
728
+ // Hard cap on returned entries: this is a synchronous loop with realpath +
729
+ // stat + isReadAllowed per entry, and the endpoint fans out via `ancestors`,
730
+ // so an unbounded listing of a huge dir (e.g. ~/.claude/projects/<hash>/)
731
+ // would block the event loop and return a giant JSON payload. 5000 is well
732
+ // past any realistic skill/CLAUDE.md folder.
733
+ const MAX_ENTRIES = 5000;
734
+ for (const entry of dirents) {
735
+ if (entries.length >= MAX_ENTRIES) break;
736
+ if (ignored.has(entry.name)) continue;
737
+ const fullPath = join(dirReal, entry.name);
738
+ let real;
739
+ try { real = realpathSync(fullPath); } catch (err) { reportSwallowed('targetFiles.entry.realpath', err); continue; }
740
+ if (!insidePath(real, rootReal)) continue;
741
+ const policy = isReadAllowed(real);
742
+ if (!policy.ok) continue;
743
+ let st;
744
+ try { st = statSync(policy.real); } catch (err) { reportSwallowed('targetFiles.entry.stat', err); continue; }
745
+ if (!st.isDirectory() && !st.isFile()) continue;
746
+ entries.push({ name: entry.name, type: st.isDirectory() ? 'directory' : 'file', absPath: policy.real });
747
+ }
748
+ entries.sort((a, b) => a.type !== b.type
749
+ ? (a.type === 'directory' ? -1 : 1)
750
+ : a.name.localeCompare(b.name));
751
+ return entries;
752
+ }
753
+
754
+ function targetFiles(req, res, parsedUrl, isLocal, deps) {
755
+ try {
756
+ // /api/target-files exposes directory listings under the user's home and
757
+ // ~/.claude (which contains transcripts, prompt history, env snapshots).
758
+ // Combined with the global `Access-Control-Allow-Origin: *` and loopback's
759
+ // no-auth path, a bare GET would let any web page enumerate the user's
760
+ // ~/.claude tree. Require admin AND a same-origin browser request.
761
+ if (!isAdminReq(req, isLocal) || !isSameOriginBrowserRequest(req, parsedUrl)) {
762
+ res.writeHead(403, { 'Content-Type': 'application/json' });
763
+ res.end(JSON.stringify({ error: 'forbidden' }));
764
+ return;
765
+ }
766
+ const cwd = _viewRootOrReply(req, res, parsedUrl);
767
+ if (!cwd) return;
768
+ const scope = resolveTargetRoot(parsedUrl, cwd);
769
+ const rawPath = parsedUrl.searchParams.get('path');
770
+ const requestedSegments = targetRelPath(rawPath);
771
+ const segments = requestedSegments === null ? scope.targetPath.split('/').filter(Boolean) : requestedSegments;
772
+ const entries = listTargetDirectory(scope.rootReal, segments, deps, segments.length > 0 || scope.rootKind !== 'home');
773
+ const rootEntries = requestedSegments === null && segments.length > 0
774
+ ? listTargetDirectory(scope.rootReal, [], deps, scope.rootKind === 'project')
775
+ : entries;
776
+ const targetEntries = requestedSegments === null
777
+ ? entries.filter(entry => insidePath(entry.absPath, join(scope.rootReal, ...segments)))
778
+ : entries;
779
+ const targetSegments = scope.targetPath ? scope.targetPath.split('/') : [];
780
+ const ancestors = [];
781
+ // Ancestors are the chain from the navigation root down to the directory
782
+ // being listed. When the caller passed no `path` that's the chain leading
783
+ // to the target itself; when they passed `path`, the chain is the requested
784
+ // segments. We deliberately DO NOT use path.relative(targetPath, rawPath)
785
+ // here — that helper happily produces "../.." for sibling branches, which
786
+ // would pollute the cache with pseudo-paths and re-list directories the
787
+ // user never navigated into.
788
+ const chainSegments = requestedSegments === null ? targetSegments : segments;
789
+ // Request-scoped memo: targetFiles lists the same directory multiple times
790
+ // (entries + ancestors chain often share prefixes), so we cache by dir key
791
+ // to avoid duplicate readdirSync storms on large directories.
792
+ const listMemo = new Map();
793
+ const listOnce = (segs, requireReadable) => {
794
+ const key = segs.join('/') + (requireReadable ? '|r' : '|n');
795
+ if (!listMemo.has(key)) listMemo.set(key, listTargetDirectory(scope.rootReal, segs, deps, requireReadable));
796
+ return listMemo.get(key);
797
+ };
798
+ const ancestorCount = Math.max(0, chainSegments.length - (requestedSegments === null ? 0 : 1));
799
+ for (let i = 0; i <= ancestorCount; i++) {
800
+ const path = chainSegments.slice(0, i).join('/');
801
+ ancestors.push({ path, entries: listOnce(path ? path.split('/') : [], false) });
802
+ }
803
+ const { rootReal, ...response } = scope;
804
+ res.writeHead(200, { 'Content-Type': 'application/json' });
805
+ res.end(JSON.stringify({ ...response, targetPath: segments.join('/'), entries: rootEntries, targetEntries, ancestors }));
806
+ } catch (err) {
807
+ const status = err.status || ERROR_STATUS_MAP[err.code] || 500;
808
+ if (status === 500) console.error('[api/target-files]', err);
809
+ res.writeHead(status, { 'Content-Type': 'application/json' });
810
+ res.end(JSON.stringify({ error: status === 500 ? 'Internal error' : err.message }));
811
+ }
812
+ }
813
+
649
814
  export const filesContentRoutes = [
650
815
  { method: 'GET', match: 'exact', path: '/api/plan-file', handler: planFile },
651
816
  { method: 'GET', match: 'exact', path: '/api/file-content', handler: fileContentGet },
@@ -654,4 +819,5 @@ export const filesContentRoutes = [
654
819
  { predicate: (url, method) => (url === '/api/file-raw' || url.startsWith('/api/file-raw/')) && (method === 'GET' || method === 'HEAD'), handler: fileRaw },
655
820
  { predicate: (url, method) => url === '/api/download-file' && (method === 'GET' || method === 'HEAD'), handler: fileDownload },
656
821
  { method: 'POST', match: 'exact', path: '/api/file-content', handler: fileContentPost },
822
+ { method: 'GET', match: 'exact', path: '/api/target-files', handler: targetFiles },
657
823
  ];
@@ -5,11 +5,12 @@ import {
5
5
  } from 'node:fs';
6
6
  import { join, dirname, basename, resolve, sep } from 'node:path';
7
7
  import { homedir, tmpdir } from 'node:os';
8
- import { execFile, spawn } from 'node:child_process';
8
+ import { execFile } from 'node:child_process';
9
9
  import { bumpWorkspacesVersion, isReadAllowed } from '../lib/file-access-policy.js';
10
10
  import { isPathContained, validateImportDir, renameSyncWithRetry } from '../lib/file-api.js';
11
11
  import { reportSwallowed } from '../lib/error-report.js';
12
12
  import { viewRootOrReply } from '../lib/view-root.js';
13
+ import { revealInOs, openTerminalAt } from '../lib/os-open.js';
13
14
  import { loadWorkspaces } from '../workspace-registry.js';
14
15
  import { listLivePtys } from '../pty-manager.js';
15
16
  import { PROFILE_PATH, _projectName, _logDir } from '../interceptor.js';
@@ -645,17 +646,12 @@ function revealFile(req, res, parsedUrl, isLocal, deps) {
645
646
  return;
646
647
  }
647
648
  const plat = process.platform;
648
- if (plat === 'darwin') {
649
- execFile('open', ['-R', fullPath], () => {});
650
- } else if (plat === 'win32') {
651
- // explorer /select 的规范形式是 /select,"<path>"(仅路径部分加引号、整体一个 arg)。
652
- // 必须 windowsVerbatimArguments 透传:否则 Node 对含空格 arg 整体加引号生成
653
- // explorer.exe "/select,C:\My Proj\x.txt",explorer 解析不了 → 功能失效。
654
- // Windows 文件名不允许 ",且不经 cmd.exe,无元字符注入面。
655
- const child = spawn('explorer.exe', [`/select,"${fullPath}"`], { windowsVerbatimArguments: true, windowsHide: true });
656
- child.on('error', () => {}); // 防 async ENOENT 变 uncaughtException 砸进程
649
+ if (plat === 'darwin' || plat === 'win32') {
650
+ revealInOs(fullPath);
657
651
  } else {
658
- execFile('xdg-open', [dirname(fullPath)], () => {});
652
+ // Linux: a directory reveals itself; a file reveals its parent (xdg-open on
653
+ // a file would launch the default app). Shared helper, see lib/os-open.js.
654
+ revealInOs(fullPath, { isDir: statSync(fullPath).isDirectory() });
659
655
  }
660
656
  res.writeHead(200, { 'Content-Type': 'application/json' });
661
657
  res.end(JSON.stringify({ ok: true, fullPath }));
@@ -919,49 +915,7 @@ function openTerminal(req, res, parsedUrl, isLocal, deps) {
919
915
  res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
920
916
  return;
921
917
  }
922
- const plat = process.platform;
923
- if (plat === 'darwin') {
924
- spawn('open', ['-a', 'Terminal', fullDir], { stdio: 'ignore', detached: true }).unref();
925
- } else if (plat === 'win32') {
926
- spawn('cmd.exe', ['/c', 'start', 'cmd.exe'], { cwd: fullDir, stdio: 'ignore', detached: true, windowsHide: true }).unref();
927
- } else {
928
- // Linux: try common terminal emulators
929
- const terminals = ['gnome-terminal', 'konsole', 'xfce4-terminal', 'xterm'];
930
- // spawn reports a missing executable asynchronously, outside try/catch.
931
- // Probe each candidate with piped stdio so the next one can be attempted
932
- // after ENOENT; detach only after a successful spawn.
933
- const tryTerminal = (index) => {
934
- if (index >= terminals.length) {
935
- const child = spawn('xdg-open', [fullDir], { stdio: 'ignore', detached: true });
936
- child.on('error', (err) => { reportSwallowed('filesFs.openTerminal', err, { terminal: 'xdg-open' }); });
937
- child.unref();
938
- return;
939
- }
940
- const term = terminals[index];
941
- let child;
942
- try {
943
- if (term === 'gnome-terminal') {
944
- child = spawn(term, ['--working-directory=' + fullDir], { stdio: 'pipe' });
945
- } else if (term === 'konsole') {
946
- child = spawn(term, ['--workdir', fullDir], { stdio: 'pipe' });
947
- } else {
948
- child = spawn(term, [], { cwd: fullDir, stdio: 'pipe' });
949
- }
950
- } catch {
951
- tryTerminal(index + 1);
952
- return;
953
- }
954
- child.once('error', (err) => {
955
- reportSwallowed('filesFs.openTerminal', err, { terminal: term });
956
- tryTerminal(index + 1);
957
- });
958
- child.once('spawn', () => {
959
- for (const stream of [child.stdin, child.stdout, child.stderr]) stream?.destroy();
960
- child.unref();
961
- });
962
- };
963
- tryTerminal(0);
964
- }
918
+ openTerminalAt(fullDir);
965
919
  res.writeHead(200, { 'Content-Type': 'application/json' });
966
920
  res.end(JSON.stringify({ ok: true }));
967
921
  } catch (err) {