aegis-bridge 2.5.2 → 2.5.4

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.
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Aegis Dashboard</title>
7
7
  <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🛡️</text></svg>" />
8
- <script type="module" crossorigin src="/dashboard/assets/index-DxAes2EQ.js"></script>
8
+ <script type="module" crossorigin src="/dashboard/assets/index-DIyuyrlO.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/dashboard/assets/index-B7DYf7vF.css">
10
10
  </head>
11
11
  <body class="bg-[#0a0a0f] text-gray-200 antialiased">
package/dist/events.d.ts CHANGED
@@ -76,6 +76,8 @@ export declare class SessionEventBus {
76
76
  private globalEmitter;
77
77
  /** #689: Pending setImmediate timers for cleanup on destroy. */
78
78
  private pendingTimers;
79
+ /** #834: Pending setTimeout timers for cleanup on destroy/cleanupSession. */
80
+ private pendingTimeouts;
79
81
  /** Subscribe to events from ALL sessions (new and existing). Returns unsubscribe function. */
80
82
  subscribeGlobal(handler: (event: GlobalSSEEvent) => void): () => void;
81
83
  /** Emit a session created event to global subscribers. */
package/dist/events.js CHANGED
@@ -178,12 +178,15 @@ export class SessionEventBus {
178
178
  // Clean up after a short delay (let clients receive the event)
179
179
  // Capture reference — only delete if it's still the same emitter
180
180
  // #357: Also delete the per-session event buffer to prevent unbounded map growth
181
- setTimeout(() => {
181
+ // #834: Track the timer so cleanupSession/destroy can cancel it
182
+ const timeout = setTimeout(() => {
183
+ this.pendingTimeouts.delete(timeout);
182
184
  if (this.emitters.get(sessionId) === emitter) {
183
185
  this.emitters.delete(sessionId);
184
186
  }
185
187
  this.eventBuffers.delete(sessionId);
186
188
  }, 1000);
189
+ this.pendingTimeouts.add(timeout);
187
190
  }
188
191
  /** Emit a stall event. */
189
192
  emitStall(sessionId, stallType, detail) {
@@ -227,6 +230,8 @@ export class SessionEventBus {
227
230
  globalEmitter = null;
228
231
  /** #689: Pending setImmediate timers for cleanup on destroy. */
229
232
  pendingTimers = new Set();
233
+ /** #834: Pending setTimeout timers for cleanup on destroy/cleanupSession. */
234
+ pendingTimeouts = new Set();
230
235
  /** Subscribe to events from ALL sessions (new and existing). Returns unsubscribe function. */
231
236
  subscribeGlobal(handler) {
232
237
  if (!this.globalEmitter) {
@@ -267,6 +272,11 @@ export class SessionEventBus {
267
272
  }
268
273
  /** #398: Clean up per-session state (call when session is killed). */
269
274
  cleanupSession(sessionId) {
275
+ // #834: Clear pending setTimeout for this session's emitEnded cleanup
276
+ for (const timeout of this.pendingTimeouts) {
277
+ clearTimeout(timeout);
278
+ this.pendingTimeouts.delete(timeout);
279
+ }
270
280
  this.eventBuffers.delete(sessionId);
271
281
  const emitter = this.emitters.get(sessionId);
272
282
  if (emitter) {
@@ -281,6 +291,11 @@ export class SessionEventBus {
281
291
  clearImmediate(imm);
282
292
  }
283
293
  this.pendingTimers.clear();
294
+ // #834: Clear pending setTimeout timers
295
+ for (const timeout of this.pendingTimeouts) {
296
+ clearTimeout(timeout);
297
+ }
298
+ this.pendingTimeouts.clear();
284
299
  for (const emitter of this.emitters.values()) {
285
300
  emitter.removeAllListeners();
286
301
  }
@@ -15,10 +15,28 @@
15
15
  */
16
16
  import { readFile, writeFile, unlink, mkdir, rmdir } from 'node:fs/promises';
17
17
  import { existsSync } from 'node:fs';
18
- import { join } from 'node:path';
18
+ import { join, resolve, normalize } from 'node:path';
19
19
  import { tmpdir } from 'node:os';
20
20
  import { randomBytes } from 'node:crypto';
21
21
  import { ccSettingsSchema } from './validation.js';
22
+ /**
23
+ * Validate a workDir path for use in hook settings resolution.
24
+ * Defense-in-depth against path traversal: rejects paths containing ".." segments
25
+ * or that resolve outside the provided workDir.
26
+ *
27
+ * @returns Sanitized absolute path, or undefined if validation fails.
28
+ */
29
+ function validateWorkDirPath(workDir) {
30
+ const normalized = normalize(workDir);
31
+ // Reject paths with traversal segments
32
+ if (normalized.includes('..'))
33
+ return undefined;
34
+ // Resolve to absolute and verify it doesn't escape upward
35
+ const resolved = resolve(normalized);
36
+ if (resolved.includes('..'))
37
+ return undefined;
38
+ return resolved;
39
+ }
22
40
  /** CC hook events that support `type: "http"`.
23
41
  *
24
42
  * All CC hook events support HTTP hooks. We register the most useful ones
@@ -89,9 +107,11 @@ export async function writeHookSettingsFile(baseUrl, sessionId, workDir) {
89
107
  const hookSettings = generateHookSettings(baseUrl, sessionId);
90
108
  // Issue #339: Read project's settings.local.json and merge hooks into it.
91
109
  // This ensures CC gets env vars, permissions, and bypassPermissions alongside hooks.
110
+ // Issue #847: Validate workDir path to prevent traversal attacks.
92
111
  let merged = {};
93
- if (workDir) {
94
- const projectSettingsPath = join(workDir, '.claude', 'settings.local.json');
112
+ const safeWorkDir = workDir ? validateWorkDirPath(workDir) : undefined;
113
+ if (safeWorkDir) {
114
+ const projectSettingsPath = join(safeWorkDir, '.claude', 'settings.local.json');
95
115
  if (existsSync(projectSettingsPath)) {
96
116
  try {
97
117
  const raw = await readFile(projectSettingsPath, 'utf-8');
@@ -105,14 +125,14 @@ export async function writeHookSettingsFile(baseUrl, sessionId, workDir) {
105
125
  }
106
126
  }
107
127
  }
108
- // Deep-merge: project settings as base, hook settings override
109
- const combined = {
110
- ...merged,
111
- hooks: {
112
- ...(merged.hooks ?? {}),
113
- ...hookSettings.hooks,
114
- },
115
- };
128
+ // Deep-merge: project settings as base, hooks merged by event key so both
129
+ // project-level and Aegis hooks coexist (Issue #635).
130
+ const existingHooks = merged.hooks ?? {};
131
+ const mergedHooks = { ...existingHooks };
132
+ for (const [event, entries] of Object.entries(hookSettings.hooks)) {
133
+ mergedHooks[event] = [...(existingHooks[event] ?? []), ...entries];
134
+ }
135
+ const combined = { ...merged, hooks: mergedHooks };
116
136
  // Issue #648: Use unpredictable directory name and restrictive permissions
117
137
  // to prevent symlink attacks and information disclosure in /tmp.
118
138
  const suffix = randomBytes(4).toString('hex');
package/dist/hooks.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * Issue #169: Phase 1 — HTTP hooks infrastructure.
15
15
  * Issue #169: Phase 3 — Hook-driven status detection.
16
16
  */
17
- import { isValidUUID, hookBodySchema } from './validation.js';
17
+ import { isValidUUID, hookBodySchema, parseIntSafe } from './validation.js';
18
18
  /** CC hook events that require a decision response. */
19
19
  const DECISION_EVENTS = new Set(['PreToolUse', 'PermissionRequest']);
20
20
  /** Permission modes that should be auto-approved via hook response. */
@@ -22,7 +22,7 @@ const AUTO_APPROVE_MODES = new Set(['bypassPermissions', 'dontAsk', 'acceptEdits
22
22
  /** Default timeout for waiting on client permission decision (ms). */
23
23
  const PERMISSION_TIMEOUT_MS = 10_000;
24
24
  /** Default timeout for waiting on external answer to AskUserQuestion (ms). */
25
- const ANSWER_TIMEOUT_MS = parseInt(process.env.ANSWER_TIMEOUT_MS || '30000', 10);
25
+ const ANSWER_TIMEOUT_MS = parseIntSafe(process.env.ANSWER_TIMEOUT_MS, 30_000);
26
26
  /** Valid permission_mode values accepted by Claude Code. */
27
27
  const VALID_PERMISSION_MODES = new Set(['default', 'plan', 'bypassPermissions']);
28
28
  /** Valid CC hook event names (allow any for extensibility, but these are known). */
@@ -43,9 +43,16 @@ export class JsonlWatcher {
43
43
  * @param initialOffset - byte offset to start reading from (usually 0 or current session.monitorOffset).
44
44
  */
45
45
  watch(sessionId, jsonlPath, initialOffset) {
46
- // Don't double-watch
46
+ // Issue #846: Clear stale timer before re-watching to prevent
47
+ // old timer closures from operating on stale entry data.
47
48
  if (this.entries.has(sessionId)) {
48
- this.unwatch(sessionId);
49
+ const oldEntry = this.entries.get(sessionId);
50
+ if (oldEntry.debounceTimer) {
51
+ clearTimeout(oldEntry.debounceTimer);
52
+ oldEntry.debounceTimer = null;
53
+ }
54
+ oldEntry.fsWatcher.close();
55
+ this.entries.delete(sessionId);
49
56
  }
50
57
  if (!existsSync(jsonlPath))
51
58
  return;
package/dist/pipeline.js CHANGED
@@ -149,6 +149,16 @@ export class PipelineManager {
149
149
  }
150
150
  /** Poll running pipelines and advance stages. */
151
151
  async pollPipelines() {
152
+ // #830: Stop polling immediately when no pipelines remain, rather than
153
+ // waiting for the 30s cleanup setTimeout to fire. Prevents ~6 no-op poll
154
+ // cycles and stale config references during the cleanup window.
155
+ if (this.pipelines.size === 0) {
156
+ if (this.pollInterval) {
157
+ clearInterval(this.pollInterval);
158
+ this.pollInterval = null;
159
+ }
160
+ return;
161
+ }
152
162
  for (const [id, pipeline] of this.pipelines) {
153
163
  if (pipeline.status !== 'running')
154
164
  continue;
package/dist/server.js CHANGED
@@ -121,6 +121,7 @@ const ipRateLimits = new Map();
121
121
  const IP_WINDOW_MS = 60_000;
122
122
  const IP_LIMIT_NORMAL = 120; // per minute for regular keys
123
123
  const IP_LIMIT_MASTER = 300; // per minute for master token
124
+ const MAX_IP_ENTRIES = 10_000; // #844: Cap tracked IPs to prevent memory exhaustion
124
125
  function checkIpRateLimit(ip, isMaster) {
125
126
  const now = Date.now();
126
127
  const cutoff = now - IP_WINDOW_MS;
@@ -136,6 +137,20 @@ function checkIpRateLimit(ip, isMaster) {
136
137
  }
137
138
  bucket.entries.push(now);
138
139
  ipRateLimits.set(ip, bucket);
140
+ // #844: Evict oldest IPs when map exceeds cap to prevent unbounded memory growth
141
+ if (ipRateLimits.size > MAX_IP_ENTRIES) {
142
+ let oldestIp = '';
143
+ let oldestTime = Infinity;
144
+ for (const [trackedIp, trackedBucket] of ipRateLimits) {
145
+ const lastTs = trackedBucket.entries[trackedBucket.entries.length - 1];
146
+ if (lastTs !== undefined && lastTs < oldestTime) {
147
+ oldestTime = lastTs;
148
+ oldestIp = trackedIp;
149
+ }
150
+ }
151
+ if (oldestIp)
152
+ ipRateLimits.delete(oldestIp);
153
+ }
139
154
  const activeCount = bucket.entries.length - bucket.start;
140
155
  const limit = isMaster ? IP_LIMIT_MASTER : IP_LIMIT_NORMAL;
141
156
  return activeCount > limit;
@@ -153,6 +168,11 @@ function pruneIpRateLimits() {
153
168
  }
154
169
  /** #583: Track keyId per request for batch rate limiting. */
155
170
  const requestKeyMap = new Map();
171
+ // #839: Clean up requestKeyMap entries after response to prevent unbounded memory leak.
172
+ app.addHook('onResponse', (req, _reply, done) => {
173
+ requestKeyMap.delete(req.id);
174
+ done();
175
+ });
156
176
  function setupAuth(authManager) {
157
177
  app.addHook('onRequest', async (req, reply) => {
158
178
  // Skip auth for health endpoint and dashboard (Issue #349: exact path matching)
@@ -203,7 +223,7 @@ function setupAuth(authManager) {
203
223
  }
204
224
  // #297: Check if this is a short-lived SSE token first
205
225
  if (isSSERoute && token.startsWith('sse_')) {
206
- if (authManager.validateSSEToken(token)) {
226
+ if (await authManager.validateSSEToken(token)) {
207
227
  return; // authenticated via short-lived SSE token
208
228
  }
209
229
  return reply.status(401).send({ error: 'Unauthorized — SSE token invalid or expired' });
@@ -463,13 +483,18 @@ async function createSessionHandler(req, reply) {
463
483
  // Issue #607: Check for an existing idle session with the same workDir
464
484
  const existing = await sessions.findIdleSessionByWorkDir(safeWorkDir);
465
485
  if (existing) {
466
- // Send prompt to the existing session if provided
467
- let promptDelivery;
468
- if (prompt) {
469
- promptDelivery = await sessions.sendInitialPrompt(existing.id, prompt);
470
- metrics.promptSent(promptDelivery.delivered);
486
+ try {
487
+ // Send prompt to the existing session if provided
488
+ let promptDelivery;
489
+ if (prompt) {
490
+ promptDelivery = await sessions.sendInitialPrompt(existing.id, prompt);
491
+ metrics.promptSent(promptDelivery.delivered);
492
+ }
493
+ return reply.status(200).send({ ...existing, reused: true, promptDelivery });
494
+ }
495
+ finally {
496
+ sessions.releaseSessionClaim(existing.id);
471
497
  }
472
- return reply.status(200).send({ ...existing, reused: true, promptDelivery });
473
498
  }
474
499
  console.time("POST_CREATE_SESSION");
475
500
  const session = await sessions.createSession({ workDir: safeWorkDir, name, resumeSessionId, claudeCommand, env, stallThresholdMs, permissionMode, autoApprove });
package/dist/session.d.ts CHANGED
@@ -56,6 +56,8 @@ export declare class SessionManager {
56
56
  private stateFile;
57
57
  private sessionMapFile;
58
58
  private pollTimers;
59
+ /** #835: Discovery timeout timers — cleared in cleanupSession to prevent orphan callbacks. */
60
+ private discoveryTimeouts;
59
61
  private saveQueue;
60
62
  private saveDebounceTimer;
61
63
  private static readonly SAVE_DEBOUNCE_MS;
@@ -63,6 +65,7 @@ export declare class SessionManager {
63
65
  private pendingQuestions;
64
66
  private static readonly MAX_CACHE_ENTRIES_PER_SESSION;
65
67
  private parsedEntriesCache;
68
+ private sessionAcquireMutex;
66
69
  constructor(tmux: TmuxManager, config: Config);
67
70
  /** Validate that parsed data looks like a valid SessionState. */
68
71
  private isValidState;
@@ -160,8 +163,11 @@ export declare class SessionManager {
160
163
  /** Issue #607: Find an idle session for the given workDir.
161
164
  * Returns the most recently active idle session, or null if none found.
162
165
  * Used to resume existing sessions instead of creating duplicates.
163
- * Issue #636: Verifies tmux window is still alive before returning. */
166
+ * Issue #636: Verifies tmux window is still alive before returning.
167
+ * Issue #840: Atomically acquires the session under a mutex to prevent TOCTOU race. */
164
168
  findIdleSessionByWorkDir(workDir: string): Promise<SessionInfo | null>;
169
+ /** Release a session claim after the reuse path completes (success or failure). */
170
+ releaseSessionClaim(id: string): void;
165
171
  /** Get health info for a session.
166
172
  * Issue #2: Returns comprehensive health status for orchestrators.
167
173
  */
package/dist/session.js CHANGED
@@ -35,10 +35,11 @@ function hydrateSessions(raw) {
35
35
  * permission options from regular numbered lists in output.
36
36
  */
37
37
  export function detectApprovalMethod(paneText) {
38
- // Match CC's permission option format: indented " 1. Yes" lines
39
- // The indentation + short number range distinguishes from output numbered lists
38
+ // Match CC's permission option format: indented " 1. Yes" lines.
39
+ // Issue #843: Tightened to require "Esc to cancel" nearby (within 300 chars)
40
+ // to avoid false positives on regular indented numbered lists in output.
40
41
  const numberedOptionPattern = /^\s{2}[1-3]\.\s/m;
41
- if (numberedOptionPattern.test(paneText)) {
42
+ if (numberedOptionPattern.test(paneText) && /Esc to cancel/i.test(paneText)) {
42
43
  return 'numbered';
43
44
  }
44
45
  return 'yes';
@@ -50,6 +51,8 @@ export class SessionManager {
50
51
  stateFile;
51
52
  sessionMapFile;
52
53
  pollTimers = new Map();
54
+ /** #835: Discovery timeout timers — cleared in cleanupSession to prevent orphan callbacks. */
55
+ discoveryTimeouts = new Map();
53
56
  saveQueue = Promise.resolve(); // #218: serialize concurrent saves
54
57
  saveDebounceTimer = null;
55
58
  static SAVE_DEBOUNCE_MS = 5_000; // #357: debounce offset-only saves
@@ -59,6 +62,8 @@ export class SessionManager {
59
62
  // #424: Evict oldest entries when cache exceeds max to prevent unbounded growth
60
63
  static MAX_CACHE_ENTRIES_PER_SESSION = 10_000;
61
64
  parsedEntriesCache = new Map();
65
+ // Issue #840: Mutex to prevent TOCTOU race in findIdleSessionByWorkDir
66
+ sessionAcquireMutex = Promise.resolve();
62
67
  constructor(tmux, config) {
63
68
  this.tmux = tmux;
64
69
  this.config = config;
@@ -540,7 +545,6 @@ export class SessionManager {
540
545
  case 'TaskCompleted':
541
546
  case 'SessionEnd':
542
547
  case 'TeammateIdle':
543
- session.status = 'idle';
544
548
  break;
545
549
  case 'PreToolUse':
546
550
  case 'PostToolUse':
@@ -570,7 +574,16 @@ export class SessionManager {
570
574
  // Issue #87: Record hook receive timestamp for latency calculation
571
575
  session.lastHookReceivedAt = now;
572
576
  if (hookTimestamp) {
573
- session.lastHookEventAt = hookTimestamp;
577
+ // Issue #828: Clamp future timestamps to prevent clock skew corruption.
578
+ // If the client's clock is ahead of ours, store our timestamp instead.
579
+ if (hookTimestamp > now) {
580
+ console.warn(`updateStatusFromHook: clamping future hookTimestamp ` +
581
+ `(${hookTimestamp} > ${now}) for session ${id.slice(0, 8)}`);
582
+ session.lastHookEventAt = now;
583
+ }
584
+ else {
585
+ session.lastHookEventAt = hookTimestamp;
586
+ }
574
587
  }
575
588
  // Issue #87: Track permission prompt timestamp
576
589
  if (hookEvent === 'PermissionRequest') {
@@ -691,20 +704,42 @@ export class SessionManager {
691
704
  /** Issue #607: Find an idle session for the given workDir.
692
705
  * Returns the most recently active idle session, or null if none found.
693
706
  * Used to resume existing sessions instead of creating duplicates.
694
- * Issue #636: Verifies tmux window is still alive before returning. */
707
+ * Issue #636: Verifies tmux window is still alive before returning.
708
+ * Issue #840: Atomically acquires the session under a mutex to prevent TOCTOU race. */
695
709
  async findIdleSessionByWorkDir(workDir) {
696
- const candidates = Object.values(this.state.sessions).filter((s) => s.workDir === workDir && s.status === 'idle');
697
- if (candidates.length === 0)
698
- return null;
699
- // Return the most recently active session
700
- candidates.sort((a, b) => b.lastActivity - a.lastActivity);
701
- // Issue #636: verify tmux window exists before returning
702
- for (const candidate of candidates) {
703
- if (await this.tmux.windowExists(candidate.windowId)) {
704
- return candidate;
710
+ // Issue #840: Acquire mutex chain onto the previous operation
711
+ let release;
712
+ const lock = new Promise((resolve) => { release = resolve; });
713
+ const previous = this.sessionAcquireMutex;
714
+ this.sessionAcquireMutex = lock;
715
+ await previous.catch(() => { }); // tolerate prior rejection
716
+ try {
717
+ const candidates = Object.values(this.state.sessions).filter((s) => s.workDir === workDir && s.status === 'idle');
718
+ if (candidates.length === 0)
719
+ return null;
720
+ // Return the most recently active session
721
+ candidates.sort((a, b) => b.lastActivity - a.lastActivity);
722
+ // Issue #636: verify tmux window exists before returning
723
+ for (const candidate of candidates) {
724
+ if (await this.tmux.windowExists(candidate.windowId)) {
725
+ // Issue #840: Mark session as acquired immediately to prevent
726
+ // concurrent callers from grabbing the same session
727
+ candidate.status = 'acquired';
728
+ return candidate;
729
+ }
705
730
  }
731
+ return null;
732
+ }
733
+ finally {
734
+ release();
735
+ }
736
+ }
737
+ /** Release a session claim after the reuse path completes (success or failure). */
738
+ releaseSessionClaim(id) {
739
+ const session = this.state.sessions[id];
740
+ if (session) {
741
+ session.status = 'idle';
706
742
  }
707
- return null;
708
743
  }
709
744
  /** Get health info for a session.
710
745
  * Issue #2: Returns comprehensive health status for orchestrators.
@@ -800,8 +835,15 @@ export class SessionManager {
800
835
  if (!session)
801
836
  throw new Error(`Session ${id} not found`);
802
837
  const result = await this.tmux.sendKeysVerified(session.windowId, text);
803
- session.lastActivity = Date.now();
804
- await this.save();
838
+ if (result.delivered) {
839
+ session.lastActivity = Date.now();
840
+ try {
841
+ await this.save();
842
+ }
843
+ catch {
844
+ // Message was delivered — don't let a save failure mask the success
845
+ }
846
+ }
805
847
  return result;
806
848
  }
807
849
  /** Send message bypassing the tmux serialize queue.
@@ -820,8 +862,15 @@ export class SessionManager {
820
862
  throw new Error(`Session ${id} not found`);
821
863
  // Issue #285: Use verified sending with retry for reliability
822
864
  const result = await this.tmux.sendKeysVerified(session.windowId, text, 3);
823
- session.lastActivity = Date.now();
824
- await this.save();
865
+ if (result.delivered) {
866
+ session.lastActivity = Date.now();
867
+ try {
868
+ await this.save();
869
+ }
870
+ catch {
871
+ // Message was delivered — don't let a save failure mask the success
872
+ }
873
+ }
825
874
  return result;
826
875
  }
827
876
  /** Record that a permission prompt was detected for this session. */
@@ -1069,6 +1118,14 @@ export class SessionManager {
1069
1118
  const fromOffset = cached ? cached.offset : 0;
1070
1119
  const result = await readNewEntries(session.jsonlPath, fromOffset);
1071
1120
  if (cached) {
1121
+ // #832: Detect JSONL truncation — newOffset resets to 0 when file is rewritten.
1122
+ // readNewEntries returns empty entries + newOffset:0 on truncation.
1123
+ // Discard stale cached entries and rebuild from scratch.
1124
+ if (fromOffset > 0 && result.newOffset === 0 && result.entries.length === 0) {
1125
+ const freshResult = await readNewEntries(session.jsonlPath, 0);
1126
+ this.parsedEntriesCache.set(session.id, { entries: [...freshResult.entries], offset: freshResult.newOffset });
1127
+ return freshResult.entries;
1128
+ }
1072
1129
  cached.entries.push(...result.entries);
1073
1130
  cached.offset = result.newOffset;
1074
1131
  // #424: Evict oldest entries when cache exceeds per-session cap
@@ -1150,6 +1207,14 @@ export class SessionManager {
1150
1207
  this.pollTimers.delete(key);
1151
1208
  }
1152
1209
  }
1210
+ // #835: Clear discovery timeout timers to prevent orphan callbacks
1211
+ for (const key of [id, `fs-${id}`]) {
1212
+ const timeout = this.discoveryTimeouts.get(key);
1213
+ if (timeout) {
1214
+ clearTimeout(timeout);
1215
+ this.discoveryTimeouts.delete(key);
1216
+ }
1217
+ }
1153
1218
  this.cleanupPendingPermission(id);
1154
1219
  this.cleanupPendingQuestion(id);
1155
1220
  this.parsedEntriesCache.delete(id);
@@ -1289,7 +1354,9 @@ export class SessionManager {
1289
1354
  }, 2000);
1290
1355
  this.pollTimers.set(id, interval);
1291
1356
  // P3 fix: Stop after 5 minutes if not found, log timeout
1292
- setTimeout(() => {
1357
+ // #835: Track the timeout so cleanupSession can cancel it
1358
+ const discoveryTimeout = setTimeout(() => {
1359
+ this.discoveryTimeouts.delete(id);
1293
1360
  const timer = this.pollTimers.get(id);
1294
1361
  const session = this.state.sessions[id];
1295
1362
  if (timer) {
@@ -1301,6 +1368,7 @@ export class SessionManager {
1301
1368
  }
1302
1369
  }
1303
1370
  }, 5 * 60 * 1000);
1371
+ this.discoveryTimeouts.set(id, discoveryTimeout);
1304
1372
  }
1305
1373
  /** Issue #16: Filesystem-based discovery for --bare mode (no hooks).
1306
1374
  * Scans the Claude projects directory for new .jsonl files created after the session.
@@ -1348,13 +1416,16 @@ export class SessionManager {
1348
1416
  }, 3000);
1349
1417
  this.pollTimers.set(`fs-${id}`, interval);
1350
1418
  // Timeout after 5 minutes
1351
- setTimeout(() => {
1419
+ // #835: Track the timeout so cleanupSession can cancel it
1420
+ const fsDiscoveryTimeout = setTimeout(() => {
1421
+ this.discoveryTimeouts.delete(`fs-${id}`);
1352
1422
  const timer = this.pollTimers.get(`fs-${id}`);
1353
1423
  if (timer) {
1354
1424
  clearInterval(timer);
1355
1425
  this.pollTimers.delete(`fs-${id}`);
1356
1426
  }
1357
1427
  }, 5 * 60 * 1000);
1428
+ this.discoveryTimeouts.set(`fs-${id}`, fsDiscoveryTimeout);
1358
1429
  }
1359
1430
  /** Sync CC session IDs from the hook-written session_map.json. */
1360
1431
  async syncSessionMap() {
@@ -78,7 +78,7 @@ export class SSEWriter {
78
78
  this.heartbeatTimer = null;
79
79
  }
80
80
  try {
81
- this.res.destroy();
81
+ this.res.end();
82
82
  }
83
83
  catch { /* already closed */ }
84
84
  this.onCleanup();
package/dist/ssrf.d.ts CHANGED
@@ -35,8 +35,8 @@ export interface DnsLookupResult {
35
35
  address: string;
36
36
  family: number;
37
37
  }
38
- /** DNS lookup function type for dependency injection. */
39
- export type DnsLookupFn = (hostname: string) => Promise<DnsLookupResult>;
38
+ /** DNS lookup function type for dependency injection. Returns ALL addresses. */
39
+ export type DnsLookupFn = (hostname: string) => Promise<DnsLookupResult[]>;
40
40
  /**
41
41
  * Result of DNS resolution with SSRF check.
42
42
  * On success, includes the resolved IP address for TOCTOU-safe pinning.
@@ -69,6 +69,23 @@ export declare function resolveAndCheckIp(hostname: string, lookupFn?: DnsLookup
69
69
  * @returns The --host-resolver-rules argument string
70
70
  */
71
71
  export declare function buildHostResolverRule(hostname: string, resolvedIp: string): string;
72
+ /**
73
+ * Build a connection URL where the hostname is replaced by the resolved IP address.
74
+ *
75
+ * This prevents DNS rebinding (TOCTOU) attacks in HTTP clients (like Node fetch)
76
+ * by ensuring the connection goes to the validated IP, not a re-resolved address.
77
+ * The original hostname is returned separately so callers can set the Host header.
78
+ *
79
+ * For IPv6 addresses, wraps the IP in brackets per RFC 2732.
80
+ *
81
+ * @param originalUrl - The original URL (e.g. "https://example.com/path")
82
+ * @param resolvedIp - The validated IP address to connect to
83
+ * @returns Object with the connection URL and the original hostname for Host header
84
+ */
85
+ export declare function buildConnectionUrl(originalUrl: string, resolvedIp: string): {
86
+ connectionUrl: string;
87
+ hostHeader: string;
88
+ };
72
89
  /**
73
90
  * Validate a URL for the screenshot endpoint to prevent SSRF attacks.
74
91
  *
package/dist/ssrf.js CHANGED
@@ -150,8 +150,8 @@ export function validateWebhookUrl(rawUrl) {
150
150
  }
151
151
  return null;
152
152
  }
153
- /** Default DNS lookup using node:dns/promises. */
154
- const defaultLookup = (hostname) => dns.lookup(hostname);
153
+ /** Default DNS lookup using node:dns/promises with { all: true } to resolve all addresses. */
154
+ const defaultLookup = (hostname) => dns.lookup(hostname, { all: true });
155
155
  /**
156
156
  * Resolve a hostname via DNS and check if the resulting IP is private/internal.
157
157
  *
@@ -173,11 +173,20 @@ export async function resolveAndCheckIp(hostname, lookupFn = defaultLookup) {
173
173
  return { error: null, resolvedIp: hostname };
174
174
  }
175
175
  try {
176
- const result = await lookupFn(hostname);
177
- if (isPrivateIP(result.address)) {
178
- return { error: `DNS resolution points to a private/internal IP: ${result.address}`, resolvedIp: null };
176
+ const results = await lookupFn(hostname);
177
+ if (results.length === 0) {
178
+ return { error: `DNS resolution returned no addresses for ${hostname}`, resolvedIp: null };
179
179
  }
180
- return { error: null, resolvedIp: result.address };
180
+ // Check ALL resolved addresses — reject if ANY is private/internal.
181
+ // An attacker can configure DNS to return both public and private IPs;
182
+ // the HTTP client may connect to any of them.
183
+ for (const result of results) {
184
+ if (isPrivateIP(result.address)) {
185
+ return { error: `DNS resolution points to a private/internal IP: ${result.address}`, resolvedIp: null };
186
+ }
187
+ }
188
+ // All addresses safe — return first for TOCTOU-safe pinning via --host-resolver-rules.
189
+ return { error: null, resolvedIp: results[0].address };
181
190
  }
182
191
  catch { /* DNS lookup failed — treat as unsafe */
183
192
  return { error: `DNS resolution failed for ${hostname}`, resolvedIp: null };
@@ -196,6 +205,29 @@ export async function resolveAndCheckIp(hostname, lookupFn = defaultLookup) {
196
205
  export function buildHostResolverRule(hostname, resolvedIp) {
197
206
  return `MAP ${hostname} ${resolvedIp}`;
198
207
  }
208
+ /**
209
+ * Build a connection URL where the hostname is replaced by the resolved IP address.
210
+ *
211
+ * This prevents DNS rebinding (TOCTOU) attacks in HTTP clients (like Node fetch)
212
+ * by ensuring the connection goes to the validated IP, not a re-resolved address.
213
+ * The original hostname is returned separately so callers can set the Host header.
214
+ *
215
+ * For IPv6 addresses, wraps the IP in brackets per RFC 2732.
216
+ *
217
+ * @param originalUrl - The original URL (e.g. "https://example.com/path")
218
+ * @param resolvedIp - The validated IP address to connect to
219
+ * @returns Object with the connection URL and the original hostname for Host header
220
+ */
221
+ export function buildConnectionUrl(originalUrl, resolvedIp) {
222
+ const parsed = new URL(originalUrl);
223
+ const originalHost = parsed.host; // includes port if non-default
224
+ // IPv6 literals need brackets in URLs
225
+ const ipForUrl = parsed.hostname.startsWith('[') || resolvedIp.includes(':')
226
+ ? `[${resolvedIp}]`
227
+ : resolvedIp;
228
+ parsed.hostname = ipForUrl;
229
+ return { connectionUrl: parsed.toString(), hostHeader: originalHost };
230
+ }
199
231
  /**
200
232
  * Validate a URL for the screenshot endpoint to prevent SSRF attacks.
201
233
  *
package/dist/tmux.d.ts CHANGED
@@ -84,6 +84,10 @@ export declare class TmuxManager {
84
84
  * Values never appear in terminal scrollback or capture-pane output.
85
85
  */
86
86
  private setEnvSecure;
87
+ /** #837: Direct variant of setEnvSecure that uses sendKeysDirectInternal instead of
88
+ * sendKeys, safe to call from inside a serialize() callback without deadlocking.
89
+ * Identical logic otherwise. */
90
+ private setEnvSecureDirect;
87
91
  /** P1 fix: Check if a window exists. Returns true if window is in the session.
88
92
  * #357: Uses a short-lived cache to avoid repeated tmux CLI calls. */
89
93
  windowExists(windowId: string): Promise<boolean>;
@@ -132,13 +136,10 @@ export declare class TmuxManager {
132
136
  * that can leak through tmux's capture-pane into the output.
133
137
  */
134
138
  capturePane(windowId: string): Promise<string>;
135
- /** Capture pane content WITHOUT going through the serialize queue.
136
- * Used for critical-path operations (e.g., sendInitialPrompt) that should
137
- * not be delayed by monitor polls. The queue is for preventing race conditions
138
- * in monitor/concurrent reads, but sendInitialPrompt is the ONLY writer at
139
- * session creation time.
140
- * #403: During window creation (_creatingCount > 0), queues behind serialize
141
- * to avoid racing with the creation sequence.
139
+ /** Capture pane content through the serialize queue.
140
+ * #824: Always serialize to prevent race conditions with concurrent reads
141
+ * from monitor polls and ! command mode. The previous _creatingCount guard
142
+ * only queued during window creation, leaving a race window at other times.
142
143
  */
143
144
  capturePaneDirect(windowId: string): Promise<string>;
144
145
  private capturePaneDirectInternal;