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.
- package/cli.js +1 -0
- package/concepts/ar/UltraPlan.md +9 -9
- package/concepts/da/UltraPlan.md +9 -9
- package/concepts/de/UltraPlan.md +9 -9
- package/concepts/en/UltraPlan.md +9 -9
- package/concepts/es/UltraPlan.md +9 -9
- package/concepts/fr/UltraPlan.md +9 -9
- package/concepts/it/UltraPlan.md +9 -9
- package/concepts/ja/UltraPlan.md +9 -9
- package/concepts/ko/UltraPlan.md +9 -9
- package/concepts/no/UltraPlan.md +9 -9
- package/concepts/pl/UltraPlan.md +9 -9
- package/concepts/pt-BR/UltraPlan.md +9 -9
- package/concepts/ru/UltraPlan.md +9 -9
- package/concepts/th/UltraPlan.md +9 -9
- package/concepts/tr/UltraPlan.md +9 -9
- package/concepts/uk/UltraPlan.md +9 -9
- package/concepts/zh/UltraPlan.md +9 -9
- package/concepts/zh-TW/UltraPlan.md +9 -9
- package/dist/assets/App-BlNbziAQ.js +2 -0
- package/dist/assets/App-BvVdj099.css +1 -0
- package/dist/assets/{MdxEditorPanel-DRLFmdU5.js → MdxEditorPanel-C9cr-WBH.js} +1 -1
- package/dist/assets/Mobile-CDQPYYQo.js +1 -0
- package/dist/assets/{ProxyStatsModal-Ba1E1lu4.js → ProxyStatsModal-BEqrTR-F.js} +1 -1
- package/dist/assets/index-B4J-uwYG.js +2 -0
- package/dist/assets/seqResourceLoaders-B-5xGs9q.css +41 -0
- package/dist/assets/seqResourceLoaders-DaZ8fZXu.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/lib/ask/perm-bridge.js +4 -1
- package/server/lib/auto-approve.js +160 -0
- package/server/lib/file-access-policy.js +132 -5
- package/server/lib/os-open.js +66 -0
- package/server/lib/sdk-manager.js +96 -9
- package/server/lib/target-scope.js +174 -0
- package/server/routes/ask-perm.js +54 -3
- package/server/routes/files-content.js +174 -8
- package/server/routes/files-fs.js +8 -54
- package/server/routes/skills.js +19 -8
- package/server/routes/target-files-fs.js +332 -0
- package/server/server.js +89 -14
- package/server/system-prompt-templates/presets/kimi-k3.md +1 -0
- package/ultraAgents/code-expert.json +1 -1
- package/dist/assets/App-BCDnm2Lh.js +0 -2
- package/dist/assets/App-CCnPkf2_.css +0 -1
- package/dist/assets/Mobile-C8VJqBwa.js +0 -1
- package/dist/assets/index-Sp3JYYKo.js +0 -2
- package/dist/assets/seqResourceLoaders-BUfjTaCV.css +0 -41
- 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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|