Skip to content

Commit a5d9e8c

Browse files
author
catlog22
committed
feat: Enhance lite-skill-generator with single file output and improved validation
1 parent 502c8a0 commit a5d9e8c

2 files changed

Lines changed: 108 additions & 21 deletions

File tree

ccw/src/core/routes/system-routes.ts

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -286,19 +286,35 @@ export async function handleSystemRoutes(ctx: SystemRouteContext): Promise<boole
286286
return true;
287287
}
288288

289+
let validatedPath: string;
289290
try {
290291
// Validate path is within allowed directories (fix: sec-001-a1b2c3d4)
291-
const validatedPath = await validateAllowedPath(filePath, {
292-
mustExist: true,
293-
allowedDirectories: [process.cwd(), resolvePath('.ccw', 'sessions')]
294-
});
292+
validatedPath = await validateAllowedPath(filePath, { mustExist: true, allowedDirectories: [initialPath] });
293+
} catch (err) {
294+
const message = err instanceof Error ? err.message : String(err);
295+
const status = message.includes('Access denied') ? 403 : (message.includes('File not found') ? 404 : 400);
296+
console.error(`[System] Path validation failed: ${message}`);
297+
res.writeHead(status, { 'Content-Type': 'application/json' });
298+
res.end(JSON.stringify({
299+
error: status === 403 ? 'Access denied' : (status === 404 ? 'File not found' : 'Invalid path')
300+
}));
301+
return true;
302+
}
303+
304+
try {
295305
const content = await fsPromises.readFile(validatedPath, 'utf-8');
296306
const json = JSON.parse(content);
297307
res.writeHead(200, { 'Content-Type': 'application/json' });
298308
res.end(JSON.stringify(json));
299-
} catch (err) {
300-
res.writeHead(404, { 'Content-Type': 'application/json' });
301-
res.end(JSON.stringify({ error: 'File not found or invalid JSON' }));
309+
} catch (err: unknown) {
310+
const errno = typeof err === 'object' && err !== null && 'code' in err ? String((err as any).code) : null;
311+
const status = err instanceof SyntaxError ? 400 : (errno === 'EACCES' ? 403 : 404);
312+
const message = err instanceof Error ? err.message : String(err);
313+
console.error(`[System] Failed to read JSON file: ${message}`);
314+
res.writeHead(status, { 'Content-Type': 'application/json' });
315+
res.end(JSON.stringify({
316+
error: status === 403 ? 'Access denied' : (status === 400 ? 'Invalid JSON' : 'File not found')
317+
}));
302318
}
303319
return true;
304320
}
@@ -449,12 +465,16 @@ export async function handleSystemRoutes(ctx: SystemRouteContext): Promise<boole
449465
}
450466

451467
// Validate path is within allowed directories (fix: sec-003-c3d4e5f6)
452-
const initialPath = process.cwd();
453-
if (browsePath) {
468+
try {
454469
targetPath = await validateAllowedPath(targetPath, {
455470
mustExist: true,
456471
allowedDirectories: [initialPath, os.homedir()]
457472
});
473+
} catch (err) {
474+
const message = err instanceof Error ? err.message : String(err);
475+
const status = message.includes('Access denied') ? 403 : 400;
476+
console.error(`[System] Path validation failed: ${message}`);
477+
return { error: status === 403 ? 'Access denied' : 'Invalid path', status };
458478
}
459479

460480
try {
@@ -486,7 +506,9 @@ export async function handleSystemRoutes(ctx: SystemRouteContext): Promise<boole
486506
homePath: os.homedir()
487507
};
488508
} catch (err) {
489-
return { error: 'Cannot access directory: ' + (err as Error).message, status: 400 };
509+
const message = err instanceof Error ? err.message : String(err);
510+
console.error(`[System] Failed to browse directory: ${message}`);
511+
return { error: 'Cannot access directory', status: 400 };
490512
}
491513
});
492514
return true;
@@ -518,11 +540,17 @@ export async function handleSystemRoutes(ctx: SystemRouteContext): Promise<boole
518540
}
519541

520542
// Validate path is within allowed directories (fix: sec-003-c3d4e5f6)
521-
const initialPath = process.cwd();
522-
targetPath = await validateAllowedPath(targetPath, {
523-
mustExist: true,
524-
allowedDirectories: [initialPath, os.homedir()]
525-
});
543+
try {
544+
targetPath = await validateAllowedPath(targetPath, {
545+
mustExist: true,
546+
allowedDirectories: [initialPath, os.homedir()]
547+
});
548+
} catch (err) {
549+
const message = err instanceof Error ? err.message : String(err);
550+
const status = message.includes('Access denied') ? 403 : 400;
551+
console.error(`[System] Path validation failed: ${message}`);
552+
return { error: status === 403 ? 'Access denied' : 'Invalid path', status };
553+
}
526554

527555
try {
528556
await fs.promises.access(targetPath, fs.constants.R_OK);
@@ -534,8 +562,10 @@ export async function handleSystemRoutes(ctx: SystemRouteContext): Promise<boole
534562
isFile: stat.isFile(),
535563
isDirectory: stat.isDirectory()
536564
};
537-
} catch {
538-
return { error: 'File not accessible', status: 404 };
565+
} catch (err: unknown) {
566+
const errno = typeof err === 'object' && err !== null && 'code' in err ? String((err as any).code) : null;
567+
const status = errno === 'EACCES' ? 403 : 404;
568+
return { error: status === 403 ? 'Access denied' : 'File not accessible', status };
539569
}
540570
});
541571
return true;

ccw/tests/integration/system-routes.test.ts

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@
99
import { after, before, describe, it, mock } from 'node:test';
1010
import assert from 'node:assert/strict';
1111
import http from 'node:http';
12-
import { mkdtempSync, rmSync } from 'node:fs';
13-
import { tmpdir } from 'node:os';
14-
import { join } from 'node:path';
12+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
13+
import { tmpdir, homedir } from 'node:os';
14+
import { join, parse } from 'node:path';
1515

1616
const CCW_HOME = mkdtempSync(join(tmpdir(), 'ccw-system-routes-home-'));
1717
const PROJECT_ROOT = mkdtempSync(join(tmpdir(), 'ccw-system-routes-project-'));
18+
const OUTSIDE_ROOT = mkdtempSync(join(tmpdir(), 'ccw-system-routes-outside-'));
1819

1920
const systemRoutesUrl = new URL('../../dist/core/routes/system-routes.js', import.meta.url);
2021
systemRoutesUrl.searchParams.set('t', String(Date.now()));
@@ -140,6 +141,7 @@ describe('system routes integration', async () => {
140141
process.env.CCW_DATA_DIR = originalEnv.CCW_DATA_DIR;
141142
rmSync(CCW_HOME, { recursive: true, force: true });
142143
rmSync(PROJECT_ROOT, { recursive: true, force: true });
144+
rmSync(OUTSIDE_ROOT, { recursive: true, force: true });
143145
});
144146

145147
it('GET /api/health returns ok payload', async () => {
@@ -237,5 +239,60 @@ describe('system routes integration', async () => {
237239
await new Promise<void>((resolve) => originalClose(() => resolve()));
238240
}
239241
});
240-
});
241242

243+
it('GET /api/file reads JSON within initialPath and rejects outside paths', async () => {
244+
const insideFile = join(PROJECT_ROOT, '.review', 'fixes', 'active-fix-session.json');
245+
mkdirSync(join(PROJECT_ROOT, '.review', 'fixes'), { recursive: true });
246+
writeFileSync(insideFile, JSON.stringify({ ok: true }), 'utf8');
247+
248+
const outsideFile = join(OUTSIDE_ROOT, 'outside.json');
249+
writeFileSync(outsideFile, JSON.stringify({ ok: true }), 'utf8');
250+
251+
const { server, baseUrl } = await createServer(PROJECT_ROOT);
252+
try {
253+
const ok = await requestJson(baseUrl, 'GET', `/api/file?path=${encodeURIComponent(insideFile)}`);
254+
assert.equal(ok.status, 200);
255+
assert.equal(ok.json.ok, true);
256+
257+
const denied = await requestJson(baseUrl, 'GET', `/api/file?path=${encodeURIComponent(outsideFile)}`);
258+
assert.equal(denied.status, 403);
259+
assert.equal(denied.json.error, 'Access denied');
260+
} finally {
261+
await new Promise<void>((resolve) => server.close(() => resolve()));
262+
}
263+
});
264+
265+
it('POST /api/dialog/browse rejects paths outside allowed roots', async () => {
266+
const { server, baseUrl } = await createServer(PROJECT_ROOT);
267+
try {
268+
const rootPath = parse(homedir()).root;
269+
const denied = await requestJson(baseUrl, 'POST', '/api/dialog/browse', { path: rootPath, showHidden: true });
270+
assert.equal(denied.status, 403);
271+
assert.equal(denied.json.error, 'Access denied');
272+
} finally {
273+
await new Promise<void>((resolve) => server.close(() => resolve()));
274+
}
275+
});
276+
277+
it('POST /api/dialog/open-file accepts files under initialPath and rejects outside paths', async () => {
278+
const allowedFile = join(PROJECT_ROOT, 'allowed.txt');
279+
writeFileSync(allowedFile, 'ok', 'utf8');
280+
281+
const rootPath = parse(homedir()).root;
282+
const deniedPath = join(rootPath, 'ccw-not-allowed.txt');
283+
284+
const { server, baseUrl } = await createServer(PROJECT_ROOT);
285+
try {
286+
const ok = await requestJson(baseUrl, 'POST', '/api/dialog/open-file', { path: allowedFile });
287+
assert.equal(ok.status, 200);
288+
assert.equal(ok.json.success, true);
289+
assert.equal(ok.json.isFile, true);
290+
291+
const denied = await requestJson(baseUrl, 'POST', '/api/dialog/open-file', { path: deniedPath });
292+
assert.equal(denied.status, 403);
293+
assert.equal(denied.json.error, 'Access denied');
294+
} finally {
295+
await new Promise<void>((resolve) => server.close(() => resolve()));
296+
}
297+
});
298+
});

0 commit comments

Comments
 (0)