claude-code-session-manager 0.8.1 → 0.8.2

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 (39) hide show
  1. package/dist/assets/{cssMode-DKTELvb6.js → cssMode-30PYohIN.js} +1 -1
  2. package/dist/assets/{editor.main-Dx55Am4z.js → editor.main-CZ_l_CSt.js} +3 -3
  3. package/dist/assets/{freemarker2-CBdvn_u-.js → freemarker2-DA5xODSz.js} +1 -1
  4. package/dist/assets/{handlebars-B67ay2ue.js → handlebars-BgJKogMf.js} +1 -1
  5. package/dist/assets/{html-002uK0_M.js → html-D3DAPwAR.js} +1 -1
  6. package/dist/assets/{htmlMode-DsT8oVY_.js → htmlMode-mS5mzFjU.js} +1 -1
  7. package/dist/assets/index-Bs-mHiD-.js +2976 -0
  8. package/dist/assets/index-DCK87t79.css +32 -0
  9. package/dist/assets/{javascript-Cfg-gFlu.js → javascript-CJ-Uxk_I.js} +1 -1
  10. package/dist/assets/{jsonMode-CCIKxANa.js → jsonMode-DbcDRati.js} +1 -1
  11. package/dist/assets/{liquid-DewgYvox.js → liquid-I4DHwPR_.js} +1 -1
  12. package/dist/assets/{lspLanguageFeatures-BcMPMUo0.js → lspLanguageFeatures-BntDl6Xn.js} +1 -1
  13. package/dist/assets/{mdx-BGrrIvjV.js → mdx-DWI58irx.js} +1 -1
  14. package/dist/assets/{python-CVhAv32T.js → python-DPx3c0QA.js} +1 -1
  15. package/dist/assets/{razor-DteXtrPO.js → razor-BcxFqE_H.js} +1 -1
  16. package/dist/assets/{tsMode-DKeWRYvl.js → tsMode-CGTi49DJ.js} +1 -1
  17. package/dist/assets/{typescript-Dl1KPrAp.js → typescript-CE9RqBjC.js} +1 -1
  18. package/dist/assets/{xml-DdyOGE0N.js → xml-DsrLAWcV.js} +1 -1
  19. package/dist/assets/{yaml-BwFXDW6t.js → yaml-CA8rRsQI.js} +1 -1
  20. package/dist/index.html +2 -2
  21. package/package.json +1 -1
  22. package/src/main/config.cjs +93 -19
  23. package/src/main/index.cjs +163 -31
  24. package/src/main/ipcSchemas.cjs +59 -2
  25. package/src/main/lib/cleanEnv.cjs +20 -0
  26. package/src/main/lib/credentials.cjs +184 -0
  27. package/src/main/lib/schedulerConfig.cjs +10 -0
  28. package/src/main/logs.cjs +1 -1
  29. package/src/main/otelSettings.cjs +1 -1
  30. package/src/main/pty.cjs +53 -6
  31. package/src/main/scheduler.cjs +518 -147
  32. package/src/main/transcripts.cjs +26 -21
  33. package/src/main/usage.cjs +76 -25
  34. package/src/main/voiceSettings.cjs +1 -1
  35. package/src/main/watchers.cjs +69 -11
  36. package/src/preload/api.d.ts +51 -11
  37. package/src/preload/index.cjs +13 -0
  38. package/dist/assets/index-DsC4vT8M.css +0 -32
  39. package/dist/assets/index-E14-spyd.js +0 -2972
package/dist/index.html CHANGED
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Claude Session Manager</title>
7
- <script type="module" crossorigin src="./assets/index-E14-spyd.js"></script>
8
- <link rel="stylesheet" crossorigin href="./assets/index-DsC4vT8M.css">
7
+ <script type="module" crossorigin src="./assets/index-Bs-mHiD-.js"></script>
8
+ <link rel="stylesheet" crossorigin href="./assets/index-DCK87t79.css">
9
9
  </head>
10
10
  <body class="bg-bg text-fg font-mono antialiased">
11
11
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-session-manager",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "Local cockpit for Claude Code CLI sessions — terminal + full config surface.",
5
5
  "type": "module",
6
6
  "main": "src/main/index.cjs",
@@ -36,10 +36,31 @@ function expandHome(p) {
36
36
  return p;
37
37
  }
38
38
 
39
+ /**
40
+ * Resolve a path to its realpath, handling non-existent files by resolving
41
+ * the parent directory instead. Prevents symlink traversal attacks.
42
+ */
43
+ function realResolve(abs) {
44
+ const lex = path.resolve(expandHome(abs));
45
+ try {
46
+ return fs.realpathSync(lex);
47
+ } catch (e) {
48
+ if (e.code === 'ENOENT') {
49
+ const parent = path.dirname(lex);
50
+ try {
51
+ return path.join(fs.realpathSync(parent), path.basename(lex));
52
+ } catch {
53
+ return lex;
54
+ }
55
+ }
56
+ throw e;
57
+ }
58
+ }
59
+
39
60
  /**
40
61
  * Validate that a resolved path stays within an allowed boundary. Prevents
41
62
  * path traversal attacks where a renderer-controlled path uses `..` segments
42
- * to escape into arbitrary filesystem locations.
63
+ * or symlinks to escape into arbitrary filesystem locations.
43
64
  *
44
65
  * Allowed roots: home directory and any cwd the app has seen (project scopes).
45
66
  */
@@ -50,11 +71,53 @@ function addAllowedRoot(dir) {
50
71
  }
51
72
 
52
73
  function validatePath(abs) {
53
- const resolved = path.resolve(abs);
74
+ const real = realResolve(abs);
54
75
  for (const root of allowedRoots) {
55
- if (resolved === root || resolved.startsWith(root + path.sep)) return resolved;
76
+ let realRoot;
77
+ try {
78
+ realRoot = fs.realpathSync(root);
79
+ } catch {
80
+ realRoot = root;
81
+ }
82
+ if (real === realRoot || real.startsWith(realRoot + path.sep)) return real;
56
83
  }
57
- throw new Error(`Path outside allowed boundaries: ${resolved}`);
84
+ throw new Error(`Path outside allowed boundaries: ${real}`);
85
+ }
86
+
87
+ // Paths that are allowed as write destinations.
88
+ const WRITE_PREFIXES = [
89
+ path.join(os.homedir(), '.claude'),
90
+ path.join(os.homedir(), '.claude.json'), // global MCP servers config
91
+ path.join(os.homedir(), '.config', 'claude-code'),
92
+ path.join(os.homedir(), '.config', 'session-manager'),
93
+ ];
94
+
95
+ /**
96
+ * Restricts write operations to a tighter set of destinations than reads.
97
+ * Never allows writing to .credentials.json.
98
+ * Call after validatePath (which provides the realpath).
99
+ */
100
+ function validateWrite(realAbs) {
101
+ if (path.basename(realAbs) === '.credentials.json') {
102
+ throw new Error(`Write to .credentials.json denied`);
103
+ }
104
+ const inWritePrefix = WRITE_PREFIXES.some(
105
+ (p) => realAbs === p || realAbs.startsWith(p + path.sep)
106
+ );
107
+ if (inWritePrefix) return;
108
+ // Also allowed inside a registered project root's .claude/ subtree.
109
+ for (const root of allowedRoots) {
110
+ if (root === os.homedir()) continue;
111
+ let realRoot;
112
+ try { realRoot = fs.realpathSync(root); } catch { realRoot = root; }
113
+ if (realAbs === realRoot || realAbs.startsWith(realRoot + path.sep)) {
114
+ const claudeSub = path.join(realRoot, '.claude');
115
+ if (realAbs === claudeSub || realAbs.startsWith(claudeSub + path.sep)) {
116
+ return;
117
+ }
118
+ }
119
+ }
120
+ throw new Error(`Write outside allowed write boundaries: ${realAbs}`);
58
121
  }
59
122
 
60
123
  async function readJson(abs) {
@@ -97,13 +160,14 @@ async function readText(abs) {
97
160
  * directories if missing.
98
161
  */
99
162
  async function writeTextAtomic(abs, text) {
100
- abs = validatePath(expandHome(abs));
101
- const dir = path.dirname(abs);
163
+ const real = validatePath(expandHome(abs));
164
+ validateWrite(real);
165
+ const dir = path.dirname(real);
102
166
  await fsp.mkdir(dir, { recursive: true });
103
- const tmp = `${abs}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
167
+ const tmp = `${real}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
104
168
  await fsp.writeFile(tmp, text, 'utf8');
105
- await fsp.rename(tmp, abs);
106
- const stat = await fsp.stat(abs);
169
+ await fsp.rename(tmp, real);
170
+ const stat = await fsp.stat(real);
107
171
  return { ok: true, mtimeMs: stat.mtimeMs };
108
172
  }
109
173
 
@@ -157,6 +221,15 @@ async function exists(abs) {
157
221
  }
158
222
  }
159
223
 
224
+ /**
225
+ * Normalize a path to a consistent map key: expand ~ then resolve to an
226
+ * absolute lexical path. Used as the refcount Map key in both watch() and
227
+ * unwatch() so callers can use either spelling interchangeably.
228
+ */
229
+ function normalizePathKey(p) {
230
+ return path.resolve(expandHome(p));
231
+ }
232
+
160
233
  /**
161
234
  * Watch one or more absolute paths. Each call increments a refcount per path.
162
235
  * Unwatch decrements; when refcount hits zero the underlying watcher is closed.
@@ -164,13 +237,14 @@ async function exists(abs) {
164
237
  */
165
238
  function watch(paths) {
166
239
  for (const rawPath of paths) {
167
- const abs = validatePath(expandHome(rawPath));
168
- const existing = watchers.get(abs);
240
+ validatePath(expandHome(rawPath)); // security check — throws if out of bounds
241
+ const key = normalizePathKey(rawPath);
242
+ const existing = watchers.get(key);
169
243
  if (existing) {
170
244
  existing.refCount++;
171
245
  continue;
172
246
  }
173
- const w = chokidar.watch(abs, {
247
+ const w = chokidar.watch(key, {
174
248
  ignoreInitial: true,
175
249
  persistent: true,
176
250
  awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 },
@@ -178,34 +252,34 @@ function watch(paths) {
178
252
  const emit = (kind) => async () => {
179
253
  let mtimeMs = 0;
180
254
  try {
181
- const st = await fsp.stat(abs);
255
+ const st = await fsp.stat(key);
182
256
  mtimeMs = st.mtimeMs;
183
257
  } catch {
184
258
  /* file may have been deleted */
185
259
  }
186
260
  if (window && !window.isDestroyed()) {
187
- window.webContents.send('config:changed', { path: abs, mtimeMs, kind });
261
+ window.webContents.send('config:changed', { path: key, mtimeMs, kind });
188
262
  }
189
263
  };
190
264
  w.on('add', emit('add'));
191
265
  w.on('change', emit('change'));
192
266
  w.on('unlink', emit('unlink'));
193
267
  w.on('error', (err) => {
194
- console.warn('[config] watcher error for', abs, err.message);
268
+ console.warn('[config] watcher error for', key, err.message);
195
269
  });
196
- watchers.set(abs, { watcher: w, refCount: 1 });
270
+ watchers.set(key, { watcher: w, refCount: 1 });
197
271
  }
198
272
  }
199
273
 
200
274
  function unwatch(paths) {
201
275
  for (const rawPath of paths) {
202
- const abs = expandHome(rawPath);
203
- const entry = watchers.get(abs);
276
+ const key = normalizePathKey(rawPath);
277
+ const entry = watchers.get(key);
204
278
  if (!entry) continue;
205
279
  entry.refCount--;
206
280
  if (entry.refCount <= 0) {
207
281
  entry.watcher.close().catch(() => {});
208
- watchers.delete(abs);
282
+ watchers.delete(key);
209
283
  }
210
284
  }
211
285
  }
@@ -2,7 +2,10 @@ const { app, BrowserWindow, ipcMain, dialog, Menu, session, systemPreferences, g
2
2
  const { spawn, execFile, execFileSync } = require('node:child_process');
3
3
  const path = require('node:path');
4
4
  const fs = require('node:fs');
5
+ const fsp = require('node:fs/promises');
5
6
  const os = require('node:os');
7
+ const { schemas } = require('./ipcSchemas.cjs');
8
+ const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
6
9
  const { manager: ptyManager, registerPtyHandlers } = require('./pty.cjs');
7
10
  const configMgr = require('./config.cjs');
8
11
  const transcripts = require('./transcripts.cjs');
@@ -68,7 +71,7 @@ function relaunchViaNpx() {
68
71
  const child = spawn(npxPath, ['--yes', 'claude-code-session-manager@latest'], {
69
72
  detached: true,
70
73
  stdio: ['ignore', logFd, logFd],
71
- env: process.env,
74
+ env: cleanChildEnv(),
72
75
  });
73
76
  // fd was dup'd into the child at spawn time; closing the parent copy frees
74
77
  // our file table slot and ensures the child owns the lifetime of the log.
@@ -147,6 +150,7 @@ function createWindow() {
147
150
  preload: path.join(__dirname, '..', 'preload', 'index.cjs'),
148
151
  contextIsolation: true,
149
152
  nodeIntegration: false,
153
+ sandbox: true,
150
154
  },
151
155
  });
152
156
 
@@ -171,27 +175,6 @@ function createWindow() {
171
175
  return;
172
176
  }
173
177
 
174
- // Lock down navigation: deny window.open + reject any navigation away from
175
- // the loaded UI. External links are routed to the OS browser via shell
176
- // instead of opening inside the Electron window. Defense against XSS or
177
- // a compromised dependency that tries to navigate to a phishing page.
178
- mainWindow.webContents.setWindowOpenHandler(({ url }) => {
179
- if (url.startsWith('http://') || url.startsWith('https://')) {
180
- shell.openExternal(url).catch(() => {});
181
- }
182
- return { action: 'deny' };
183
- });
184
-
185
- mainWindow.webContents.on('will-navigate', (event, url) => {
186
- const allowed = useDevServer
187
- ? ['http://localhost:5173', 'http://127.0.0.1:5173']
188
- : []; // file:// loads happen via loadFile, not navigation
189
- if (!allowed.some((a) => url.startsWith(a))) {
190
- event.preventDefault();
191
- console.warn('[main] blocked will-navigate to', url);
192
- }
193
- });
194
-
195
178
  mainWindow.on('closed', () => {
196
179
  mainWindow = null;
197
180
  });
@@ -238,13 +221,32 @@ ipcMain.handle('app:test-fire-hook', async (_e, payload) => {
238
221
  return { exitCode: -1, stdout: '', stderr: 'empty command', durationMs: 0 };
239
222
  }
240
223
 
224
+ // Require main window focused and explicit user confirmation before exec.
225
+ if (!mainWindow || !mainWindow.isFocused()) {
226
+ return { exitCode: -1, stdout: '', stderr: 'window not focused', durationMs: 0 };
227
+ }
228
+ const detail = command.length > 500 ? command.slice(0, 500) + '…' : command;
229
+ const { response } = await dialog.showMessageBox(mainWindow, {
230
+ type: 'warning',
231
+ buttons: ['Cancel', 'Run'],
232
+ defaultId: 0,
233
+ cancelId: 0,
234
+ message: 'Run hook command?',
235
+ detail,
236
+ });
237
+ if (response !== 1) {
238
+ return { exitCode: -1, stdout: '', stderr: 'user cancelled', durationMs: 0 };
239
+ }
240
+
241
+ const MAX_BUF = 1024 * 1024; // 1 MiB per stream
242
+
241
243
  return await new Promise((resolve) => {
242
244
  const startedAt = Date.now();
243
245
  let child;
244
246
  try {
245
247
  child = spawn(command, {
246
248
  shell: true,
247
- env: { ...process.env, ...(env ?? {}) },
249
+ env: cleanChildEnv(env ?? {}),
248
250
  stdio: ['pipe', 'pipe', 'pipe'],
249
251
  });
250
252
  } catch (err) {
@@ -259,6 +261,8 @@ ipcMain.handle('app:test-fire-hook', async (_e, payload) => {
259
261
 
260
262
  const stdoutChunks = [];
261
263
  const stderrChunks = [];
264
+ let stdoutLen = 0;
265
+ let stderrLen = 0;
262
266
  let timedOut = false;
263
267
 
264
268
  const killTimer = setTimeout(() => {
@@ -266,8 +270,18 @@ ipcMain.handle('app:test-fire-hook', async (_e, payload) => {
266
270
  try { child.kill('SIGKILL'); } catch { /* already dead */ }
267
271
  }, timeoutMs);
268
272
 
269
- child.stdout.on('data', (b) => stdoutChunks.push(b));
270
- child.stderr.on('data', (b) => stderrChunks.push(b));
273
+ child.stdout.on('data', (b) => {
274
+ if (stdoutLen < MAX_BUF) {
275
+ stdoutChunks.push(b);
276
+ stdoutLen += b.length;
277
+ }
278
+ });
279
+ child.stderr.on('data', (b) => {
280
+ if (stderrLen < MAX_BUF) {
281
+ stderrChunks.push(b);
282
+ stderrLen += b.length;
283
+ }
284
+ });
271
285
 
272
286
  child.on('error', (err) => {
273
287
  clearTimeout(killTimer);
@@ -317,6 +331,75 @@ ipcMain.handle('app:git-branch', async (_e, payload) => {
317
331
  });
318
332
  });
319
333
 
334
+ // Returns the resolved path of a command, or null if not found on PATH.
335
+ function findCommand(name) {
336
+ try {
337
+ const out = execFileSync(
338
+ process.platform === 'win32' ? 'where' : 'which',
339
+ [name],
340
+ { encoding: 'utf8', env: process.env, timeout: 500 },
341
+ ).trim().split(/\r?\n/)[0];
342
+ if (out) return out;
343
+ } catch { /* not found */ }
344
+ return null;
345
+ }
346
+
347
+ ipcMain.handle('app:open-in-editor', async (_e, payload) => {
348
+ const { cwd, editor } = schemas.openInEditor.parse(payload);
349
+ const home = os.homedir();
350
+ if (!path.resolve(cwd).startsWith(home)) throw new Error('cwd outside home');
351
+ const candidates = (editor && editor !== 'auto')
352
+ ? [editor]
353
+ : [process.env.VISUAL, process.env.EDITOR, 'code', 'cursor', 'subl', 'nano'].filter(Boolean);
354
+ for (const cmd of candidates) {
355
+ if (!findCommand(cmd)) continue;
356
+ const child = spawn(cmd, [cwd], { detached: true, stdio: 'ignore', env: cleanChildEnv() });
357
+ child.unref();
358
+ return { ok: true, editor: cmd };
359
+ }
360
+ return { ok: false, error: 'no editor found' };
361
+ });
362
+
363
+ ipcMain.handle('app:open-in-finder', async (_e, payload) => {
364
+ const { cwd } = schemas.openInFinder.parse(payload);
365
+ const home = os.homedir();
366
+ if (!path.resolve(cwd).startsWith(home)) throw new Error('cwd outside home');
367
+ await shell.openPath(cwd);
368
+ return { ok: true };
369
+ });
370
+
371
+ ipcMain.handle('app:open-in-terminal', async (_e, payload) => {
372
+ const { cwd } = schemas.openInTerminal.parse(payload);
373
+ const home = os.homedir();
374
+ if (!path.resolve(cwd).startsWith(home)) throw new Error('cwd outside home');
375
+ if (process.platform === 'linux') {
376
+ const terms = ['gnome-terminal', 'konsole', 'xfce4-terminal', 'xterm'];
377
+ for (const t of terms) {
378
+ if (!findCommand(t)) continue;
379
+ const args = t === 'gnome-terminal'
380
+ ? ['--working-directory=' + cwd]
381
+ : ['-e', `bash -c "cd '${cwd.replace(/'/g, "'\\''")}' && exec bash"`];
382
+ const child = spawn(t, args, { detached: true, stdio: 'ignore', env: cleanChildEnv() });
383
+ child.unref();
384
+ return { ok: true, terminal: t };
385
+ }
386
+ } else if (process.platform === 'darwin') {
387
+ spawn('open', ['-a', 'Terminal', cwd], { detached: true, stdio: 'ignore', env: cleanChildEnv() }).unref();
388
+ return { ok: true, terminal: 'Terminal.app' };
389
+ }
390
+ return { ok: false, error: 'no terminal found' };
391
+ });
392
+
393
+ ipcMain.handle('app:archive-project', async (_e, payload) => {
394
+ const { encoded } = schemas.archiveProject.parse(payload);
395
+ const home = os.homedir();
396
+ const src = path.join(home, '.claude', 'projects', encoded);
397
+ const dst = path.join(home, '.claude', 'projects-archive', encoded);
398
+ await fsp.mkdir(path.dirname(dst), { recursive: true });
399
+ await fsp.rename(src, dst);
400
+ return { ok: true };
401
+ });
402
+
320
403
  registerPtyHandlers();
321
404
  configMgr.registerConfigHandlers();
322
405
  transcripts.registerTranscriptHandlers();
@@ -371,6 +454,33 @@ if (!isDev) {
371
454
  }
372
455
  }
373
456
 
457
+ // Global webContents hardening — applies to DevTools, future webContents, etc.
458
+ // Must be registered before app.whenReady so it captures the first window too.
459
+ app.on('web-contents-created', (_e, wc) => {
460
+ const useDevServer = process.env.SM_DEV === '1';
461
+
462
+ wc.setWindowOpenHandler(({ url }) => {
463
+ if (url.startsWith('http://') || url.startsWith('https://')) {
464
+ shell.openExternal(url).catch(() => {});
465
+ }
466
+ return { action: 'deny' };
467
+ });
468
+
469
+ wc.on('will-navigate', (event, url) => {
470
+ const allowed = useDevServer
471
+ ? ['http://localhost:5173', 'http://127.0.0.1:5173']
472
+ : [];
473
+ if (!allowed.some((a) => url.startsWith(a))) {
474
+ event.preventDefault();
475
+ console.warn('[main] blocked will-navigate to', url);
476
+ }
477
+ });
478
+
479
+ wc.on('will-attach-webview', (event) => {
480
+ event.preventDefault();
481
+ });
482
+ });
483
+
374
484
  app.whenReady().then(async () => {
375
485
  logs.pruneOld();
376
486
  logs.writeLine({ scope: 'main', level: 'info', message: 'app start', meta: { version: app.getVersion(), platform: process.platform } });
@@ -383,14 +493,36 @@ app.whenReady().then(async () => {
383
493
  logs.writeLine({ scope: 'main', level: 'error', message: 'unhandledRejection', meta: r });
384
494
  });
385
495
 
386
- // Grant microphone / media permissions so the renderer's getUserMedia
387
- // (Whisper voice-to-text) resolves instead of being silently denied.
496
+ // Inject Content-Security-Policy for all renderer responses.
497
+ const CSP = [
498
+ "default-src 'self'",
499
+ "script-src 'self' 'wasm-unsafe-eval'",
500
+ "style-src 'self' 'unsafe-inline'",
501
+ "img-src 'self' data: blob:",
502
+ "font-src 'self' data:",
503
+ "connect-src 'self' https://api.anthropic.com",
504
+ "media-src 'self' blob:",
505
+ "worker-src 'self' blob:",
506
+ ].join('; ') + ';';
507
+ session.defaultSession.webRequest.onHeadersReceived((details, cb) => {
508
+ cb({
509
+ responseHeaders: {
510
+ ...details.responseHeaders,
511
+ 'Content-Security-Policy': [CSP],
512
+ },
513
+ });
514
+ });
515
+
516
+ // Grant microphone / media permissions only for trusted origins.
388
517
  const MEDIA_PERMS = new Set(['media', 'audioCapture', 'microphone']);
389
- session.defaultSession.setPermissionRequestHandler((_wc, permission, cb) => {
390
- cb(MEDIA_PERMS.has(permission));
518
+ const isTrustedOrigin = (url) =>
519
+ (typeof url === 'string') &&
520
+ (url.startsWith('file://') || url.startsWith('http://localhost:5173'));
521
+ session.defaultSession.setPermissionRequestHandler((_wc, permission, cb, details) => {
522
+ cb(MEDIA_PERMS.has(permission) && isTrustedOrigin(details?.requestingUrl));
391
523
  });
392
- session.defaultSession.setPermissionCheckHandler((_wc, permission) => {
393
- return MEDIA_PERMS.has(permission);
524
+ session.defaultSession.setPermissionCheckHandler((_wc, permission, requestingOrigin) => {
525
+ return MEDIA_PERMS.has(permission) && isTrustedOrigin(requestingOrigin);
394
526
  });
395
527
  // macOS: trigger the OS-level mic consent prompt up front. Safe no-op on
396
528
  // other platforms (systemPreferences.askForMediaAccess is darwin-only).
@@ -6,6 +6,8 @@
6
6
  */
7
7
 
8
8
  const { z } = require('zod');
9
+ const os = require('node:os');
10
+ const path = require('node:path');
9
11
 
10
12
  // ──────────────────────────────────────────── PTY
11
13
  const ptySpawn = z.object({
@@ -30,17 +32,19 @@ const ptyResize = z.object({
30
32
  });
31
33
 
32
34
  // ──────────────────────────────────────────── Transcripts
35
+ const SESSION_UUID_RE = /^[a-zA-Z0-9-]{1,64}$/;
36
+
33
37
  const transcriptSubscribe = z.object({
34
38
  tabId: z.string().min(1).max(128),
35
39
  cwd: z.string().min(1).max(4096),
36
- sessionUuid: z.string().min(1).max(128),
40
+ sessionUuid: z.string().regex(SESSION_UUID_RE),
37
41
  });
38
42
 
39
43
  const transcriptTabId = z.object({ tabId: z.string().min(1).max(128) });
40
44
 
41
45
  const transcriptPath = z.object({
42
46
  cwd: z.string().min(1).max(4096),
43
- sessionUuid: z.string().min(1).max(128),
47
+ sessionUuid: z.string().regex(SESSION_UUID_RE),
44
48
  });
45
49
 
46
50
  // ──────────────────────────────────────────── Config
@@ -79,6 +83,52 @@ const sessionsPayload = z.object({
79
83
  activeTabId: z.string().max(128).nullable(),
80
84
  });
81
85
 
86
+ // ──────────────────────────────────────────── Schedule
87
+ const SCHEDULE_SLUG_RE = /^[A-Za-z0-9._-]{1,128}$/;
88
+ const SCHEDULE_RUN_ID_RE = /^[A-Za-z0-9._:-]{1,64}$/;
89
+
90
+ const scheduleSlug = z.object({
91
+ slug: z.string().regex(SCHEDULE_SLUG_RE),
92
+ });
93
+
94
+ const scheduleReadLog = z.object({
95
+ slug: z.string().regex(SCHEDULE_SLUG_RE),
96
+ runId: z.string().regex(SCHEDULE_RUN_ID_RE),
97
+ });
98
+
99
+ // ──────────────────────────────────────────── Projects
100
+ const ENCODED_SLUG_RE = /^[A-Za-z0-9._-]+$/;
101
+
102
+ const openInEditor = z.object({
103
+ cwd: z.string().min(1).max(4096),
104
+ editor: z.string().max(256).nullable().optional(),
105
+ });
106
+
107
+ const openInFinder = z.object({
108
+ cwd: z.string().min(1).max(4096),
109
+ });
110
+
111
+ const openInTerminal = z.object({
112
+ cwd: z.string().min(1).max(4096),
113
+ });
114
+
115
+ const archiveProject = z.object({
116
+ encoded: z.string().regex(ENCODED_SLUG_RE).min(1).max(4096),
117
+ });
118
+
119
+ const home = os.homedir();
120
+ const setConfigSchema = z.object({
121
+ enabled: z.boolean().optional(),
122
+ offsetMinutes: z.number().int().min(0).max(180).optional(),
123
+ concurrencyCap: z.number().int().min(1).max(20).optional(),
124
+ defaultCwd: z.string().max(4096).refine(
125
+ (s) => s === home || s.startsWith(home + path.sep),
126
+ 'defaultCwd must be inside home directory'
127
+ ).optional(),
128
+ firePolicy: z.enum(['when-available', 'on-reset', 'manual']).optional(),
129
+ utilizationThreshold: z.number().min(0).max(200).optional(),
130
+ }).strict();
131
+
82
132
  /**
83
133
  * Wrap an IPC handler with schema validation. Returns a new handler that
84
134
  * parses the payload before calling the original.
@@ -105,6 +155,13 @@ module.exports = {
105
155
  configListDir,
106
156
  configWatch,
107
157
  sessionsPayload,
158
+ scheduleSlug,
159
+ scheduleReadLog,
160
+ setConfigSchema,
161
+ openInEditor,
162
+ openInFinder,
163
+ openInTerminal,
164
+ archiveProject,
108
165
  },
109
166
  validated,
110
167
  };
@@ -0,0 +1,20 @@
1
+ const SECRET_KEY_RE = /^(?:.*_)?(TOKEN|API_?KEY|SECRET|PASSWORD|AUTHORIZATION|COOKIE|REFRESH[_-]?TOKEN|ACCESS[_-]?TOKEN)$/i;
2
+
3
+ function cleanChildEnv(extra = {}) {
4
+ const env = { ...process.env, ...extra };
5
+ for (const k of Object.keys(env)) {
6
+ if (
7
+ k === 'CLAUDE_EFFORT' ||
8
+ k === 'CLAUDECODE' ||
9
+ k === 'NODE_OPTIONS' ||
10
+ k.startsWith('CLAUDE_CODE_') ||
11
+ k.startsWith('npm_config_') ||
12
+ SECRET_KEY_RE.test(k)
13
+ ) {
14
+ delete env[k];
15
+ }
16
+ }
17
+ return env;
18
+ }
19
+
20
+ module.exports = { cleanChildEnv };