cc-viewer 1.9.16 → 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.
package/dist/index.html CHANGED
@@ -21,7 +21,7 @@
21
21
  // 整体显示大小已弃用 CSS zoom:Electron 改用 webFrame.setZoomFactor(首屏抢占见
22
22
  // electron/tab-content-preload.js),纯浏览器交由用户用浏览器自带快捷键缩放,故此处不再设 zoom。
23
23
  </script>
24
- <script type="module" crossorigin src="./assets/index-sgW8MlD3.js"></script>
24
+ <script type="module" crossorigin src="./assets/index-B4J-uwYG.js"></script>
25
25
  <link rel="modulepreload" crossorigin href="./assets/vendor-antd-I6I2F7Rv.js">
26
26
  <link rel="modulepreload" crossorigin href="./assets/vendor-codemirror-Zkm6UHQg.js">
27
27
  <link rel="modulepreload" crossorigin href="./assets/vendor-mdxeditor-CC09MDyZ.js">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.9.16",
3
+ "version": "1.9.17",
4
4
  "description": "Claude Code logging, visualization, and management toolkit — launch a web viewer alongside Claude Code with full request/response tracing, proxy, and mobile support",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
@@ -10,8 +10,8 @@
10
10
  *
11
11
  * 不替代写路径的业务校验(见 server/lib/file-api.js writeFileContent),但同样会被 endpoint 用作首道关卡。
12
12
  */
13
- import { realpathSync } from 'node:fs';
14
- import { resolve, basename, sep, join } from 'node:path';
13
+ import { realpathSync, lstatSync } from 'node:fs';
14
+ import { resolve, basename, dirname, sep, join } from 'node:path';
15
15
  import { homedir, platform, tmpdir } from 'node:os';
16
16
  import { getClaudeConfigDir, onLogDirChange, LOG_DIR } from '../../findcc.js';
17
17
  import { loadWorkspaces } from '../workspace-registry.js';
@@ -122,7 +122,14 @@ function _logDirSensitiveFiles() {
122
122
  files.add(norm(join(LOG_DIR, name)));
123
123
  files.add(norm(join(rootReal, name)));
124
124
  }
125
- _logDirSensitiveCache = { root: norm(withSep(LOG_DIR)), rootReal: norm(withSep(rootReal)), files };
125
+ // `root`/`rootReal` keep a trailing separator for startsWith() prefix matching;
126
+ // `rootBare`/`rootRealBare` (no trailing sep) let isWriteAllowed also deny the
127
+ // LOG_DIR directory ITSELF (delete/rename), which the prefix match would miss.
128
+ _logDirSensitiveCache = {
129
+ root: norm(withSep(LOG_DIR)), rootReal: norm(withSep(rootReal)),
130
+ rootBare: norm(LOG_DIR), rootRealBare: norm(rootReal),
131
+ files,
132
+ };
126
133
  return _logDirSensitiveCache;
127
134
  }
128
135
  onLogDirChange(() => { _logDirSensitiveCache = null; });
@@ -215,6 +222,16 @@ export function isReadAllowed(absPath) {
215
222
  let real;
216
223
  try { real = realpathSync(absPath); } catch { return { ok: false, reason: 'realpath-failed' }; }
217
224
 
225
+ return evaluateResolvedPath(real);
226
+ }
227
+
228
+ // Shared verdict for a realpath-resolved path — the allowlist + sensitive-file
229
+ // + sensitive-prefix checks that isReadAllowed (existing files) and
230
+ // isWriteAllowed (existing or to-be-created files) both apply. Keeping this the
231
+ // single implementation prevents read/write policy drift: a path the user can
232
+ // read but must not overwrite (e.g. ~/.claude/settings.json) is denied here for
233
+ // both directions.
234
+ function evaluateResolvedPath(real) {
218
235
  // 1) Allowlist 命中
219
236
  const roots = getAllowedRoots();
220
237
  let hitRoot = null;
@@ -310,6 +327,94 @@ export function isReadAllowed(absPath) {
310
327
  return { ok: true, real };
311
328
  }
312
329
 
330
+ /**
331
+ * Write-access verdict for the file browser's target mode (the CLAUDE.md / skill
332
+ * folder modal, where the user may browse up to an ancestor of the target). The
333
+ * target file itself may not exist yet (create-file / create-dir / rename
334
+ * destination), so unlike isReadAllowed we cannot realpath it directly. Instead:
335
+ * the parent directory must exist and pass the read policy, and the candidate
336
+ * path (parent's real path + basename) must pass the SAME shared verdict — so a
337
+ * location the user can read but must not overwrite (settings.json, credentials,
338
+ * master.key, backups) is rejected identically in both directions.
339
+ *
340
+ * SECURITY / INTENTIONAL DESIGN (do NOT weaken or remove in review): the write
341
+ * surface intentionally spans the whole navigation root (see
342
+ * lib/target-scope.js resolveTargetWriteScope). This function is the backstop
343
+ * that keeps secrets unwritable inside that wider surface.
344
+ *
345
+ * Two write-only denials layered on top of the read policy:
346
+ * - The ENTIRE LOG_DIR tree (~/.claude/cc-viewer/**): the read policy only
347
+ * blocks specific data files in it, but a write/delete could rmdir the whole
348
+ * dir (wiping the preferences + scheduled-task store), so writes deny the
349
+ * whole subtree.
350
+ * - The config-backup root is already denied for reads (2b) and stays denied.
351
+ */
352
+ export function isWriteAllowed(absPath) {
353
+ if (typeof absPath !== 'string' || !absPath) return { ok: false, reason: 'invalid' };
354
+ if (absPath.indexOf('\x00') !== -1) return { ok: false, reason: 'null-byte' };
355
+
356
+ // Existing file: realpath it and run the shared verdict directly.
357
+ // New file: realpath the nearest existing parent, then re-evaluate the
358
+ // candidate (parentReal + basename) so symlink/denylist checks apply to the
359
+ // resolved location, not the spelled path.
360
+ let real;
361
+ let existed = true;
362
+ try { real = realpathSync(absPath); } catch { existed = false; }
363
+ if (!existed) {
364
+ // Dangling-symlink guard: realpath failed but the leaf may still be a symlink
365
+ // whose target is missing/outside the allowlist. writeFileSync would FOLLOW it
366
+ // and create/overwrite the link's target outside the navigation root — a
367
+ // containment bypass. lstat does not follow the link, so an isSymbolicLink()
368
+ // hit here refuses the write. Do NOT remove (TOCTOU-class escape).
369
+ try { if (lstatSync(absPath).isSymbolicLink()) return { ok: false, reason: 'symlink' }; } catch { /* truly absent leaf */ }
370
+ let cursor = resolve(absPath, '..');
371
+ const descent = [basename(absPath)];
372
+ let parentReal = null;
373
+ for (let depth = 0; depth < 32; depth++) {
374
+ try { parentReal = realpathSync(cursor); break; } catch {
375
+ const parent = resolve(cursor, '..');
376
+ if (parent === cursor) break;
377
+ descent.unshift(basename(cursor));
378
+ cursor = parent;
379
+ }
380
+ }
381
+ if (parentReal === null) return { ok: false, reason: 'realpath-failed' };
382
+ // Every ancestor on the way down must be inside the allowlist / not denied;
383
+ // evaluate the nearest existing ancestor, then rebuild the candidate.
384
+ const ancestorVerdict = evaluateResolvedPath(parentReal);
385
+ if (!ancestorVerdict.ok) return ancestorVerdict;
386
+ real = join(parentReal, ...descent);
387
+ }
388
+
389
+ const verdict = evaluateResolvedPath(real);
390
+ if (!verdict.ok) return verdict;
391
+
392
+ // Write-only: deny the whole LOG_DIR subtree (read policy only blocks named
393
+ // data files in it). Matches both the raw and realpath'd spellings, both the
394
+ // prefix (children) AND the directory itself (bare), so a delete/rename cannot
395
+ // rmdir the whole task/preferences store.
396
+ const sens = _logDirSensitiveFiles();
397
+ const realNorm = norm(real);
398
+ if (realNorm.startsWith(sens.root) || realNorm.startsWith(sens.rootReal)
399
+ || realNorm === sens.rootBare || realNorm === sens.rootRealBare) {
400
+ return { ok: false, reason: 'sensitive-ccv-data' };
401
+ }
402
+
403
+ // Write-only: deny a sensitive-PREFIX directory itself (~/.ssh, ~/.aws, …), not
404
+ // just its contents. The read policy only matches children (startsWith withSep),
405
+ // so renaming the directory to a benign name would expose the whole prefix's
406
+ // contents. Renaming/deleting the dir root is refused here. (Only meaningful
407
+ // outside the project root, matching the prefix rule's own scope.)
408
+ const projectRoot = getProjectRoot();
409
+ if (!isInsideRoot(real, projectRoot)) {
410
+ for (const prefix of SENSITIVE_PATH_PREFIXES) {
411
+ if (realNorm === norm(prefix)) return { ok: false, reason: 'sensitive-prefix' };
412
+ }
413
+ }
414
+
415
+ return { ok: true, real };
416
+ }
417
+
313
418
  /**
314
419
  * 给 endpoint 用的 reason → HTTP status code 映射。
315
420
  * - 路径不存在/realpath 失败 → 404(更友好,不暴露"路径合法但不存在")
@@ -0,0 +1,66 @@
1
+ // Shared OS-integration helpers (open in file manager / terminal) used by both
2
+ // the project-scoped routes (files-fs.js) and the target-mode write routes
3
+ // (target-files-fs.js). Centralizing the platform branches keeps the two call
4
+ // surfaces from drifting apart (a Windows quoting or Linux terminal-fallback fix
5
+ // must apply to both). Pure argv spawning — no shell — so no metachar injection.
6
+ //
7
+ // L1 (lib): must NOT import from routes/ (verify:boundaries).
8
+ import { execFile, spawn } from 'node:child_process';
9
+ import { dirname } from 'node:path';
10
+ import { reportSwallowed } from './error-report.js';
11
+
12
+ // Reveal a file (select it) or open a directory in the OS file manager.
13
+ // - macOS: `open -R <path>` selects files AND opens directories in place.
14
+ // - Windows: `explorer /select,"<path>"` (verbatim args; a dir path opens it).
15
+ // - Linux: no select concept — open the path itself when it is a directory,
16
+ // otherwise its parent. (xdg-open on a FILE would launch the default app, so
17
+ // callers pass `isDir` to pick the directory to show.)
18
+ export function revealInOs(fullPath, { isDir = false } = {}) {
19
+ const plat = process.platform;
20
+ if (plat === 'darwin') {
21
+ execFile('open', ['-R', fullPath], () => {});
22
+ } else if (plat === 'win32') {
23
+ // windowsVerbatimArguments: Node would otherwise re-quote the spaced path into
24
+ // a form explorer cannot parse. No cmd.exe is involved, so no metachar risk;
25
+ // Windows filenames cannot contain `"`.
26
+ const child = spawn('explorer.exe', [`/select,"${fullPath}"`], { windowsVerbatimArguments: true, windowsHide: true });
27
+ child.on('error', (err) => { reportSwallowed('osOpen.reveal', err); });
28
+ } else {
29
+ execFile('xdg-open', [isDir ? fullPath : dirname(fullPath)], () => {});
30
+ }
31
+ }
32
+
33
+ // Open an OS terminal with cwd = dir. Linux probes common emulators with piped
34
+ // stdio so the next candidate is tried after ENOENT, falling back to xdg-open.
35
+ export function openTerminalAt(dir) {
36
+ const plat = process.platform;
37
+ if (plat === 'darwin') {
38
+ const child = spawn('open', ['-a', 'Terminal', dir], { stdio: 'ignore', detached: true });
39
+ child.on('error', (err) => { reportSwallowed('osOpen.terminal', err, { terminal: 'Terminal.app' }); });
40
+ child.unref();
41
+ } else if (plat === 'win32') {
42
+ const child = spawn('cmd.exe', ['/c', 'start', 'cmd.exe'], { cwd: dir, stdio: 'ignore', detached: true, windowsHide: true });
43
+ child.on('error', (err) => { reportSwallowed('osOpen.terminal', err, { terminal: 'cmd.exe' }); });
44
+ child.unref();
45
+ } else {
46
+ const terminals = ['gnome-terminal', 'konsole', 'xfce4-terminal', 'xterm'];
47
+ const tryTerminal = (index) => {
48
+ if (index >= terminals.length) {
49
+ const child = spawn('xdg-open', [dir], { stdio: 'ignore', detached: true });
50
+ child.on('error', (err) => { reportSwallowed('osOpen.terminal', err, { terminal: 'xdg-open' }); });
51
+ child.unref();
52
+ return;
53
+ }
54
+ const term = terminals[index];
55
+ let child;
56
+ try {
57
+ if (term === 'gnome-terminal') child = spawn(term, ['--working-directory=' + dir], { stdio: 'pipe' });
58
+ else if (term === 'konsole') child = spawn(term, ['--workdir', dir], { stdio: 'pipe' });
59
+ else child = spawn(term, [], { cwd: dir, stdio: 'pipe' });
60
+ } catch { tryTerminal(index + 1); return; }
61
+ child.once('error', (err) => { reportSwallowed('osOpen.terminal', err, { terminal: term }); tryTerminal(index + 1); });
62
+ child.once('spawn', () => { for (const s of [child.stdin, child.stdout, child.stderr]) s?.destroy(); child.unref(); });
63
+ };
64
+ tryTerminal(0);
65
+ }
66
+ }
@@ -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
+ }
@@ -1,10 +1,8 @@
1
1
  // Read-oriented file-access routes (moved verbatim from server.js handleRequest).
2
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';
5
- import { isReadAllowed, reasonToStatus } from '../lib/file-access-policy.js';
3
+ import { join, basename, dirname, resolve, sep } from 'node:path';
4
+ import { isReadAllowed, isWriteAllowed, reasonToStatus } from '../lib/file-access-policy.js';
6
5
  import { ERROR_STATUS_MAP, isPathContained } from '../lib/file-api.js';
7
- import { listSkills } from '../lib/skills-api.js';
8
6
  import { viewRootOrReply, resolveViewRoot } from '../lib/view-root.js';
9
7
  import { findSdkSessionByInstance } from '../lib/sdk-session-index.js';
10
8
  import { projectKeyForCwd } from '../lib/system-prompt-snapshots.js';
@@ -16,6 +14,10 @@ import { getClaudeConfigDir } from '../../findcc.js';
16
14
  import { _projectName } from '../interceptor.js';
17
15
  import { isAdminReq } from '../lib/is-admin.js';
18
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';
19
21
 
20
22
  // Multi-project file operations use the same viewed root for reads and writes.
21
23
  // Every file-browser mutation enforces realpath containment in that root;
@@ -563,7 +565,36 @@ function fileContentPost(req, res, parsedUrl, isLocal) {
563
565
  const { path: reqPath, content, editorSession } = JSON.parse(body);
564
566
  const cwd = _viewRootOrReply(req, res, parsedUrl);
565
567
  if (!cwd) return;
566
- 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
+ }
567
598
  if (!reqPath) {
568
599
  res.writeHead(400, { 'Content-Type': 'application/json' });
569
600
  res.end(JSON.stringify({ error: 'Invalid path' }));
@@ -575,12 +606,18 @@ function fileContentPost(req, res, parsedUrl, isLocal) {
575
606
  return;
576
607
  }
577
608
  const isAbs = /^([a-zA-Z]:[\\/]|[\\/])/.test(reqPath);
578
- 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);
579
612
  // 写路径同样走 policy(收敛 editorSession 后门):允许覆盖现有文件;
580
613
  // 新文件场景递归向上找最近存在的祖先目录,只要它在 allowlist 内即放行。
581
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;
582
619
  let targetReal;
583
- const policy = isReadAllowed(absPath);
620
+ const policy = policyFn(absPath);
584
621
  if (policy.ok) {
585
622
  targetReal = policy.real;
586
623
  } else if (policy.reason === 'realpath-failed') {
@@ -644,7 +681,16 @@ function fileContentPost(req, res, parsedUrl, isLocal) {
644
681
  res.end(JSON.stringify({ error: 'Forbidden', reason: 'editor-session-requires-admin' }));
645
682
  return;
646
683
  }
647
- if (!editorSession && existsSync(targetReal) && !isInsideWriteRoot(targetReal)) {
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)) {
648
694
  res.writeHead(403, { 'Content-Type': 'application/json' });
649
695
  res.end(JSON.stringify({ error: 'Forbidden', reason: 'outside-view-root' }));
650
696
  return;
@@ -654,7 +700,9 @@ function fileContentPost(req, res, parsedUrl, isLocal) {
654
700
  res.writeHead(200, { 'Content-Type': 'application/json' });
655
701
  res.end(JSON.stringify({ ok: true, size: stat.size }));
656
702
  } catch (err) {
657
- 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;
658
706
  const message = status === 500 ? `Cannot save file: ${err.message}` : err.message;
659
707
  res.writeHead(status, { 'Content-Type': 'application/json' });
660
708
  res.end(JSON.stringify({ error: message }));
@@ -662,130 +710,6 @@ function fileContentPost(req, res, parsedUrl, isLocal) {
662
710
  });
663
711
  }
664
712
 
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
713
  function listTargetDirectory(rootReal, segments, deps, requireReadable = true) {
790
714
  const dirReal = requireReadable
791
715
  ? checkedDirectory(segments.length ? join(rootReal, ...segments) : rootReal, 'Directory')