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/assets/{App-CYdOioAz.js → App-BlNbziAQ.js} +2 -2
- package/dist/assets/{MdxEditorPanel-CuCAe1OG.js → MdxEditorPanel-C9cr-WBH.js} +1 -1
- package/dist/assets/Mobile-CDQPYYQo.js +1 -0
- package/dist/assets/{ProxyStatsModal-Dhj4EVnh.js → ProxyStatsModal-BEqrTR-F.js} +1 -1
- package/dist/assets/index-B4J-uwYG.js +2 -0
- package/dist/assets/seqResourceLoaders-DaZ8fZXu.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/lib/file-access-policy.js +108 -3
- package/server/lib/os-open.js +66 -0
- package/server/lib/target-scope.js +174 -0
- package/server/routes/files-content.js +57 -133
- package/server/routes/files-fs.js +8 -54
- package/server/routes/target-files-fs.js +332 -0
- package/server/server.js +8 -4
- package/dist/assets/Mobile-CfNYOgRa.js +0 -1
- package/dist/assets/index-sgW8MlD3.js +0 -2
- package/dist/assets/seqResourceLoaders-Cj0pdz-l.js +0 -2
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-
|
|
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.
|
|
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
|
-
|
|
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,
|
|
4
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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')
|