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.
- package/dashboard/dist/assets/{index-DxAes2EQ.js → index-DIyuyrlO.js} +47 -47
- package/dashboard/dist/index.html +1 -1
- package/dist/auth.d.ts +3 -2
- package/dist/auth.js +42 -29
- package/dist/channels/manager.d.ts +8 -0
- package/dist/channels/manager.js +15 -0
- package/dist/channels/webhook.d.ts +2 -0
- package/dist/channels/webhook.js +57 -15
- package/dist/dashboard/assets/{index-DxAes2EQ.js → index-DIyuyrlO.js} +47 -47
- package/dist/dashboard/index.html +1 -1
- package/dist/events.d.ts +2 -0
- package/dist/events.js +16 -1
- package/dist/hook-settings.js +31 -11
- package/dist/hooks.js +2 -2
- package/dist/jsonl-watcher.js +9 -2
- package/dist/pipeline.js +10 -0
- package/dist/server.js +32 -7
- package/dist/session.d.ts +7 -1
- package/dist/session.js +93 -22
- package/dist/sse-writer.js +1 -1
- package/dist/ssrf.d.ts +19 -2
- package/dist/ssrf.js +38 -6
- package/dist/tmux.d.ts +8 -7
- package/dist/tmux.js +51 -36
- package/dist/transcript.js +12 -8
- package/dist/validation.d.ts +4 -0
- package/dist/validation.js +3 -2
- package/package.json +1 -1
|
@@ -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-
|
|
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
|
-
|
|
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
|
}
|
package/dist/hook-settings.js
CHANGED
|
@@ -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
|
-
|
|
94
|
-
|
|
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,
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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 =
|
|
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). */
|
package/dist/jsonl-watcher.js
CHANGED
|
@@ -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
|
-
//
|
|
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.
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
804
|
-
|
|
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
|
-
|
|
824
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() {
|
package/dist/sse-writer.js
CHANGED
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
|
|
177
|
-
if (
|
|
178
|
-
return { error: `DNS resolution
|
|
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
|
-
|
|
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
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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;
|