agentgui 1.0.958 → 1.0.959

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.
@@ -37,6 +37,56 @@ function confineToRoots(inputPath, allowRoots) {
37
37
  return { ok: true, realPath };
38
38
  }
39
39
 
40
+ // The allowlist the Files surface operates within: server cwd + Claude
41
+ // projects dir, widened via FS_ROOTS (path-separated). One construction so
42
+ // /api/list,file,download and the mutation routes can never drift apart.
43
+ function fsAllowRoots() {
44
+ return [
45
+ process.env.STARTUP_CWD || process.cwd(),
46
+ process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects'),
47
+ ...(process.env.FS_ROOTS ? process.env.FS_ROOTS.split(path.delimiter) : []),
48
+ ].map(r => path.normalize(r));
49
+ }
50
+
51
+ // A new file/dir name must be a single path component: no separators, no
52
+ // traversal, no NTFS alternate-data-stream colon, no reserved Windows device
53
+ // names, no trailing dot/space (Windows silently strips them, aliasing two
54
+ // names onto one entry). Returns the trimmed name or null when inadmissible.
55
+ function sanitizeEntryName(name) {
56
+ if (typeof name !== 'string') return null;
57
+ const n = name.trim();
58
+ if (!n || n.length > 255) return null;
59
+ if (/[\\/:*?"<>|\x00-\x1f]/.test(n)) return null;
60
+ if (n === '.' || n === '..') return null;
61
+ if (/[. ]$/.test(n)) return null;
62
+ if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)/i.test(n)) return null;
63
+ return n;
64
+ }
65
+
66
+ // Read a request body with a hard size cap; resolves a Buffer or rejects with
67
+ // .code='TOO_LARGE' so the caller can answer 413 without buffering the rest.
68
+ function readBody(req, maxBytes) {
69
+ return new Promise((resolve, reject) => {
70
+ const chunks = []; let total = 0;
71
+ req.on('data', (c) => {
72
+ total += c.length;
73
+ if (total > maxBytes) { const e = new Error('body too large'); e.code = 'TOO_LARGE'; req.destroy(); reject(e); return; }
74
+ chunks.push(c);
75
+ });
76
+ req.on('end', () => resolve(Buffer.concat(chunks)));
77
+ req.on('error', reject);
78
+ });
79
+ }
80
+
81
+ // True when the resolved path IS one of the allowlist roots - the roots
82
+ // themselves are never mutation targets (rename/delete of a root would orphan
83
+ // the whole surface).
84
+ function isAllowRoot(realPath, allowRoots) {
85
+ const isWindows = os.platform() === 'win32';
86
+ const p = isWindows ? realPath.toLowerCase() : realPath;
87
+ return allowRoots.some(r => (isWindows ? r.toLowerCase() : r) === p);
88
+ }
89
+
40
90
  export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, serveFile, staticDir, messageQueues, getWss, activeExecutions, getACPStatus, discoveredAgents, PKG_VERSION, RATE_LIMIT_MAX, rateLimitMap, routes, PORT }) {
41
91
  return async function httpHandler(req, res) {
42
92
  // CORS: when PASSWORD is set the server holds credentials a cross-origin
@@ -116,6 +166,28 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
116
166
  }
117
167
 
118
168
  const pathOnly = req.url.split('?')[0];
169
+
170
+ // CSRF guard on every state-changing method. Without PASSWORD the server
171
+ // is open and advertises a wildcard ACAO, so a cross-site page could POST
172
+ // a form at the mutation routes (rename/delete/mkdir/upload) on localhost.
173
+ // A simple cross-site form cannot set Sec-Fetch-Site:same-origin, send
174
+ // application/json, or attach custom headers - so: accept when the
175
+ // browser says same-origin/none (direct nav, same-origin fetch), when the
176
+ // body is declared JSON, or when an Authorization header rode along (a
177
+ // credentialed programmatic client). Reject the rest with 403.
178
+ if (req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE') {
179
+ const sfs = req.headers['sec-fetch-site'];
180
+ const ct = (req.headers['content-type'] || '').toLowerCase();
181
+ const sameSite = !sfs || sfs === 'same-origin' || sfs === 'none';
182
+ const jsonBody = ct.startsWith('application/json') || ct.startsWith('application/octet-stream') || ct === '';
183
+ const authed = !!req.headers['authorization'];
184
+ if (!sameSite && !jsonBody && !authed) {
185
+ res.writeHead(403, { 'Content-Type': 'application/json' });
186
+ res.end(JSON.stringify({ error: 'cross-site request rejected' }));
187
+ return;
188
+ }
189
+ }
190
+
119
191
  if (pathOnly.startsWith(BASE_URL + '/api/upload/') || pathOnly.startsWith(BASE_URL + '/files/') || pathOnly.startsWith('/v1/history') || (BASE_URL && pathOnly.startsWith(BASE_URL + '/v1/history'))) return expressApp(req, res);
120
192
 
121
193
  if (req.url === '/favicon.ico' || req.url === BASE_URL + '/favicon.ico') {
@@ -147,7 +219,31 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
147
219
  try { queries._db.prepare('SELECT 1').get(); } catch (e) { dbStatus = { ok: false, error: e.message }; }
148
220
  const queueSizes = {};
149
221
  for (const [k, v] of messageQueues) queueSizes[k] = v.length;
150
- sendJSON(req, res, 200, { status: 'ok', version: PKG_VERSION, uptime: process.uptime(), agents: discoveredAgents.length, activeExecutions: activeExecutions.size, wsClients: getWss()?.clients?.size ?? 0, memory: process.memoryUsage(), acp: getACPStatus(), db: dbStatus, queueSizes });
222
+ sendJSON(req, res, 200, {
223
+ status: 'ok', version: PKG_VERSION, uptime: process.uptime(), agents: discoveredAgents.length,
224
+ activeExecutions: activeExecutions.size, wsClients: getWss()?.clients?.size ?? 0,
225
+ memory: process.memoryUsage(), acp: getACPStatus(), db: dbStatus, queueSizes,
226
+ // Read-only server facts for the settings server-info panel.
227
+ projectsDir: process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects'),
228
+ allowRoots: fsAllowRoots(),
229
+ });
230
+ return;
231
+ }
232
+
233
+ // Existence/shape probe for a proposed chat working directory, confined
234
+ // to the same allowlist as the Files surface (an unconfined stat would be
235
+ // a filesystem oracle). Returns {ok, dir} or a 403/404 with plain copy.
236
+ if (routePath.startsWith('/api/stat') && req.method === 'GET') {
237
+ const rawS = routePath.split('?')[0].slice('/api/stat'.length).replace(/^\//, '');
238
+ const decodedPath = rawS ? decodeURIComponent(rawS) : '';
239
+ const conf = confineToRoots(decodedPath, fsAllowRoots());
240
+ if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: conf.reason }); return; }
241
+ try {
242
+ const st = fs.statSync(conf.realPath);
243
+ sendJSON(req, res, 200, { ok: true, dir: st.isDirectory(), path: conf.realPath });
244
+ } catch (err) {
245
+ sendJSON(req, res, err.code === 'ENOENT' ? 404 : 403, { error: err.message });
246
+ }
151
247
  return;
152
248
  }
153
249
 
@@ -191,11 +287,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
191
287
  if (routePath.startsWith('/api/list')) {
192
288
  const rawQ = routePath.split('?')[0].slice('/api/list'.length).replace(/^\//, '');
193
289
  const decodedPath = rawQ ? decodeURIComponent(rawQ) : '';
194
- const allowRoots = [
195
- process.env.STARTUP_CWD || process.cwd(),
196
- process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects'),
197
- ...(process.env.FS_ROOTS ? process.env.FS_ROOTS.split(path.delimiter) : []),
198
- ].map(r => path.normalize(r));
290
+ const allowRoots = fsAllowRoots();
199
291
  // Empty path = the first allow-root (a sane default landing dir).
200
292
  const reqPath = !decodedPath ? allowRoots[0] : decodedPath;
201
293
  const conf = confineToRoots(reqPath, allowRoots);
@@ -264,11 +356,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
264
356
  if (routePath.startsWith('/api/file/')) {
265
357
  const rawF = routePath.split('?')[0].slice('/api/file/'.length);
266
358
  const decodedPath = decodeURIComponent(rawF);
267
- const allowRoots = [
268
- process.env.STARTUP_CWD || process.cwd(),
269
- process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects'),
270
- ...(process.env.FS_ROOTS ? process.env.FS_ROOTS.split(path.delimiter) : []),
271
- ].map(r => path.normalize(r));
359
+ const allowRoots = fsAllowRoots();
272
360
  const conf = confineToRoots(decodedPath, allowRoots);
273
361
  if (!conf.ok) { res.writeHead(conf.reason === 'not found' ? 404 : 403); res.end('Forbidden'); return; }
274
362
  const normalizedPath = conf.realPath;
@@ -309,11 +397,7 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
309
397
  if (routePath.startsWith('/api/download/')) {
310
398
  const rawD = routePath.split('?')[0].slice('/api/download/'.length);
311
399
  const decodedPath = decodeURIComponent(rawD);
312
- const allowRoots = [
313
- process.env.STARTUP_CWD || process.cwd(),
314
- process.env.CLAUDE_PROJECTS_DIR || path.join(os.homedir(), '.claude', 'projects'),
315
- ...(process.env.FS_ROOTS ? process.env.FS_ROOTS.split(path.delimiter) : []),
316
- ].map(r => path.normalize(r));
400
+ const allowRoots = fsAllowRoots();
317
401
  const conf = confineToRoots(decodedPath, allowRoots);
318
402
  if (!conf.ok) { res.writeHead(conf.reason === 'not found' ? 404 : 403); res.end('Forbidden'); return; }
319
403
  const normalizedPath = conf.realPath;
@@ -337,6 +421,102 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
337
421
  return;
338
422
  }
339
423
 
424
+ // --- File mutations ----------------------------------------------------
425
+ // The Files surface is a real manager (fsbrowse-grade): rename, delete,
426
+ // mkdir, upload. Every route re-confines via confineToRoots (realpath, so
427
+ // a symlink inside a root cannot point a mutation outside it), refuses
428
+ // the roots themselves as targets, and sanitizes any NEW name to a single
429
+ // path component (sanitizeEntryName). All are POST/PUT-only with JSON or
430
+ // raw-byte bodies; errors map to plain machine codes the client renders
431
+ // as human copy.
432
+
433
+ // POST /api/rename {path, newName} -> {ok, path}
434
+ if (routePath.split('?')[0] === '/api/rename' && req.method === 'POST') {
435
+ let body;
436
+ try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}'); }
437
+ catch (e) { sendJSON(req, res, e.code === 'TOO_LARGE' ? 413 : 400, { error: 'bad request body' }); return; }
438
+ const allowRoots = fsAllowRoots();
439
+ const conf = confineToRoots(String(body.path || ''), allowRoots);
440
+ if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + conf.reason }); return; }
441
+ if (isAllowRoot(conf.realPath, allowRoots)) { sendJSON(req, res, 403, { error: 'forbidden: cannot rename an allowed root' }); return; }
442
+ const newName = sanitizeEntryName(body.newName);
443
+ if (!newName) { sendJSON(req, res, 400, { error: 'invalid name' }); return; }
444
+ const target = path.join(path.dirname(conf.realPath), newName);
445
+ // The target stays in the same (already-confined) directory by
446
+ // construction, but re-check anyway so the invariant is local.
447
+ const tConf = confineToRoots(path.dirname(target), allowRoots);
448
+ if (!tConf.ok) { sendJSON(req, res, 403, { error: 'forbidden: target outside allowed roots' }); return; }
449
+ if (fs.existsSync(target)) { sendJSON(req, res, 409, { error: 'a file with that name already exists' }); return; }
450
+ try { fs.renameSync(conf.realPath, target); sendJSON(req, res, 200, { ok: true, path: target }); }
451
+ catch (err) { sendJSON(req, res, err.code === 'EACCES' || err.code === 'EPERM' ? 403 : 400, { error: err.message }); }
452
+ return;
453
+ }
454
+
455
+ // POST /api/delete {path, recursive?} -> {ok}. Deleting a non-empty dir
456
+ // requires recursive:true (the client confirms first).
457
+ if (routePath.split('?')[0] === '/api/delete' && req.method === 'POST') {
458
+ let body;
459
+ try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}'); }
460
+ catch (e) { sendJSON(req, res, e.code === 'TOO_LARGE' ? 413 : 400, { error: 'bad request body' }); return; }
461
+ const allowRoots = fsAllowRoots();
462
+ const conf = confineToRoots(String(body.path || ''), allowRoots);
463
+ if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + conf.reason }); return; }
464
+ if (isAllowRoot(conf.realPath, allowRoots)) { sendJSON(req, res, 403, { error: 'forbidden: cannot delete an allowed root' }); return; }
465
+ try {
466
+ const st = fs.lstatSync(conf.realPath);
467
+ if (st.isDirectory()) {
468
+ if (body.recursive === true) fs.rmSync(conf.realPath, { recursive: true });
469
+ else fs.rmdirSync(conf.realPath); // throws ENOTEMPTY unless empty
470
+ } else {
471
+ fs.unlinkSync(conf.realPath);
472
+ }
473
+ sendJSON(req, res, 200, { ok: true });
474
+ } catch (err) {
475
+ const code = err.code === 'ENOTEMPTY' ? 409 : (err.code === 'EACCES' || err.code === 'EPERM' ? 403 : (err.code === 'ENOENT' ? 404 : 400));
476
+ sendJSON(req, res, code, { error: err.code === 'ENOTEMPTY' ? 'directory is not empty' : err.message });
477
+ }
478
+ return;
479
+ }
480
+
481
+ // POST /api/mkdir {dir, name} -> {ok, path}. dir must exist inside roots.
482
+ if (routePath.split('?')[0] === '/api/mkdir' && req.method === 'POST') {
483
+ let body;
484
+ try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}'); }
485
+ catch (e) { sendJSON(req, res, e.code === 'TOO_LARGE' ? 413 : 400, { error: 'bad request body' }); return; }
486
+ const allowRoots = fsAllowRoots();
487
+ const conf = confineToRoots(String(body.dir || ''), allowRoots);
488
+ if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + conf.reason }); return; }
489
+ const name = sanitizeEntryName(body.name);
490
+ if (!name) { sendJSON(req, res, 400, { error: 'invalid name' }); return; }
491
+ const target = path.join(conf.realPath, name);
492
+ if (fs.existsSync(target)) { sendJSON(req, res, 409, { error: 'a file with that name already exists' }); return; }
493
+ try { fs.mkdirSync(target); sendJSON(req, res, 200, { ok: true, path: target }); }
494
+ catch (err) { sendJSON(req, res, err.code === 'EACCES' || err.code === 'EPERM' ? 403 : 400, { error: err.message }); }
495
+ return;
496
+ }
497
+
498
+ // PUT /api/upload-file?dir=<enc>&name=<enc> with raw file bytes as the
499
+ // body (no multipart dependency; the client sends fetch(file)). 50MB cap,
500
+ // never overwrites unless ?overwrite=1. Distinct path from the legacy
501
+ // express-mounted /api/upload/:conversationId.
502
+ if (routePath.split('?')[0] === '/api/upload-file' && req.method === 'PUT') {
503
+ let qs;
504
+ try { qs = new URL(req.url, 'http://localhost').searchParams; } catch { qs = new URLSearchParams(); }
505
+ const allowRoots = fsAllowRoots();
506
+ const conf = confineToRoots(qs.get('dir') || '', allowRoots);
507
+ if (!conf.ok) { sendJSON(req, res, conf.reason === 'not found' ? 404 : 403, { error: 'forbidden: ' + conf.reason }); return; }
508
+ const name = sanitizeEntryName(qs.get('name'));
509
+ if (!name) { sendJSON(req, res, 400, { error: 'invalid name' }); return; }
510
+ const target = path.join(conf.realPath, name);
511
+ if (fs.existsSync(target) && qs.get('overwrite') !== '1') { sendJSON(req, res, 409, { error: 'a file with that name already exists' }); return; }
512
+ let buf;
513
+ try { buf = await readBody(req, 50 * 1024 * 1024); }
514
+ catch (e) { sendJSON(req, res, e.code === 'TOO_LARGE' ? 413 : 400, { error: e.code === 'TOO_LARGE' ? 'file too large (50MB cap)' : 'upload failed' }); return; }
515
+ try { fs.writeFileSync(target, buf); sendJSON(req, res, 200, { ok: true, path: target, size: buf.length }); }
516
+ catch (err) { sendJSON(req, res, err.code === 'EACCES' || err.code === 'EPERM' ? 403 : 400, { error: err.message }); }
517
+ return;
518
+ }
519
+
340
520
  if (routePath.startsWith('/api/image/')) {
341
521
  const imagePath = routePath.slice('/api/image/'.length);
342
522
  const decodedPath = decodeURIComponent(imagePath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.958",
3
+ "version": "1.0.959",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -0,0 +1,40 @@
1
+ // Live security validation of the confined file-mutation endpoints.
2
+ // Run with the server up: node scripts/validate-mutations.mjs
3
+ const BASE = process.env.BASE || 'http://localhost:3000';
4
+ const J = { 'Content-Type': 'application/json' };
5
+ const results = [];
6
+ async function post(route, body, headers = J) {
7
+ const r = await fetch(BASE + route, { method: 'POST', headers, body: typeof body === 'string' ? body : JSON.stringify(body) });
8
+ return { status: r.status, body: await r.text() };
9
+ }
10
+ async function check(name, expected, actual) {
11
+ const ok = actual.status === expected;
12
+ results.push({ name, expected, got: actual.status, ok, body: actual.body.slice(0, 80) });
13
+ }
14
+ const ROOT = 'C:\\dev\\agentgui';
15
+ // security cases
16
+ await check('rename-traversal', 403, await post('/api/rename', { path: ROOT + '\\..\\..\\Windows\\win.ini', newName: 'x.txt' }));
17
+ await check('delete-out-of-roots', 403, await post('/api/delete', { path: 'C:\\Windows\\Temp\\x.txt' }));
18
+ await check('delete-root-refused', 403, await post('/api/delete', { path: ROOT }));
19
+ await check('mkdir-reserved-name', 400, await post('/api/mkdir', { dir: ROOT, name: 'CON' }));
20
+ await check('mkdir-name-with-separator', 400, await post('/api/mkdir', { dir: ROOT, name: 'a/b' }));
21
+ await check('mkdir-name-trailing-dot', 400, await post('/api/mkdir', { dir: ROOT, name: 'evil.' }));
22
+ await check('mkdir-ads-colon', 400, await post('/api/mkdir', { dir: ROOT, name: 'a:b' }));
23
+ await check('csrf-cross-site-form', 403, await post('/api/delete', 'path=x', { 'Content-Type': 'application/x-www-form-urlencoded', 'Sec-Fetch-Site': 'cross-site' }));
24
+ // round trip
25
+ await check('mkdir-ok', 200, await post('/api/mkdir', { dir: ROOT, name: '_wtest' }));
26
+ const up = await fetch(BASE + '/api/upload-file?dir=' + encodeURIComponent(ROOT + '\\_wtest') + '&name=up.txt', { method: 'PUT', body: 'hello upload' });
27
+ results.push({ name: 'upload-ok', expected: 200, got: up.status, ok: up.status === 200 });
28
+ const up2 = await fetch(BASE + '/api/upload-file?dir=' + encodeURIComponent(ROOT + '\\_wtest') + '&name=up.txt', { method: 'PUT', body: 'x' });
29
+ results.push({ name: 'upload-conflict-409', expected: 409, got: up2.status, ok: up2.status === 409 });
30
+ await check('rename-ok', 200, await post('/api/rename', { path: ROOT + '\\_wtest\\up.txt', newName: 'up2.txt' }));
31
+ await check('rename-conflict-409', 409, await post('/api/rename', { path: ROOT + '\\_wtest\\up2.txt', newName: 'up2.txt' }));
32
+ await check('delete-nonempty-no-recursive-409', 409, await post('/api/delete', { path: ROOT + '\\_wtest' }));
33
+ await check('delete-recursive-ok', 200, await post('/api/delete', { path: ROOT + '\\_wtest', recursive: true }));
34
+ const st = await fetch(BASE + '/api/stat/' + encodeURIComponent(ROOT));
35
+ results.push({ name: 'stat-ok', expected: 200, got: st.status, ok: st.status === 200 });
36
+ const st2 = await fetch(BASE + '/api/stat/' + encodeURIComponent('C:\\Windows'));
37
+ results.push({ name: 'stat-outside-403', expected: 403, got: st2.status, ok: st2.status === 403 });
38
+ let fail = 0;
39
+ for (const r of results) { if (!r.ok) fail++; console.log((r.ok ? 'PASS' : 'FAIL') + ' ' + r.name + ' expected=' + r.expected + ' got=' + r.got + (r.ok ? '' : ' body=' + (r.body || ''))); }
40
+ process.exit(fail ? 1 : 0);