ai-agent-session-center 2.3.3 → 2.3.5

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.
Files changed (27) hide show
  1. package/dist/client/assets/{AnalyticsView-DVDqblSH.js → AnalyticsView-qY6U6hm6.js} +1 -1
  2. package/dist/client/assets/{Charts.module-CQf7HQCb.js → Charts.module-DMrHdYE2.js} +1 -1
  3. package/dist/client/assets/{CyberdromeScene-w4bk6IXJ.js → CyberdromeScene-BAHIVPjO.js} +1 -1
  4. package/dist/client/assets/{HistoryView--XwKlCyd.js → HistoryView-DBPWSMVy.js} +1 -1
  5. package/dist/client/assets/{ProjectBrowserView-CTN5CmBa.js → ProjectBrowserView-BVsrZVHg.js} +1 -1
  6. package/dist/client/assets/{QueueView-Bj-e0m_U.js → QueueView-t313VSQZ.js} +1 -1
  7. package/dist/client/assets/{TimelineView-CoTsTAGd.js → TimelineView-COH0s3pN.js} +1 -1
  8. package/dist/client/assets/index-DcItPQrq.js +130 -0
  9. package/dist/client/assets/index-b03MoG49.css +1 -0
  10. package/dist/client/assets/{useQuery-5GNo2Ewt.js → useQuery-C6BUH11S.js} +1 -1
  11. package/dist/client/assets/{with-selector-DTnjuyBc.js → with-selector-Cw2vedS9.js} +1 -1
  12. package/dist/client/index.html +2 -2
  13. package/dist/client/screenshot-mobile-history.png +0 -0
  14. package/dist/client/screenshot-mobile-home.png +0 -0
  15. package/dist/client/screenshot-mobile-project.png +0 -0
  16. package/dist/client/screenshot-mobile-terminal.png +0 -0
  17. package/package.json +1 -1
  18. package/server/apiRouter.ts +145 -30
  19. package/server/approvalDetector.ts +2 -2
  20. package/server/index.ts +51 -18
  21. package/server/mqReader.ts +4 -4
  22. package/server/processMonitor.ts +12 -6
  23. package/server/sessionMatcher.ts +39 -0
  24. package/server/sessionStore.ts +4 -3
  25. package/server/wsManager.ts +51 -10
  26. package/dist/client/assets/index-Dgi6T0Nt.js +0 -128
  27. package/dist/client/assets/index-DqtLpLIs.css +0 -1
@@ -18,8 +18,8 @@ import { getStats as getHookStats, resetStats as resetHookStats } from './hookSt
18
18
  import * as db from './db.js';
19
19
  import { getMqStats } from './mqReader.js';
20
20
  import { execFile } from 'child_process';
21
- import { readFileSync, writeFileSync, readdirSync, existsSync, statSync, mkdirSync } from 'fs';
22
- import { join, dirname, extname, basename } from 'path';
21
+ import { createReadStream, readFileSync, writeFileSync, readdirSync, existsSync, statSync, mkdirSync } from 'fs';
22
+ import { join, dirname, extname, basename, resolve, sep } from 'path';
23
23
  import { homedir, userInfo } from 'os';
24
24
  import { fileURLToPath } from 'url';
25
25
  import { ALL_CLAUDE_HOOK_EVENTS, DENSITY_EVENTS, SESSION_STATUS, WS_TYPES } from './constants.js';
@@ -68,12 +68,12 @@ const terminalCreateSchema = z.object({
68
68
  host: noShellMeta(255).optional(),
69
69
  port: z.number().int().min(1).max(65535).optional(),
70
70
  username: usernameSchema.optional(),
71
- password: z.string().optional(),
71
+ password: z.string().max(256).optional(),
72
72
  privateKeyPath: z.string().optional(),
73
73
  authMethod: authMethodSchema,
74
74
  workingDir: noShellMetaWorkDir.optional(),
75
75
  command: noShellMeta(512).optional(),
76
- apiKey: z.string().optional(),
76
+ apiKey: z.string().max(512).optional(),
77
77
  tmuxSession: z.string().regex(/^[a-zA-Z0-9_.\-]+$/, 'must be alphanumeric, dash, underscore, or dot').optional(),
78
78
  useTmux: z.boolean().optional(),
79
79
  sessionTitle: z.string().max(500).optional(),
@@ -242,7 +242,8 @@ router.get('/hooks/status', (_req: Request, res: Response) => {
242
242
  res.json({ installed: installedEvents.length > 0, density, events: installedEvents });
243
243
  } catch (err: unknown) {
244
244
  const msg = err instanceof Error ? err.message : String(err);
245
- res.status(500).json({ error: msg });
245
+ log.error('api', `Hook status check failed: ${msg}`);
246
+ res.status(500).json({ error: 'Failed to check hook status' });
246
247
  }
247
248
  });
248
249
 
@@ -285,11 +286,18 @@ router.post('/hooks/uninstall', (_req: Request, res: Response) => {
285
286
  router.post('/sessions/:id/resume', async (req: Request, res: Response) => {
286
287
  const sessionId = str(req.params.id);
287
288
 
289
+ // Validate session ID format to prevent command injection (only allow UUID-like chars)
290
+ if (!/^[a-zA-Z0-9_-]+$/.test(sessionId)) {
291
+ res.status(400).json({ error: 'Invalid session ID format' });
292
+ return;
293
+ }
294
+
288
295
  const session = getSession(sessionId);
289
296
  if (!session) { res.status(404).json({ error: 'Session not found' }); return; }
290
297
 
291
- // Build resume command: try exact session ID first, fall back to --continue
292
- const resumeCmd = `claude --resume ${sessionId} || claude --continue`;
298
+ // Build resume command with single-quoted session ID to prevent shell interpretation
299
+ const safeId = sessionId.replace(/'/g, "'\\''");
300
+ const resumeCmd = `claude --resume '${safeId}' || claude --continue`;
293
301
 
294
302
  const allTerminals = getTerminals();
295
303
  const terminalExists = session.lastTerminalId && allTerminals.some(t => t.terminalId === session.lastTerminalId);
@@ -357,7 +365,7 @@ router.post('/sessions/:id/resume', async (req: Request, res: Response) => {
357
365
  } catch (err: unknown) {
358
366
  const msg = err instanceof Error ? err.message : String(err);
359
367
  log.error('api', `Resume with new terminal failed: ${msg}`);
360
- res.status(500).json({ error: `Failed to create new terminal: ${msg}` });
368
+ res.status(500).json({ error: 'Failed to create new terminal' });
361
369
  }
362
370
  });
363
371
 
@@ -390,7 +398,7 @@ router.post('/sessions/:id/reconnect-terminal', async (req: Request, res: Respon
390
398
  } catch (err: unknown) {
391
399
  const msg = err instanceof Error ? err.message : String(err);
392
400
  log.error('api', `Reconnect terminal failed: ${msg}`);
393
- res.status(500).json({ error: `Failed to reconnect terminal: ${msg}` });
401
+ res.status(500).json({ error: 'Failed to reconnect terminal' });
394
402
  }
395
403
  });
396
404
 
@@ -418,7 +426,8 @@ router.post('/sessions/:id/kill', (req: Request, res: Response) => {
418
426
  }, 3000);
419
427
  } catch (e: unknown) {
420
428
  const msg = e instanceof Error ? e.message : String(e);
421
- res.status(500).json({ error: `Failed to kill PID ${pid}: ${msg}` });
429
+ log.error('api', `Failed to kill PID ${pid}: ${msg}`);
430
+ res.status(500).json({ error: 'Failed to terminate process' });
422
431
  return;
423
432
  }
424
433
  }
@@ -536,7 +545,7 @@ router.post('/sessions/:id/summarize', async (req: Request, res: Response) => {
536
545
  activeSummarizeRequests--;
537
546
  const msg = err instanceof Error ? err.message : String(err);
538
547
  log.error('api', `Summarize error: ${msg}`);
539
- res.status(500).json({ success: false, error: `Summarize failed: ${msg}` });
548
+ res.status(500).json({ success: false, error: 'Summarize failed' });
540
549
  }
541
550
  });
542
551
 
@@ -569,7 +578,8 @@ router.post('/tmux-sessions', async (req: Request, res: Response) => {
569
578
  res.json({ sessions });
570
579
  } catch (err: unknown) {
571
580
  const msg = err instanceof Error ? err.message : String(err);
572
- res.status(500).json({ error: msg });
581
+ log.error('api', `Tmux session list failed: ${msg}`);
582
+ res.status(500).json({ error: 'Failed to list tmux sessions' });
573
583
  }
574
584
  });
575
585
 
@@ -623,7 +633,8 @@ router.post('/terminals', async (req: Request, res: Response) => {
623
633
  res.json({ ok: true, terminalId });
624
634
  } catch (err: unknown) {
625
635
  const msg = err instanceof Error ? err.message : String(err);
626
- res.status(500).json({ success: false, error: msg });
636
+ log.error('api', `Terminal creation failed: ${msg}`);
637
+ res.status(500).json({ success: false, error: 'Failed to create terminal' });
627
638
  }
628
639
  });
629
640
 
@@ -644,6 +655,10 @@ router.post('/terminals/:id/write', (req: Request, res: Response) => {
644
655
  res.status(400).json({ error: 'Missing or invalid "data" field' });
645
656
  return;
646
657
  }
658
+ if (data.length > 8192) {
659
+ res.status(400).json({ error: 'Data too large (max 8KB)' });
660
+ return;
661
+ }
647
662
  const terminals = getTerminals();
648
663
  const exists = terminals.some((t) => t.terminalId === terminalId);
649
664
  if (!exists) {
@@ -720,7 +735,7 @@ router.post('/teams/:teamId/members/:sessionId/terminal', async (req: Request, r
720
735
  } catch (err: unknown) {
721
736
  const msg = err instanceof Error ? err.message : String(err);
722
737
  log.error('api', `Failed to attach to tmux pane ${tmuxPaneId}: ${msg}`);
723
- res.status(500).json({ success: false, error: msg });
738
+ res.status(500).json({ success: false, error: 'Failed to attach to tmux pane' });
724
739
  }
725
740
  });
726
741
 
@@ -738,8 +753,8 @@ router.get('/db/sessions', (req: Request, res: Response) => {
738
753
  archived: (archived as string) || undefined,
739
754
  sortBy: ((sortBy as string) || 'started_at') as 'started_at' | 'last_activity_at' | 'project_name' | 'status',
740
755
  sortDir: ((sortDir as string) || 'desc') as 'asc' | 'desc',
741
- page: page ? Number(page) : 1,
742
- pageSize: pageSize ? Number(pageSize) : 50,
756
+ page: Math.max(1, Math.min(1000, page ? parseInt(String(page), 10) || 1 : 1)),
757
+ pageSize: Math.max(1, Math.min(200, pageSize ? parseInt(String(pageSize), 10) || 50 : 50)),
743
758
  });
744
759
  res.json(result);
745
760
  });
@@ -762,14 +777,19 @@ router.get('/db/projects', (_req: Request, res: Response) => {
762
777
  res.json(db.getDistinctProjects());
763
778
  });
764
779
 
765
- // Full-text search across prompts and responses
780
+ // Full-text search across prompts and responses (rate-limited: expensive)
766
781
  router.get('/db/search', (req: Request, res: Response) => {
782
+ const ip = req.ip || 'unknown';
783
+ if (isRateLimited(`db-search:${ip}`, 5)) {
784
+ res.status(429).json({ error: 'Rate limit exceeded' });
785
+ return;
786
+ }
767
787
  const { query, type, page, pageSize } = req.query;
768
788
  res.json(db.fullTextSearch({
769
789
  query: (query as string) || '',
770
790
  type: (type as string) || 'all',
771
- page: page ? Number(page) : 1,
772
- pageSize: pageSize ? Number(pageSize) : 50,
791
+ page: Math.max(1, Math.min(1000, page ? parseInt(String(page), 10) || 1 : 1)),
792
+ pageSize: Math.max(1, Math.min(200, pageSize ? parseInt(String(pageSize), 10) || 50 : 50)),
773
793
  }));
774
794
  });
775
795
 
@@ -805,7 +825,12 @@ router.get('/db/analytics/projects', (_req: Request, res: Response) => {
805
825
  res.json(db.getActiveProjects());
806
826
  });
807
827
 
808
- router.get('/db/analytics/heatmap', (_req: Request, res: Response) => {
828
+ router.get('/db/analytics/heatmap', (req: Request, res: Response) => {
829
+ const ip = req.ip || 'unknown';
830
+ if (isRateLimited(`heatmap:${ip}`, 2)) {
831
+ res.status(429).json({ error: 'Rate limit exceeded' });
832
+ return;
833
+ }
809
834
  res.json(db.getHeatmap());
810
835
  });
811
836
 
@@ -902,6 +927,10 @@ const TEXT_NAMES = new Set([
902
927
  ]);
903
928
 
904
929
  const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2 MB
930
+ const MAX_STREAMABLE_SIZE = 100 * 1024 * 1024; // 100 MB (for PDF/image streaming)
931
+
932
+ /** Extensions that can be streamed directly to the browser (not read into JSON). */
933
+ const STREAMABLE_EXTENSIONS = new Set(['.pdf']);
905
934
 
906
935
  /** Directories to skip when listing. */
907
936
  const HIDDEN_DIRS = new Set([
@@ -915,13 +944,30 @@ function isTextFile(name: string): boolean {
915
944
  return ext === '' || TEXT_EXTENSIONS.has(ext);
916
945
  }
917
946
 
947
+ /** Validate that root is a known/safe project path — blocks dangerous roots like /. */
948
+ function isAllowedProjectRoot(root: string): boolean {
949
+ if (!root) return false;
950
+ // Must be an absolute path
951
+ if (!root.startsWith('/') && !/^[A-Z]:\\/.test(root)) return false;
952
+ // Block shallow roots: /, /etc, /home, /Users, etc.
953
+ const segments = root.split('/').filter(Boolean);
954
+ if (segments.length < 2) return false;
955
+ // Block specific dangerous roots
956
+ const blocked = ['/', '/etc', '/root', '/tmp', '/var', '/bin', '/sbin', '/usr', '/dev', '/proc', '/sys'];
957
+ if (blocked.includes(root)) return false;
958
+ return true;
959
+ }
960
+
918
961
  /** Resolve and validate a requested path is within the project root. */
919
962
  function resolveProjectPath(projectRoot: string, relPath: string): string | null {
920
- // Prevent traversal: normalise, then ensure it's within root
921
- const resolved = join(projectRoot, relPath);
922
- const normalised = join(resolved); // removes .., . etc
923
- if (!normalised.startsWith(projectRoot)) return null;
924
- return normalised;
963
+ // Prevent traversal: resolve to absolute, then ensure it's within root
964
+ // Strip leading '/' so path.resolve treats it as relative to projectRoot
965
+ const cleaned = relPath.replace(/^\/+/, '');
966
+ const rootWithSep = projectRoot.endsWith(sep) ? projectRoot : projectRoot + sep;
967
+ const resolved = resolve(projectRoot, cleaned);
968
+ // Must either equal the root exactly or be underneath it (with separator check)
969
+ if (resolved !== projectRoot && !resolved.startsWith(rootWithSep)) return null;
970
+ return resolved;
925
971
  }
926
972
 
927
973
  const filePathSchema = z.object({
@@ -932,6 +978,7 @@ const filePathSchema = z.object({
932
978
  router.get('/files/list', (req: Request, res: Response) => {
933
979
  const root = str(req.query.root);
934
980
  if (!root) { res.status(400).json({ error: 'root query param required' }); return; }
981
+ if (!isAllowedProjectRoot(root)) { res.status(400).json({ error: 'Invalid project root' }); return; }
935
982
 
936
983
  const body = filePathSchema.safeParse({ path: str(req.query.path) || '/' });
937
984
  if (!body.success) { res.status(400).json({ error: 'Invalid path' }); return; }
@@ -976,7 +1023,8 @@ router.get('/files/list', (req: Request, res: Response) => {
976
1023
  res.json({ path: relPath, items });
977
1024
  } catch (err: unknown) {
978
1025
  const msg = err instanceof Error ? err.message : String(err);
979
- res.status(500).json({ error: msg });
1026
+ log.error('api', `File list failed: ${msg}`);
1027
+ res.status(500).json({ error: 'Failed to list directory' });
980
1028
  }
981
1029
  });
982
1030
 
@@ -984,6 +1032,7 @@ router.get('/files/list', (req: Request, res: Response) => {
984
1032
  router.get('/files/read', (req: Request, res: Response) => {
985
1033
  const root = str(req.query.root);
986
1034
  if (!root) { res.status(400).json({ error: 'root query param required' }); return; }
1035
+ if (!isAllowedProjectRoot(root)) { res.status(400).json({ error: 'Invalid project root' }); return; }
987
1036
 
988
1037
  const body = filePathSchema.safeParse({ path: str(req.query.path) });
989
1038
  if (!body.success) { res.status(400).json({ error: 'Invalid path' }); return; }
@@ -997,9 +1046,21 @@ router.get('/files/read', (req: Request, res: Response) => {
997
1046
 
998
1047
  const stat = statSync(fullPath);
999
1048
  if (stat.isDirectory()) { res.status(400).json({ error: 'Path is a directory, not a file' }); return; }
1049
+ const name = basename(fullPath);
1050
+ const fileExt = extname(name).toLowerCase();
1051
+
1052
+ // PDFs and other streamable files: return metadata with streamable flag (no size limit)
1053
+ if (STREAMABLE_EXTENSIONS.has(fileExt)) {
1054
+ if (stat.size > MAX_STREAMABLE_SIZE) {
1055
+ res.status(413).json({ error: `File too large (${(stat.size / 1024 / 1024).toFixed(1)} MB, max 100 MB)` });
1056
+ return;
1057
+ }
1058
+ res.json({ path: relPath, streamable: true, ext: fileExt.replace('.', ''), size: stat.size, name });
1059
+ return;
1060
+ }
1061
+
1000
1062
  if (stat.size > MAX_FILE_SIZE) { res.status(413).json({ error: `File too large (${(stat.size / 1024 / 1024).toFixed(1)} MB, max 2 MB)` }); return; }
1001
1063
 
1002
- const name = basename(fullPath);
1003
1064
  if (!isTextFile(name)) {
1004
1065
  res.json({ path: relPath, binary: true, size: stat.size, name });
1005
1066
  return;
@@ -1010,7 +1071,51 @@ router.get('/files/read', (req: Request, res: Response) => {
1010
1071
  res.json({ path: relPath, content, ext, size: stat.size, name });
1011
1072
  } catch (err: unknown) {
1012
1073
  const msg = err instanceof Error ? err.message : String(err);
1013
- res.status(500).json({ error: msg });
1074
+ log.error('api', `File read failed: ${msg}`);
1075
+ res.status(500).json({ error: 'Failed to read file' });
1076
+ }
1077
+ });
1078
+
1079
+ /** GET /api/files/stream?root=<projectPath>&path=<relative> — stream a file (PDF, images) */
1080
+ router.get('/files/stream', (req: Request, res: Response) => {
1081
+ const root = str(req.query.root);
1082
+ if (!root) { res.status(400).json({ error: 'root query param required' }); return; }
1083
+ if (!isAllowedProjectRoot(root)) { res.status(400).json({ error: 'Invalid project root' }); return; }
1084
+
1085
+ const body = filePathSchema.safeParse({ path: str(req.query.path) });
1086
+ if (!body.success) { res.status(400).json({ error: 'Invalid path' }); return; }
1087
+
1088
+ const relPath = body.data.path;
1089
+ const fullPath = resolveProjectPath(root, relPath);
1090
+ if (!fullPath) { res.status(400).json({ error: 'Path outside project root' }); return; }
1091
+
1092
+ try {
1093
+ if (!existsSync(fullPath)) { res.status(404).json({ error: 'File not found' }); return; }
1094
+
1095
+ const stat = statSync(fullPath);
1096
+ if (stat.isDirectory()) { res.status(400).json({ error: 'Path is a directory' }); return; }
1097
+ if (stat.size > MAX_STREAMABLE_SIZE) { res.status(413).json({ error: 'File too large' }); return; }
1098
+
1099
+ const fileExt = extname(fullPath).toLowerCase();
1100
+ if (!STREAMABLE_EXTENSIONS.has(fileExt)) { res.status(400).json({ error: 'File type not streamable' }); return; }
1101
+
1102
+ const mimeMap: Record<string, string> = { '.pdf': 'application/pdf' };
1103
+ const contentType = mimeMap[fileExt] || 'application/octet-stream';
1104
+
1105
+ res.setHeader('Content-Type', contentType);
1106
+ res.setHeader('Content-Length', stat.size);
1107
+ const safeName = basename(fullPath).replace(/[^a-zA-Z0-9._-]/g, '_');
1108
+ res.setHeader('Content-Disposition', `inline; filename="${safeName}"`);
1109
+
1110
+ const stream = createReadStream(fullPath);
1111
+ stream.pipe(res);
1112
+ stream.on('error', () => {
1113
+ if (!res.headersSent) res.status(500).json({ error: 'Stream error' });
1114
+ });
1115
+ } catch (err: unknown) {
1116
+ const msg = err instanceof Error ? err.message : String(err);
1117
+ log.error('api', `File stream failed: ${msg}`);
1118
+ if (!res.headersSent) res.status(500).json({ error: 'Failed to stream file' });
1014
1119
  }
1015
1120
  });
1016
1121
 
@@ -1026,6 +1131,7 @@ router.post('/files/write', (req: Request, res: Response) => {
1026
1131
  if (!parsed.success) { res.status(400).json({ error: 'Invalid request body' }); return; }
1027
1132
 
1028
1133
  const { root, path: relPath, content } = parsed.data;
1134
+ if (!isAllowedProjectRoot(root)) { res.status(400).json({ error: 'Invalid project root' }); return; }
1029
1135
  const fullPath = resolveProjectPath(root, relPath);
1030
1136
  if (!fullPath) { res.status(400).json({ error: 'Path outside project root' }); return; }
1031
1137
 
@@ -1040,7 +1146,8 @@ router.post('/files/write', (req: Request, res: Response) => {
1040
1146
  res.json({ ok: true, path: relPath, size: stat.size });
1041
1147
  } catch (err: unknown) {
1042
1148
  const msg = err instanceof Error ? err.message : String(err);
1043
- res.status(500).json({ error: msg });
1149
+ log.error('api', `File write failed: ${msg}`);
1150
+ res.status(500).json({ error: 'Failed to write file' });
1044
1151
  }
1045
1152
  });
1046
1153
 
@@ -1055,6 +1162,7 @@ router.post('/files/mkdir', (req: Request, res: Response) => {
1055
1162
  if (!parsed.success) { res.status(400).json({ error: 'Invalid request body' }); return; }
1056
1163
 
1057
1164
  const { root, path: relPath } = parsed.data;
1165
+ if (!isAllowedProjectRoot(root)) { res.status(400).json({ error: 'Invalid project root' }); return; }
1058
1166
  const fullPath = resolveProjectPath(root, relPath);
1059
1167
  if (!fullPath) { res.status(400).json({ error: 'Path outside project root' }); return; }
1060
1168
 
@@ -1064,15 +1172,22 @@ router.post('/files/mkdir', (req: Request, res: Response) => {
1064
1172
  res.json({ ok: true, path: relPath });
1065
1173
  } catch (err: unknown) {
1066
1174
  const msg = err instanceof Error ? err.message : String(err);
1067
- res.status(500).json({ error: msg });
1175
+ log.error('api', `Mkdir failed: ${msg}`);
1176
+ res.status(500).json({ error: 'Failed to create directory' });
1068
1177
  }
1069
1178
  });
1070
1179
 
1071
1180
  /** GET /api/files/search?root=<projectPath>&q=<query> — fuzzy search file names */
1072
1181
  router.get('/files/search', (req: Request, res: Response) => {
1182
+ const ip = req.ip || 'unknown';
1183
+ if (isRateLimited(`file-search:${ip}`, 5)) {
1184
+ res.status(429).json({ error: 'Rate limit exceeded' });
1185
+ return;
1186
+ }
1073
1187
  const root = str(req.query.root);
1074
1188
  const query = str(req.query.q).toLowerCase();
1075
1189
  if (!root) { res.status(400).json({ error: 'root query param required' }); return; }
1190
+ if (!isAllowedProjectRoot(root)) { res.status(400).json({ error: 'Invalid project root' }); return; }
1076
1191
  if (!query) { res.json({ results: [] }); return; }
1077
1192
 
1078
1193
  const results: Array<{ path: string; name: string; type: 'dir' | 'file' }> = [];
@@ -4,7 +4,7 @@
4
4
  * If PostToolUse does not arrive within the timeout, the session transitions to approval/input status.
5
5
  * PermissionRequest events provide a direct signal that bypasses the timeout heuristic.
6
6
  */
7
- import { execSync } from 'child_process';
7
+ import { execFileSync } from 'child_process';
8
8
  import { getToolTimeout, getToolCategory, getWaitingStatus, getWaitingLabel } from './config.js';
9
9
  import { SESSION_STATUS, ANIMATION_STATE } from './constants.js';
10
10
  import log from './logger.js';
@@ -28,7 +28,7 @@ export function hasChildProcesses(pid: number): boolean {
28
28
  const validPid = validatePid(pid);
29
29
  if (!validPid) return false;
30
30
  try {
31
- const out = execSync(`pgrep -P ${validPid} 2>/dev/null`, { encoding: 'utf-8', timeout: 2000 });
31
+ const out = execFileSync('pgrep', ['-P', String(validPid)], { encoding: 'utf-8', timeout: 2000 });
32
32
  return out.trim().length > 0;
33
33
  } catch (e: unknown) {
34
34
  // #37: Return true on error as safer default — assume command is still running
package/server/index.ts CHANGED
@@ -5,7 +5,7 @@ import { createServer } from 'http';
5
5
  import { WebSocketServer } from 'ws';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { dirname, join } from 'path';
8
- import { execSync } from 'child_process';
8
+ import { execFile } from 'child_process';
9
9
  import hookRouter from './hookRouter.js';
10
10
  import { handleConnection, stopHeartbeat } from './wsManager.js';
11
11
  import { getAllSessions, loadSnapshot, saveSnapshot, startPeriodicSave, stopPeriodicSave } from './sessionStore.js';
@@ -31,21 +31,25 @@ const noOpen = args.includes('--no-open');
31
31
 
32
32
  const app = express();
33
33
  const server = createServer(app);
34
- const wss = new WebSocketServer({ server });
34
+ const wss = new WebSocketServer({ server, maxPayload: 64 * 1024 }); // 64KB max WS message
35
35
 
36
- app.use(express.json({ limit: '10mb' }));
36
+ app.use(express.json({ limit: '2mb' }));
37
37
 
38
38
  // -- Security headers --
39
- app.use((_req, res, next) => {
39
+ app.use((req, res, next) => {
40
40
  res.setHeader('X-Content-Type-Options', 'nosniff');
41
41
  res.setHeader('X-Frame-Options', 'DENY');
42
42
  res.setHeader('X-XSS-Protection', '1; mode=block');
43
43
  res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
44
44
  res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
45
- // CSP: allow self + inline styles (needed for xterm/three.js) + WebSocket
45
+ // HSTS when behind TLS terminating proxy
46
+ if (req.secure || req.headers['x-forwarded-proto'] === 'https') {
47
+ res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
48
+ }
49
+ // CSP: restrict connect-src to self (covers ws:/wss: same-origin)
46
50
  res.setHeader(
47
51
  'Content-Security-Policy',
48
- "default-src 'self'; script-src 'self' blob:; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss: https://cdn.jsdelivr.net; img-src 'self' data: blob:; font-src 'self' data: https://cdn.jsdelivr.net; worker-src 'self' blob:",
52
+ "default-src 'self'; script-src 'self' blob:; style-src 'self' 'unsafe-inline'; connect-src 'self' https://cdn.jsdelivr.net; img-src 'self' data: blob:; font-src 'self' data: https://cdn.jsdelivr.net; worker-src 'self' blob:; frame-src 'self' blob:",
49
53
  );
50
54
  next();
51
55
  });
@@ -82,14 +86,18 @@ app.post('/api/auth/login', (req, res) => {
82
86
  }
83
87
  if (!verifyPassword(password, config.passwordHash ?? '')) {
84
88
  recordLoginAttempt(ip);
89
+ log.warn('auth', `Failed login attempt from IP ${ip}`);
85
90
  res.status(401).json({ error: 'Wrong password' });
86
91
  return;
87
92
  }
88
93
 
89
94
  clearLoginAttempts(ip);
95
+ log.warn('auth', `Successful login from IP ${ip}`);
90
96
  const token = createToken();
91
- res.setHeader('Set-Cookie', `auth_token=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${TOKEN_TTL_SECONDS}`);
92
- res.json({ success: true, token, expiresIn: TOKEN_TTL_SECONDS });
97
+ const isSecure = req.secure || req.headers['x-forwarded-proto'] === 'https';
98
+ const secureSuffix = isSecure ? '; Secure' : '';
99
+ res.setHeader('Set-Cookie', `auth_token=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${TOKEN_TTL_SECONDS}${secureSuffix}`);
100
+ res.json({ success: true, expiresIn: TOKEN_TTL_SECONDS });
93
101
  });
94
102
 
95
103
  app.post('/api/auth/refresh', (req, res) => {
@@ -103,8 +111,10 @@ app.post('/api/auth/refresh', (req, res) => {
103
111
  res.status(401).json({ error: 'Token expired or invalid — please login again' });
104
112
  return;
105
113
  }
106
- res.setHeader('Set-Cookie', `auth_token=${newToken}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${TOKEN_TTL_SECONDS}`);
107
- res.json({ success: true, token: newToken, expiresIn: TOKEN_TTL_SECONDS });
114
+ const isSecure = req.secure || req.headers['x-forwarded-proto'] === 'https';
115
+ const secureSuffix = isSecure ? '; Secure' : '';
116
+ res.setHeader('Set-Cookie', `auth_token=${newToken}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${TOKEN_TTL_SECONDS}${secureSuffix}`);
117
+ res.json({ success: true, expiresIn: TOKEN_TTL_SECONDS });
108
118
  });
109
119
 
110
120
  app.post('/api/auth/logout', (req, res) => {
@@ -128,12 +138,13 @@ app.get('/api/sessions', authMiddleware, (_req, res) => {
128
138
  res.json(getAllSessions());
129
139
  });
130
140
 
131
- // Request logging middleware (debug mode only)
141
+ // Request logging middleware (debug mode only) — strip tokens from logged URLs
132
142
  if (log.isDebug) {
133
143
  app.use((req, res, next) => {
134
144
  const start = Date.now();
135
145
  res.on('finish', () => {
136
- log.debug('http', `${req.method} ${req.originalUrl} ${res.statusCode} ${Date.now() - start}ms`);
146
+ const sanitizedUrl = req.originalUrl.replace(/token=[^&]+/, 'token=***');
147
+ log.debug('http', `${req.method} ${sanitizedUrl} ${res.statusCode} ${Date.now() - start}ms`);
137
148
  });
138
149
  next();
139
150
  });
@@ -144,10 +155,29 @@ app.get('/{*splat}', (_req, res) => {
144
155
  res.sendFile(join(clientDir, 'index.html'));
145
156
  });
146
157
 
147
- // -- WebSocket with auth validation --
158
+ // -- WebSocket with origin validation + auth --
148
159
  wss.on('connection', (ws, req) => {
160
+ // Origin validation: only allow same-host connections to prevent CSWSH
161
+ const origin = req.headers.origin;
162
+ const host = req.headers.host;
163
+ if (origin && host) {
164
+ try {
165
+ const originHost = new URL(origin).host;
166
+ if (originHost !== host) {
167
+ log.warn('ws', `Rejected WebSocket from foreign origin: ${origin} (expected host: ${host})`);
168
+ ws.close(4003, 'Forbidden: origin mismatch');
169
+ return;
170
+ }
171
+ } catch {
172
+ log.warn('ws', `Rejected WebSocket with invalid origin: ${origin}`);
173
+ ws.close(4003, 'Forbidden: invalid origin');
174
+ return;
175
+ }
176
+ }
177
+
149
178
  if (isPasswordEnabled()) {
150
- const token = extractToken(req);
179
+ // Prefer cookie-based auth (avoids token in URL query string)
180
+ const token = parseCookieToken(req.headers.cookie) ?? extractToken(req);
151
181
  if (!validateToken(token)) {
152
182
  log.debug('auth', 'Rejected unauthorized WebSocket connection');
153
183
  ws.close(4001, 'Unauthorized');
@@ -170,7 +200,7 @@ function openBrowser(url: string): void {
170
200
  const cmd = process.platform === 'darwin' ? 'open'
171
201
  : process.platform === 'win32' ? 'start'
172
202
  : 'xdg-open';
173
- execSync(`${cmd} "${url}"`, { stdio: 'ignore', timeout: 5000 });
203
+ execFile(cmd, [url], { timeout: 5000 }, () => { /* ignore errors */ });
174
204
  } catch {
175
205
  // Browser open failed -- not critical
176
206
  }
@@ -207,11 +237,14 @@ function onReady(): void {
207
237
  if (isPasswordEnabled()) {
208
238
  log.info('server', 'Password protection ENABLED -- login required (1h token TTL)');
209
239
  } else {
210
- // Warn if binding to all interfaces without password
240
+ // Warn/block if binding to all interfaces without password
211
241
  const bindAddr = (server.address() as { address?: string } | null)?.address;
212
242
  if (bindAddr === '0.0.0.0' || bindAddr === '::') {
213
- log.warn('server', '⚠ WARNING: Server is publicly accessible WITHOUT a password!');
214
- log.warn('server', ' Run `npm run setup` to set a password before exposing to the internet.');
243
+ log.error('server', '------------------------------------------------------------');
244
+ log.error('server', 'SECURITY: Server is publicly accessible WITHOUT a password!');
245
+ log.error('server', 'This is DANGEROUS. Anyone on the network has full access.');
246
+ log.error('server', 'Run `npm run setup` to set a password.');
247
+ log.error('server', '------------------------------------------------------------');
215
248
  }
216
249
  }
217
250
  if (log.isDebug) {
@@ -60,12 +60,12 @@ export function startMqReader(options?: MqReaderOptions): void {
60
60
  running = true;
61
61
  mqStats.startedAt = Date.now();
62
62
 
63
- // Ensure queue directory exists
64
- mkdirSync(QUEUE_DIR, { recursive: true });
63
+ // Ensure queue directory exists with restrictive permissions (user-only)
64
+ mkdirSync(QUEUE_DIR, { recursive: true, mode: 0o700 });
65
65
 
66
- // Create queue file if it doesn't exist (but don't truncate existing)
66
+ // Create queue file if it doesn't exist with restrictive permissions
67
67
  if (!existsSync(QUEUE_FILE)) {
68
- writeFileSync(QUEUE_FILE, '');
68
+ writeFileSync(QUEUE_FILE, '', { mode: 0o600 });
69
69
  }
70
70
 
71
71
  // Resume from snapshot offset or start from current EOF
@@ -4,7 +4,7 @@
4
4
  * Auto-ends sessions whose processes have died (e.g., terminal closed abruptly).
5
5
  * Also provides findClaudeProcess() with cached PID, pgrep, and lsof fallbacks.
6
6
  */
7
- import { execSync } from 'child_process';
7
+ import { execSync, execFileSync } from 'child_process';
8
8
  import { getTerminalForSession } from './sshManager.js';
9
9
  import { SESSION_STATUS, ANIMATION_STATE, WS_TYPES } from './constants.js';
10
10
  import { PROCESS_CHECK_INTERVAL } from './config.js';
@@ -167,7 +167,12 @@ export function findClaudeProcess(
167
167
  if (pid) cachePid(pid, sessionId, session, pidToSession);
168
168
  return pid || null;
169
169
  } else {
170
- const pidsOut = execSync(`pgrep -f claude 2>/dev/null || true`, { encoding: 'utf-8', timeout: 5000 });
170
+ let pidsOut: string;
171
+ try {
172
+ pidsOut = execFileSync('pgrep', ['-f', 'claude'], { encoding: 'utf-8', timeout: 5000 });
173
+ } catch {
174
+ pidsOut = ''; // pgrep exits non-zero when no matches
175
+ }
171
176
  const pids = pidsOut.trim().split('\n')
172
177
  .map(p => validatePid(p.trim()))
173
178
  .filter((p): p is number => p !== null && p !== myPid);
@@ -185,10 +190,11 @@ export function findClaudeProcess(
185
190
  try {
186
191
  let cwd: string;
187
192
  if (process.platform === 'darwin') {
188
- const out = execSync(`lsof -a -d cwd -Fn -p ${pid} 2>/dev/null | grep '^n'`, { encoding: 'utf-8', timeout: 3000 });
189
- cwd = out.trim().replace(/^n/, '');
193
+ const out = execFileSync('lsof', ['-a', '-d', 'cwd', '-Fn', '-p', String(pid)], { encoding: 'utf-8', timeout: 3000 });
194
+ const nLine = out.split('\n').find(l => l.startsWith('n'));
195
+ cwd = nLine ? nLine.slice(1).trim() : '';
190
196
  } else {
191
- cwd = execSync(`readlink /proc/${pid}/cwd 2>/dev/null`, { encoding: 'utf-8', timeout: 3000 }).trim();
197
+ cwd = execFileSync('readlink', [`/proc/${pid}/cwd`], { encoding: 'utf-8', timeout: 3000 }).trim();
192
198
  }
193
199
  const match = cwd === projectPath;
194
200
  log.debug('findProcess', `pid=${pid} cwd="${cwd}" ${match ? 'MATCH' : 'no match'}`);
@@ -207,7 +213,7 @@ export function findClaudeProcess(
207
213
  for (const pid of pids) {
208
214
  if (claimedPids.has(pid)) continue;
209
215
  try {
210
- const tty = execSync(`ps -o tty= -p ${pid}`, { encoding: 'utf-8', timeout: 3000 }).trim();
216
+ const tty = execFileSync('ps', ['-o', 'tty=', '-p', String(pid)], { encoding: 'utf-8', timeout: 3000 }).trim();
211
217
  log.debug('findProcess', `fallback pid=${pid} tty=${tty || 'NONE'}`);
212
218
  if (tty && tty !== '??' && tty !== '?') {
213
219
  log.debug('findProcess', `FALLBACK returning pid=${pid} (first unclaimed with tty)`);