ai-agent-session-center 2.3.4 → 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.
@@ -19,7 +19,7 @@ import * as db from './db.js';
19
19
  import { getMqStats } from './mqReader.js';
20
20
  import { execFile } from 'child_process';
21
21
  import { createReadStream, readFileSync, writeFileSync, readdirSync, existsSync, statSync, mkdirSync } from 'fs';
22
- import { join, dirname, extname, basename } from 'path';
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
 
@@ -919,13 +944,30 @@ function isTextFile(name: string): boolean {
919
944
  return ext === '' || TEXT_EXTENSIONS.has(ext);
920
945
  }
921
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
+
922
961
  /** Resolve and validate a requested path is within the project root. */
923
962
  function resolveProjectPath(projectRoot: string, relPath: string): string | null {
924
- // Prevent traversal: normalise, then ensure it's within root
925
- const resolved = join(projectRoot, relPath);
926
- const normalised = join(resolved); // removes .., . etc
927
- if (!normalised.startsWith(projectRoot)) return null;
928
- 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;
929
971
  }
930
972
 
931
973
  const filePathSchema = z.object({
@@ -936,6 +978,7 @@ const filePathSchema = z.object({
936
978
  router.get('/files/list', (req: Request, res: Response) => {
937
979
  const root = str(req.query.root);
938
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; }
939
982
 
940
983
  const body = filePathSchema.safeParse({ path: str(req.query.path) || '/' });
941
984
  if (!body.success) { res.status(400).json({ error: 'Invalid path' }); return; }
@@ -980,7 +1023,8 @@ router.get('/files/list', (req: Request, res: Response) => {
980
1023
  res.json({ path: relPath, items });
981
1024
  } catch (err: unknown) {
982
1025
  const msg = err instanceof Error ? err.message : String(err);
983
- res.status(500).json({ error: msg });
1026
+ log.error('api', `File list failed: ${msg}`);
1027
+ res.status(500).json({ error: 'Failed to list directory' });
984
1028
  }
985
1029
  });
986
1030
 
@@ -988,6 +1032,7 @@ router.get('/files/list', (req: Request, res: Response) => {
988
1032
  router.get('/files/read', (req: Request, res: Response) => {
989
1033
  const root = str(req.query.root);
990
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; }
991
1036
 
992
1037
  const body = filePathSchema.safeParse({ path: str(req.query.path) });
993
1038
  if (!body.success) { res.status(400).json({ error: 'Invalid path' }); return; }
@@ -1026,7 +1071,8 @@ router.get('/files/read', (req: Request, res: Response) => {
1026
1071
  res.json({ path: relPath, content, ext, size: stat.size, name });
1027
1072
  } catch (err: unknown) {
1028
1073
  const msg = err instanceof Error ? err.message : String(err);
1029
- res.status(500).json({ error: msg });
1074
+ log.error('api', `File read failed: ${msg}`);
1075
+ res.status(500).json({ error: 'Failed to read file' });
1030
1076
  }
1031
1077
  });
1032
1078
 
@@ -1034,6 +1080,7 @@ router.get('/files/read', (req: Request, res: Response) => {
1034
1080
  router.get('/files/stream', (req: Request, res: Response) => {
1035
1081
  const root = str(req.query.root);
1036
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; }
1037
1084
 
1038
1085
  const body = filePathSchema.safeParse({ path: str(req.query.path) });
1039
1086
  if (!body.success) { res.status(400).json({ error: 'Invalid path' }); return; }
@@ -1057,7 +1104,8 @@ router.get('/files/stream', (req: Request, res: Response) => {
1057
1104
 
1058
1105
  res.setHeader('Content-Type', contentType);
1059
1106
  res.setHeader('Content-Length', stat.size);
1060
- res.setHeader('Content-Disposition', `inline; filename="${basename(fullPath)}"`);
1107
+ const safeName = basename(fullPath).replace(/[^a-zA-Z0-9._-]/g, '_');
1108
+ res.setHeader('Content-Disposition', `inline; filename="${safeName}"`);
1061
1109
 
1062
1110
  const stream = createReadStream(fullPath);
1063
1111
  stream.pipe(res);
@@ -1066,7 +1114,8 @@ router.get('/files/stream', (req: Request, res: Response) => {
1066
1114
  });
1067
1115
  } catch (err: unknown) {
1068
1116
  const msg = err instanceof Error ? err.message : String(err);
1069
- if (!res.headersSent) res.status(500).json({ error: msg });
1117
+ log.error('api', `File stream failed: ${msg}`);
1118
+ if (!res.headersSent) res.status(500).json({ error: 'Failed to stream file' });
1070
1119
  }
1071
1120
  });
1072
1121
 
@@ -1082,6 +1131,7 @@ router.post('/files/write', (req: Request, res: Response) => {
1082
1131
  if (!parsed.success) { res.status(400).json({ error: 'Invalid request body' }); return; }
1083
1132
 
1084
1133
  const { root, path: relPath, content } = parsed.data;
1134
+ if (!isAllowedProjectRoot(root)) { res.status(400).json({ error: 'Invalid project root' }); return; }
1085
1135
  const fullPath = resolveProjectPath(root, relPath);
1086
1136
  if (!fullPath) { res.status(400).json({ error: 'Path outside project root' }); return; }
1087
1137
 
@@ -1096,7 +1146,8 @@ router.post('/files/write', (req: Request, res: Response) => {
1096
1146
  res.json({ ok: true, path: relPath, size: stat.size });
1097
1147
  } catch (err: unknown) {
1098
1148
  const msg = err instanceof Error ? err.message : String(err);
1099
- res.status(500).json({ error: msg });
1149
+ log.error('api', `File write failed: ${msg}`);
1150
+ res.status(500).json({ error: 'Failed to write file' });
1100
1151
  }
1101
1152
  });
1102
1153
 
@@ -1111,6 +1162,7 @@ router.post('/files/mkdir', (req: Request, res: Response) => {
1111
1162
  if (!parsed.success) { res.status(400).json({ error: 'Invalid request body' }); return; }
1112
1163
 
1113
1164
  const { root, path: relPath } = parsed.data;
1165
+ if (!isAllowedProjectRoot(root)) { res.status(400).json({ error: 'Invalid project root' }); return; }
1114
1166
  const fullPath = resolveProjectPath(root, relPath);
1115
1167
  if (!fullPath) { res.status(400).json({ error: 'Path outside project root' }); return; }
1116
1168
 
@@ -1120,15 +1172,22 @@ router.post('/files/mkdir', (req: Request, res: Response) => {
1120
1172
  res.json({ ok: true, path: relPath });
1121
1173
  } catch (err: unknown) {
1122
1174
  const msg = err instanceof Error ? err.message : String(err);
1123
- res.status(500).json({ error: msg });
1175
+ log.error('api', `Mkdir failed: ${msg}`);
1176
+ res.status(500).json({ error: 'Failed to create directory' });
1124
1177
  }
1125
1178
  });
1126
1179
 
1127
1180
  /** GET /api/files/search?root=<projectPath>&q=<query> — fuzzy search file names */
1128
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
+ }
1129
1187
  const root = str(req.query.root);
1130
1188
  const query = str(req.query.q).toLowerCase();
1131
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; }
1132
1191
  if (!query) { res.json({ results: [] }); return; }
1133
1192
 
1134
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)`);
@@ -50,6 +50,29 @@ export function reKeyResumedSession(
50
50
  oldSession.cachedPid = null;
51
51
  }
52
52
 
53
+ // Archive the old session data into previousSessions before resetting.
54
+ // Check dedup: resumeSession() already archives before calling this function,
55
+ // so only archive if the last entry doesn't match the old session ID.
56
+ const hasData = oldSession.promptHistory.length > 0 || oldSession.toolLog?.length > 0 || oldSession.events?.length > 0;
57
+ if (hasData) {
58
+ const lastPrev = oldSession.previousSessions?.[oldSession.previousSessions.length - 1];
59
+ if (!lastPrev || lastPrev.sessionId !== oldSessionId) {
60
+ if (!oldSession.previousSessions) oldSession.previousSessions = [];
61
+ oldSession.previousSessions.push({
62
+ sessionId: oldSessionId,
63
+ startedAt: oldSession.startedAt,
64
+ endedAt: oldSession.endedAt,
65
+ promptHistory: [...oldSession.promptHistory],
66
+ toolLog: [...(oldSession.toolLog || [])],
67
+ responseLog: [...(oldSession.responseLog || [])],
68
+ events: [...oldSession.events],
69
+ toolUsage: { ...oldSession.toolUsage },
70
+ totalToolCalls: oldSession.totalToolCalls,
71
+ });
72
+ if (oldSession.previousSessions.length > 5) oldSession.previousSessions.shift();
73
+ }
74
+ }
75
+
53
76
  oldSession.replacesId = oldSessionId;
54
77
  oldSession.sessionId = newSessionId;
55
78
  oldSession.status = SESSION_STATUS.IDLE;
@@ -294,6 +317,22 @@ export function matchSession(
294
317
  }
295
318
  }
296
319
 
320
+ // Priority 1.5: Match by cached PID — when Claude resumes with a new session_id
321
+ // but the same process (e.g., `claude --resume` creates a new session internally),
322
+ // link back to the same SSH terminal session instead of creating a duplicate card.
323
+ if (!session && hookData.claude_pid && hook_event_name === EVENT_TYPES.SESSION_START) {
324
+ const pid = Number(hookData.claude_pid);
325
+ const existingSessionId = pidToSession.get(pid);
326
+ if (existingSessionId && existingSessionId !== session_id) {
327
+ const existingSession = sessions.get(existingSessionId);
328
+ if (existingSession && existingSession.terminalId) {
329
+ session = reKeyResumedSession(sessions, existingSession, session_id, existingSessionId, pidToSession);
330
+ consumePendingLink(existingSession.projectPath || '');
331
+ log.info('session', `Re-keyed session ${existingSessionId?.slice(0, 8)} -> ${session_id?.slice(0, 8)} (via cached PID=${pid}, same process new session_id)`);
332
+ }
333
+ }
334
+ }
335
+
297
336
  // Priority 2: Match via pending workDir link
298
337
  if (!session) {
299
338
  const linkedTerminalId = tryLinkByWorkDir(cwd || '', session_id);
@@ -141,9 +141,9 @@ export function saveSnapshot(mqOffset?: number): void {
141
141
  pidToSession: pidObj,
142
142
  pendingResume: pendingResumeObj,
143
143
  };
144
- mkdirSync(SNAPSHOT_DIR, { recursive: true });
144
+ mkdirSync(SNAPSHOT_DIR, { recursive: true, mode: 0o700 });
145
145
  const tmpFile = SNAPSHOT_FILE + '.tmp';
146
- writeFileSync(tmpFile, JSON.stringify(snapshot));
146
+ writeFileSync(tmpFile, JSON.stringify(snapshot), { mode: 0o600 });
147
147
  renameSync(tmpFile, SNAPSHOT_FILE);
148
148
  log.debug('session', `Snapshot saved: ${Object.keys(sessionsObj).length} sessions`);
149
149
  } catch (err: unknown) {
@@ -770,7 +770,8 @@ export function handleEvent(hookData: HookPayload): HandleEventResult | null {
770
770
  if (session.source === 'ssh') {
771
771
  session.isHistorical = true;
772
772
  session.lastTerminalId = session.terminalId;
773
- session.terminalId = null;
773
+ // Keep terminalId alive — the PTY shell is still running even though Claude exited.
774
+ // terminalId is nulled when the PTY actually dies (registerTerminalExitCallback).
774
775
  }
775
776
  // Non-SSH sessions are also kept (no auto-delete)
776
777
  break;