Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion dist/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,30 @@ import { startWatcher } from './scanner/watcher.js';
import { invalidateCache } from './routes/skills.js';
import { fullScan } from './scanner/discovery.js';
import { purgeExpired as purgeExpiredTrash } from './trash/store.js';
import { registerOriginGuard } from './security.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = Fastify({ logger: false });
await app.register(cors, { origin: true });
// Track the actually-bound origin so CORS + CSRF guards know what to accept.
// The server may fall through to a higher port on EADDRINUSE, so both lists
// are mutable and populated after `listen()` resolves.
const allowedOrigins = [];
function allowedOriginsGetter() {
return allowedOrigins;
}
await app.register(cors, {
origin: (origin, cb) => {
// Same-origin requests from our own UI come through without an Origin
// header — allow those. Everything else must exactly match an origin we
// bound to.
if (!origin)
return cb(null, true);
const ok = allowedOrigins.some((o) => o.toLowerCase() === origin.toLowerCase());
cb(null, ok);
},
credentials: false,
});
registerOriginGuard(app, allowedOriginsGetter);
await app.register(websocket);
await app.register(skillRoutes);
await app.register(manageRoutes);
Expand Down Expand Up @@ -117,6 +137,11 @@ const basePort = parseInt(process.env.PORT || '3456');
try {
const actualPort = await listenWithRetry(basePort);
const url = `http://localhost:${actualPort}`;
// Register the origins we accept for CORS + mutating requests. Both the
// loopback IP and the `localhost` hostname are legitimate: browsers open
// the auto-launched URL, `fetch()` from the UI, and user-pasted links all
// route to the same server but send different Origin headers.
allowedOrigins.push(`http://localhost:${actualPort}`, `http://127.0.0.1:${actualPort}`);
// Purge expired trash entries on startup (best-effort, non-blocking failures)
try {
const removed = await purgeExpiredTrash();
Expand Down
158 changes: 113 additions & 45 deletions dist/server/routes/manage.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,30 @@ import os from 'os';
import { invalidateCache } from './skills.js';
import { createSnapshot } from '../versioning/store.js';
import { moveToTrash } from '../trash/store.js';
import { confinePath, confineExistingPath } from '../security.js';
const homedir = os.homedir();
const settingsPath = path.join(homedir, '.claude', 'settings.json');
// skillName is user-supplied and gets joined into a filesystem path. Reject
// anything that could escape the intended parent dir (separators, traversal,
// NUL bytes, Windows drive letters). Also cap length — npm/git barf past this.
function sanitizeSkillName(raw) {
if (typeof raw !== 'string')
throw new Error('skillName 必须是字符串');
const trimmed = raw.trim();
if (trimmed.length === 0)
throw new Error('skillName 不能为空');
if (trimmed.length > 128)
throw new Error('skillName 过长');
if (trimmed.includes('/') ||
trimmed.includes('\\') ||
trimmed.includes('\0') ||
trimmed === '.' ||
trimmed === '..' ||
/^[A-Za-z]:/.test(trimmed)) {
throw new Error(`skillName 包含非法字符: ${raw}`);
}
return trimmed;
}
async function readSettings() {
try {
const raw = await fs.readFile(settingsPath, 'utf-8');
Expand Down Expand Up @@ -42,108 +64,141 @@ export async function manageRoutes(app) {
return { ok: true, enabled };
});
// Update SKILL.md content
app.put('/api/skills/:id/content', async (req) => {
app.put('/api/skills/:id/content', async (req, reply) => {
const { realPath, content } = req.body;
const skillMdPath = path.join(realPath, 'SKILL.md');
// Verify the file exists
if (typeof content !== 'string') {
reply.status(400);
return { ok: false, error: 'content 必须是字符串' };
}
let safeRealPath;
try {
safeRealPath = await confineExistingPath(realPath);
}
catch (err) {
reply.status(403);
return { ok: false, error: err?.message || '路径不合法' };
}
const skillMdPath = path.join(safeRealPath, 'SKILL.md');
try {
await fs.access(skillMdPath);
}
catch {
reply.status(404);
return { ok: false, error: 'SKILL.md not found' };
}
// Auto-snapshot before overwriting (save the old version)
const skillName = path.basename(realPath);
const skillName = path.basename(safeRealPath);
try {
await createSnapshot(realPath, skillName, '编辑前自动备份', 'auto');
await createSnapshot(safeRealPath, skillName, '编辑前自动备份', 'auto');
}
catch { }
await fs.writeFile(skillMdPath, content, 'utf-8');
// Snapshot the new version
try {
await createSnapshot(realPath, skillName, '通过编辑器保存', 'auto');
await createSnapshot(safeRealPath, skillName, '通过编辑器保存', 'auto');
}
catch { }
invalidateCache();
return { ok: true };
});
// Copy skill to another location
app.post('/api/skills/copy', async (req) => {
app.post('/api/skills/copy', async (req, reply) => {
const { sourcePath, targetScope, projectPath, skillName } = req.body;
let safeName;
let safeSource;
let targetDir;
if (targetScope === 'global') {
targetDir = path.join(homedir, '.claude', 'skills', skillName);
}
else if (projectPath) {
targetDir = path.join(projectPath, '.claude', 'skills', skillName);
}
else {
return { ok: false, error: 'Project path required for project scope' };
}
// Resolve source if symlink
let realSource;
try {
realSource = await fs.realpath(sourcePath);
safeName = sanitizeSkillName(skillName);
safeSource = await confineExistingPath(sourcePath);
if (targetScope === 'global') {
targetDir = path.join(homedir, '.claude', 'skills', safeName);
}
else if (projectPath) {
const safeProject = await confineExistingPath(projectPath);
targetDir = path.join(safeProject, '.claude', 'skills', safeName);
}
else {
reply.status(400);
return { ok: false, error: 'Project path required for project scope' };
}
// Target itself must also fall inside a safe root.
targetDir = await confinePath(targetDir);
}
catch {
realSource = sourcePath;
catch (err) {
reply.status(403);
return { ok: false, error: err?.message || '参数不合法' };
}
// Check if target already exists
try {
await fs.access(targetDir);
return { ok: false, error: '目标位置已存在同名 Skill' };
}
catch {
// Good — doesn't exist
}
// Copy directory recursively
await copyDir(realSource, targetDir);
await copyDir(safeSource, targetDir);
invalidateCache();
return { ok: true, targetDir };
});
// Move skill (copy + delete source)
app.post('/api/skills/move', async (req) => {
app.post('/api/skills/move', async (req, reply) => {
const { sourcePath, targetScope, projectPath, skillName } = req.body;
let safeName;
let safeSourceReal;
let safeSourceLink;
let targetDir;
if (targetScope === 'global') {
targetDir = path.join(homedir, '.claude', 'skills', skillName);
}
else if (projectPath) {
targetDir = path.join(projectPath, '.claude', 'skills', skillName);
}
else {
return { ok: false, error: 'Project path required for project scope' };
}
let realSource;
try {
realSource = await fs.realpath(sourcePath);
safeName = sanitizeSkillName(skillName);
// Validate both the link location AND the symlink target; both get
// touched (source link is unlinked, real dir is copied).
safeSourceLink = await confineExistingPath(sourcePath);
safeSourceReal = await confineExistingPath(await fs.realpath(safeSourceLink).catch(() => safeSourceLink));
if (targetScope === 'global') {
targetDir = path.join(homedir, '.claude', 'skills', safeName);
}
else if (projectPath) {
const safeProject = await confineExistingPath(projectPath);
targetDir = path.join(safeProject, '.claude', 'skills', safeName);
}
else {
reply.status(400);
return { ok: false, error: 'Project path required for project scope' };
}
targetDir = await confinePath(targetDir);
}
catch {
realSource = sourcePath;
catch (err) {
reply.status(403);
return { ok: false, error: err?.message || '参数不合法' };
}
try {
await fs.access(targetDir);
return { ok: false, error: '目标位置已存在同名 Skill' };
}
catch { }
await copyDir(realSource, targetDir);
await copyDir(safeSourceReal, targetDir);
// Remove the source (if symlink, just remove the link; if dir, remove recursively)
const stat = await fs.lstat(sourcePath);
const stat = await fs.lstat(safeSourceLink);
if (stat.isSymbolicLink()) {
await fs.unlink(sourcePath);
await fs.unlink(safeSourceLink);
}
else {
await fs.rm(sourcePath, { recursive: true });
await fs.rm(safeSourceLink, { recursive: true });
}
invalidateCache();
return { ok: true, targetDir };
});
// Delete skill (soft delete → recycle bin; 7-day TTL)
app.delete('/api/skills/:id', async (req, reply) => {
const skillPath = req.body.path;
const skillName = req.body.skillName;
let safePath;
try {
safePath = await confineExistingPath(req.body.path);
}
catch (err) {
reply.status(403);
return { ok: false, error: err?.message || '路径不合法' };
}
try {
const meta = await moveToTrash(skillPath, skillName);
const meta = await moveToTrash(safePath, skillName);
invalidateCache();
return { ok: true, trashId: meta.id, expiresAt: meta.expiresAt };
}
Expand All @@ -165,8 +220,21 @@ export async function manageRoutes(app) {
results.push({ id: item?.id || '(unknown)', ok: false, error: '参数不完整' });
continue;
}
let safeItemPath;
try {
safeItemPath = await confineExistingPath(item.path);
}
catch (err) {
results.push({
id: item.id,
skillName: item.skillName,
ok: false,
error: err?.message || '路径不合法',
});
continue;
}
try {
const meta = await moveToTrash(item.path, item.skillName);
const meta = await moveToTrash(safeItemPath, item.skillName);
results.push({
id: item.id,
skillName: item.skillName,
Expand Down
30 changes: 27 additions & 3 deletions dist/server/routes/trash.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { listTrash, restoreFromTrash, purgeOne, purgeExpired, TrashConflictError, TrashNotFoundError, } from '../trash/store.js';
import { invalidateCache } from './skills.js';
// newId() from ../trash/store.ts always matches `<base36>-<hex>`. Reject
// anything else so an attacker can't smuggle path traversal into fs.rm.
const TRASH_ID_RE = /^[a-z0-9]{1,20}-[a-f0-9]{4,32}$/;
function assertSafeTrashId(id) {
if (typeof id !== 'string' || !TRASH_ID_RE.test(id)) {
throw new Error(`非法 trash id: ${String(id)}`);
}
return id;
}
export async function trashRoutes(app) {
// List trash entries (also purges expired as a side effect)
app.get('/api/trash', async () => {
Expand All @@ -8,7 +17,14 @@ export async function trashRoutes(app) {
});
// Restore a trash entry back to its original location
app.post('/api/trash/:id/restore', async (req, reply) => {
const { id } = req.params;
let id;
try {
id = assertSafeTrashId(req.params.id);
}
catch (err) {
reply.status(400);
return { ok: false, error: err.message };
}
const force = req.query.force === 'true' || req.query.force === '1';
try {
const meta = await restoreFromTrash(id, force);
Expand All @@ -29,8 +45,16 @@ export async function trashRoutes(app) {
}
});
// Permanently delete a single trash entry
app.delete('/api/trash/:id', async (req) => {
const ok = await purgeOne(req.params.id);
app.delete('/api/trash/:id', async (req, reply) => {
let id;
try {
id = assertSafeTrashId(req.params.id);
}
catch (err) {
reply.status(400);
return { ok: false, error: err.message };
}
const ok = await purgeOne(id);
return { ok };
});
// Manual purge of expired entries
Expand Down
39 changes: 33 additions & 6 deletions dist/server/routes/versions.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { createSnapshot, getHistory, getVersion, diffVersions, diffWithCurrent, rollback, deleteVersion, } from '../versioning/store.js';
import { invalidateCache } from './skills.js';
import { confinePath, confineExistingPath } from '../security.js';
export async function versionRoutes(app) {
// 创建快照
app.post('/api/versions/snapshot', async (req) => {
app.post('/api/versions/snapshot', async (req, reply) => {
const { skillPath, skillName, message } = req.body;
let safePath;
try {
const meta = await createSnapshot(skillPath, skillName, message, 'manual');
safePath = await confineExistingPath(skillPath);
}
catch (err) {
reply.status(403);
return { ok: false, error: err?.message || '路径不合法' };
}
try {
const meta = await createSnapshot(safePath, skillName, message, 'manual');
return { ok: true, version: meta };
}
catch (e) {
Expand Down Expand Up @@ -43,18 +52,36 @@ export async function versionRoutes(app) {
return { ok: true, diff };
});
// 回滚到指定版本
app.post('/api/versions/rollback', async (req) => {
app.post('/api/versions/rollback', async (req, reply) => {
const { skillPath, versionId } = req.body;
const success = await rollback(skillPath, versionId);
let safePath;
try {
safePath = await confineExistingPath(skillPath);
}
catch (err) {
reply.status(403);
return { ok: false, error: err?.message || '路径不合法' };
}
const success = await rollback(safePath, versionId);
if (!success)
return { ok: false, error: 'Rollback failed' };
invalidateCache();
return { ok: true };
});
// 删除版本
app.delete('/api/versions', async (req) => {
app.delete('/api/versions', async (req, reply) => {
const { skillPath, versionId } = req.query;
const success = await deleteVersion(skillPath, versionId);
let safePath;
try {
// version metadata is keyed by skill path — we still confine, but the
// path may have been deleted (allow non-existent), so use confinePath.
safePath = await confinePath(skillPath);
}
catch (err) {
reply.status(403);
return { ok: false, error: err?.message || '路径不合法' };
}
const success = await deleteVersion(safePath, versionId);
return { ok: success };
});
}
Loading