aegis-bridge 2.4.0 → 2.5.0

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 (46) hide show
  1. package/dashboard/dist/assets/{index-DPp-wise.css → index-B7DYf7vF.css} +1 -1
  2. package/dashboard/dist/assets/{index-I_vW1gcQ.js → index-DxAes2EQ.js} +47 -47
  3. package/dashboard/dist/index.html +2 -2
  4. package/dist/auth.js +1 -2
  5. package/dist/channels/index.js +0 -1
  6. package/dist/channels/manager.js +0 -1
  7. package/dist/channels/telegram-style.js +0 -1
  8. package/dist/channels/telegram.js +0 -1
  9. package/dist/channels/types.js +0 -1
  10. package/dist/channels/webhook.js +0 -1
  11. package/dist/cli.js +0 -1
  12. package/dist/config.js +11 -5
  13. package/dist/dashboard/assets/{index-DPp-wise.css → index-B7DYf7vF.css} +1 -1
  14. package/dist/dashboard/assets/{index-I_vW1gcQ.js → index-DxAes2EQ.js} +47 -47
  15. package/dist/dashboard/index.html +2 -2
  16. package/dist/error-categories.js +0 -1
  17. package/dist/events.d.ts +2 -0
  18. package/dist/events.js +21 -3
  19. package/dist/hook-settings.js +13 -7
  20. package/dist/hook.js +0 -1
  21. package/dist/hooks.js +21 -20
  22. package/dist/jsonl-watcher.js +0 -1
  23. package/dist/mcp-server.js +0 -1
  24. package/dist/metrics.d.ts +2 -0
  25. package/dist/metrics.js +30 -17
  26. package/dist/monitor.js +1 -2
  27. package/dist/permission-guard.js +0 -1
  28. package/dist/pipeline.js +0 -1
  29. package/dist/screenshot.js +0 -1
  30. package/dist/server.js +88 -274
  31. package/dist/session.js +14 -9
  32. package/dist/signal-cleanup-helper.js +0 -1
  33. package/dist/sse-limiter.js +0 -1
  34. package/dist/sse-writer.js +0 -1
  35. package/dist/ssrf.d.ts +4 -0
  36. package/dist/ssrf.js +23 -2
  37. package/dist/swarm-monitor.js +1 -3
  38. package/dist/terminal-parser.js +3 -2
  39. package/dist/tmux-capture-cache.js +0 -1
  40. package/dist/tmux.js +1 -2
  41. package/dist/transcript.js +53 -51
  42. package/dist/utils/redact-headers.js +0 -1
  43. package/dist/validation.d.ts +34 -2
  44. package/dist/validation.js +20 -4
  45. package/dist/ws-terminal.js +4 -3
  46. package/package.json +3 -3
@@ -5,8 +5,8 @@
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-I_vW1gcQ.js"></script>
9
- <link rel="stylesheet" crossorigin href="/dashboard/assets/index-DPp-wise.css">
8
+ <script type="module" crossorigin src="/dashboard/assets/index-DxAes2EQ.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/dashboard/assets/index-B7DYf7vF.css">
10
10
  </head>
11
11
  <body class="bg-[#0a0a0f] text-gray-200 antialiased">
12
12
  <div id="root"></div>
@@ -71,4 +71,3 @@ export function categorize(error) {
71
71
  export function shouldRetry(error) {
72
72
  return categorize(error).retryable;
73
73
  }
74
- //# sourceMappingURL=error-categories.js.map
package/dist/events.d.ts CHANGED
@@ -74,6 +74,8 @@ export declare class SessionEventBus {
74
74
  subscriberCount(sessionId: string): number;
75
75
  /** Global emitter for aggregating events across all sessions. */
76
76
  private globalEmitter;
77
+ /** #689: Pending setImmediate timers for cleanup on destroy. */
78
+ private pendingTimers;
77
79
  /** Subscribe to events from ALL sessions (new and existing). Returns unsubscribe function. */
78
80
  subscribeGlobal(handler: (event: GlobalSSEEvent) => void): () => void;
79
81
  /** Emit a session created event to global subscribers. */
package/dist/events.js CHANGED
@@ -98,7 +98,11 @@ export class SessionEventBus {
98
98
  }
99
99
  const emitter = this.emitters.get(sessionId);
100
100
  if (emitter) {
101
- setImmediate(() => emitter.emit('event', event));
101
+ const imm = setImmediate(() => {
102
+ this.pendingTimers.delete(imm);
103
+ emitter.emit('event', event);
104
+ });
105
+ this.pendingTimers.add(imm);
102
106
  }
103
107
  // Forward to global subscribers
104
108
  if (this.globalEmitter) {
@@ -108,7 +112,11 @@ export class SessionEventBus {
108
112
  if (this.globalEventBuffer.length > SessionEventBus.BUFFER_SIZE) {
109
113
  this.globalEventBuffer.splice(0, this.globalEventBuffer.length - SessionEventBus.BUFFER_SIZE);
110
114
  }
111
- setImmediate(() => this.globalEmitter.emit('event', globalEvent));
115
+ const imm = setImmediate(() => {
116
+ this.pendingTimers.delete(imm);
117
+ this.globalEmitter?.emit('event', globalEvent);
118
+ });
119
+ this.pendingTimers.add(imm);
112
120
  }
113
121
  }
114
122
  /** Get events emitted after the given event ID for a session. */
@@ -217,6 +225,8 @@ export class SessionEventBus {
217
225
  // ── Global (all-session) SSE ──────────────────────────────────────
218
226
  /** Global emitter for aggregating events across all sessions. */
219
227
  globalEmitter = null;
228
+ /** #689: Pending setImmediate timers for cleanup on destroy. */
229
+ pendingTimers = new Set();
220
230
  /** Subscribe to events from ALL sessions (new and existing). Returns unsubscribe function. */
221
231
  subscribeGlobal(handler) {
222
232
  if (!this.globalEmitter) {
@@ -226,6 +236,10 @@ export class SessionEventBus {
226
236
  this.globalEmitter.on('event', handler);
227
237
  return () => {
228
238
  this.globalEmitter?.off('event', handler);
239
+ // #689: Nullify globalEmitter when all subscribers leave
240
+ if (this.globalEmitter && this.globalEmitter.listenerCount('event') === 0) {
241
+ this.globalEmitter = null;
242
+ }
229
243
  };
230
244
  }
231
245
  /** Emit a session created event to global subscribers. */
@@ -262,6 +276,11 @@ export class SessionEventBus {
262
276
  }
263
277
  /** Clean up all emitters. */
264
278
  destroy() {
279
+ // #689: Clear pending setImmediate timers before removing listeners
280
+ for (const imm of this.pendingTimers) {
281
+ clearImmediate(imm);
282
+ }
283
+ this.pendingTimers.clear();
265
284
  for (const emitter of this.emitters.values()) {
266
285
  emitter.removeAllListeners();
267
286
  }
@@ -272,4 +291,3 @@ export class SessionEventBus {
272
291
  this.globalEmitter = null;
273
292
  }
274
293
  }
275
- //# sourceMappingURL=events.js.map
@@ -13,10 +13,11 @@
13
13
  *
14
14
  * Issue #169: Phase 2 — Inject CC settings.json with HTTP hooks.
15
15
  */
16
- import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
16
+ import { readFile, writeFile, unlink, mkdir, rmdir } from 'node:fs/promises';
17
17
  import { existsSync } from 'node:fs';
18
18
  import { join } from 'node:path';
19
19
  import { tmpdir } from 'node:os';
20
+ import { randomBytes } from 'node:crypto';
20
21
  import { ccSettingsSchema } from './validation.js';
21
22
  /** CC hook events that support `type: "http"`.
22
23
  *
@@ -112,12 +113,13 @@ export async function writeHookSettingsFile(baseUrl, sessionId, workDir) {
112
113
  ...hookSettings.hooks,
113
114
  },
114
115
  };
115
- const settingsDir = join(tmpdir(), 'aegis-hooks');
116
- if (!existsSync(settingsDir)) {
117
- await mkdir(settingsDir, { recursive: true });
118
- }
116
+ // Issue #648: Use unpredictable directory name and restrictive permissions
117
+ // to prevent symlink attacks and information disclosure in /tmp.
118
+ const suffix = randomBytes(4).toString('hex');
119
+ const settingsDir = join(tmpdir(), `aegis-hooks-${suffix}`);
120
+ await mkdir(settingsDir, { recursive: true, mode: 0o700 });
119
121
  const filePath = join(settingsDir, `hooks-${sessionId}.json`);
120
- await writeFile(filePath, JSON.stringify(combined, null, 2) + '\n', 'utf-8');
122
+ await writeFile(filePath, JSON.stringify(combined, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
121
123
  return filePath;
122
124
  }
123
125
  /**
@@ -129,10 +131,14 @@ export async function cleanupHookSettingsFile(filePath) {
129
131
  try {
130
132
  if (existsSync(filePath)) {
131
133
  await unlink(filePath);
134
+ // Issue #648: Also remove the randomized parent directory
135
+ const parentDir = join(filePath, '..');
136
+ await rmdir(parentDir).catch(() => {
137
+ // Non-fatal: directory may not be empty or already removed
138
+ });
132
139
  }
133
140
  }
134
141
  catch {
135
142
  // Non-fatal: temp file cleanup failed
136
143
  }
137
144
  }
138
- //# sourceMappingURL=hook-settings.js.map
package/dist/hook.js CHANGED
@@ -196,4 +196,3 @@ function install() {
196
196
  console.log(`Aegis hook installed in ${settingsPath}`);
197
197
  }
198
198
  main();
199
- //# sourceMappingURL=hook.js.map
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 } from './validation.js';
17
+ import { isValidUUID, hookBodySchema } 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. */
@@ -114,10 +114,15 @@ export function registerHookRoutes(app, deps) {
114
114
  if (!session) {
115
115
  return reply.status(404).send({ error: `Session ${sessionId} not found` });
116
116
  }
117
+ // Issue #665: Validate hook body with Zod instead of unsafe casts
118
+ const parseResult = hookBodySchema.safeParse(req.body);
119
+ if (!parseResult.success) {
120
+ return reply.status(400).send({ error: `Invalid hook body: ${parseResult.error.message}` });
121
+ }
122
+ const hookBody = parseResult.data;
117
123
  // Issue #88: Track active subagents
118
- const hookBody = req.body;
119
124
  if (eventName === 'SubagentStart') {
120
- const agentName = hookBody?.agent_name || hookBody?.tool_input?.command || 'unknown';
125
+ const agentName = hookBody.agent_name || hookBody.tool_input?.command || 'unknown';
121
126
  deps.sessions.addSubagent(sessionId, agentName);
122
127
  deps.eventBus.emit(sessionId, {
123
128
  event: 'subagent_start',
@@ -127,7 +132,7 @@ export function registerHookRoutes(app, deps) {
127
132
  });
128
133
  }
129
134
  else if (eventName === 'SubagentStop') {
130
- const agentName = hookBody?.agent_name || 'unknown';
135
+ const agentName = hookBody.agent_name || 'unknown';
131
136
  deps.sessions.removeSubagent(sessionId, agentName);
132
137
  deps.eventBus.emit(sessionId, {
133
138
  event: 'subagent_stop',
@@ -149,16 +154,15 @@ export function registerHookRoutes(app, deps) {
149
154
  if (eventName === 'PreCompact' || eventName === 'PostCompact') {
150
155
  session.lastActivity = Date.now();
151
156
  }
152
- // Forward the raw hook event to SSE subscribers
153
- deps.eventBus.emitHook(sessionId, eventName, req.body);
157
+ // Forward the validated hook event to SSE subscribers
158
+ deps.eventBus.emitHook(sessionId, eventName, hookBody);
154
159
  // Issue #89 L25: Capture model field from hook payload for dashboard display
155
- const hookPayload = req.body;
156
- if (hookPayload?.model && typeof hookPayload.model === 'string') {
157
- deps.sessions.updateSessionModel(sessionId, hookPayload.model);
160
+ if (hookBody.model) {
161
+ deps.sessions.updateSessionModel(sessionId, hookBody.model);
158
162
  }
159
163
  // Issue #89 L24: Validate permission_mode from PermissionRequest hook
160
164
  if (eventName === 'PermissionRequest') {
161
- const rawMode = hookBody?.permission_mode;
165
+ const rawMode = hookBody.permission_mode;
162
166
  if (rawMode !== undefined && !VALID_PERMISSION_MODES.has(rawMode)) {
163
167
  console.warn(`Hooks: invalid permission_mode "${rawMode}" from PermissionRequest, using "default"`);
164
168
  hookBody.permission_mode = 'default';
@@ -167,8 +171,8 @@ export function registerHookRoutes(app, deps) {
167
171
  // Issue #169 Phase 3: Update session status from hook event
168
172
  // Issue #87: Extract timestamp from hook payload for latency calculation
169
173
  const hookReceivedAt = Date.now();
170
- const hookEventTimestamp = hookPayload?.timestamp
171
- ? new Date(hookPayload.timestamp).getTime()
174
+ const hookEventTimestamp = hookBody.timestamp
175
+ ? new Date(hookBody.timestamp).getTime()
172
176
  : undefined;
173
177
  // Issue #87: Record hook latency if we have a timestamp from the payload
174
178
  if (hookEventTimestamp && deps.metrics) {
@@ -202,22 +206,20 @@ export function registerHookRoutes(app, deps) {
202
206
  deps.eventBus.emitStatus(sessionId, 'working', 'Elicitation result received (hook: ElicitationResult)');
203
207
  break;
204
208
  case 'PermissionRequest':
205
- deps.eventBus.emitApproval(sessionId, req.body?.permission_prompt
206
- || 'Permission requested (hook)');
209
+ deps.eventBus.emitApproval(sessionId, hookBody.permission_prompt || 'Permission requested (hook)');
207
210
  break;
208
211
  }
209
212
  }
210
213
  // Decision events need a response body that CC uses
211
214
  // Format: { hookSpecificOutput: { hookEventName, permissionDecision, reason? } }
212
215
  if (DECISION_EVENTS.has(eventName)) {
213
- const hookBody = req.body;
214
- const toolName = hookBody?.tool_name || '';
215
- const permissionPrompt = hookBody?.permission_prompt || '';
216
+ const toolName = hookBody.tool_name || '';
217
+ const permissionPrompt = hookBody.permission_prompt || '';
216
218
  if (eventName === 'PreToolUse') {
217
219
  // Issue #336: Intercept AskUserQuestion for headless question answering
218
220
  if (toolName === 'AskUserQuestion') {
219
- const toolInput = hookBody?.tool_input;
220
- const toolUseId = hookBody?.tool_use_id || '';
221
+ const toolInput = hookBody.tool_input;
222
+ const toolUseId = hookBody.tool_use_id || '';
221
223
  const questionText = extractQuestionText(toolInput);
222
224
  // Emit ask_question SSE event for external clients
223
225
  deps.eventBus.emit(sessionId, {
@@ -281,4 +283,3 @@ export function registerHookRoutes(app, deps) {
281
283
  return reply.status(200).send({ ok: true });
282
284
  });
283
285
  }
284
- //# sourceMappingURL=hooks.js.map
@@ -156,4 +156,3 @@ export class JsonlWatcher {
156
156
  }
157
157
  }
158
158
  }
159
- //# sourceMappingURL=jsonl-watcher.js.map
@@ -786,4 +786,3 @@ export async function startMcpServer(port, authToken) {
786
786
  await server.connect(transport);
787
787
  // Server runs until stdin closes
788
788
  }
789
- //# sourceMappingURL=mcp-server.js.map
package/dist/metrics.d.ts CHANGED
@@ -93,6 +93,8 @@ export declare class MetricsCollector {
93
93
  recordPermissionResponse(sessionId: string, latencyMs: number): void;
94
94
  recordChannelDelivery(sessionId: string, latencyMs: number): void;
95
95
  private summarizeSamples;
96
+ /** Stream-aggregate a single latency field across all sessions without creating temp arrays. */
97
+ private aggregateLatencyField;
96
98
  getSessionLatency(sessionId: string): SessionLatencySummary | null;
97
99
  /** Clean up latency data for a session (called on session kill). */
98
100
  clearSessionLatency(sessionId: string): void;
package/dist/metrics.js CHANGED
@@ -150,6 +150,27 @@ export class MetricsCollector {
150
150
  }
151
151
  return { min, max, avg: Math.round(sum / samples.length), count: samples.length };
152
152
  }
153
+ /** Stream-aggregate a single latency field across all sessions without creating temp arrays. */
154
+ aggregateLatencyField(field) {
155
+ let min;
156
+ let max;
157
+ let sum = 0;
158
+ let count = 0;
159
+ for (const lat of this.latency.values()) {
160
+ const samples = lat[field];
161
+ for (const s of samples) {
162
+ if (min === undefined || s < min)
163
+ min = s;
164
+ if (max === undefined || s > max)
165
+ max = s;
166
+ sum += s;
167
+ count++;
168
+ }
169
+ }
170
+ if (count === 0)
171
+ return { min: null, max: null, avg: null, count: 0 };
172
+ return { min: min, max: max, avg: Math.round(sum / count), count };
173
+ }
153
174
  getSessionLatency(sessionId) {
154
175
  const lat = this.latency.get(sessionId);
155
176
  if (!lat)
@@ -173,17 +194,11 @@ export class MetricsCollector {
173
194
  getGlobalMetrics(activeSessionCount) {
174
195
  const avgMessages = this.global.sessionsCreated > 0
175
196
  ? Math.round(this.global.totalMessages / this.global.sessionsCreated) : 0;
176
- // Issue #87: Aggregate latency across all sessions
177
- const allHookLatency = [];
178
- const allStateChange = [];
179
- const allPermissionResponse = [];
180
- const allChannelDelivery = [];
181
- for (const lat of this.latency.values()) {
182
- allHookLatency.push(...lat.hook_latency_ms);
183
- allStateChange.push(...lat.state_change_detection_ms);
184
- allPermissionResponse.push(...lat.permission_response_ms);
185
- allChannelDelivery.push(...lat.channel_delivery_ms);
186
- }
197
+ // Issue #87: Stream-aggregate latency across all sessions (no temp arrays)
198
+ const aggHook = this.aggregateLatencyField('hook_latency_ms');
199
+ const aggStateChange = this.aggregateLatencyField('state_change_detection_ms');
200
+ const aggPermission = this.aggregateLatencyField('permission_response_ms');
201
+ const aggChannel = this.aggregateLatencyField('channel_delivery_ms');
187
202
  return {
188
203
  uptime: Math.round((Date.now() - this.startTime) / 1000),
189
204
  sessions: {
@@ -207,12 +222,11 @@ export class MetricsCollector {
207
222
  success_rate: this.global.promptsSent > 0
208
223
  ? Math.round((this.global.promptsDelivered / this.global.promptsSent) * 100) : null,
209
224
  },
210
- // Issue #87: Aggregate latency metrics
211
225
  latency: {
212
- hook_latency_ms: this.summarizeSamples(allHookLatency),
213
- state_change_detection_ms: this.summarizeSamples(allStateChange),
214
- permission_response_ms: this.summarizeSamples(allPermissionResponse),
215
- channel_delivery_ms: this.summarizeSamples(allChannelDelivery),
226
+ hook_latency_ms: aggHook,
227
+ state_change_detection_ms: aggStateChange,
228
+ permission_response_ms: aggPermission,
229
+ channel_delivery_ms: aggChannel,
216
230
  },
217
231
  };
218
232
  }
@@ -223,4 +237,3 @@ export class MetricsCollector {
223
237
  return this.global.sessionsCreated;
224
238
  }
225
239
  }
226
- //# sourceMappingURL=metrics.js.map
package/dist/monitor.js CHANGED
@@ -234,7 +234,7 @@ export class SessionMonitor {
234
234
  await this.channels.statusChange(this.makePayload('status.permission_timeout', session, detail));
235
235
  }
236
236
  catch (e) {
237
- console.error(`Monitor: auto-reject failed for session ${session.id}: ${e.message}`);
237
+ console.error(`Monitor: auto-reject failed for session ${session.id}: ${e instanceof Error ? e.message : String(e)}`);
238
238
  }
239
239
  }
240
240
  }
@@ -619,4 +619,3 @@ export class SessionMonitor {
619
619
  function sleep(ms) {
620
620
  return new Promise(resolve => setTimeout(resolve, ms));
621
621
  }
622
- //# sourceMappingURL=monitor.js.map
@@ -194,4 +194,3 @@ export async function cleanOrphanedBackup(workDir, homeDir) {
194
194
  }
195
195
  }
196
196
  }
197
- //# sourceMappingURL=permission-guard.js.map
package/dist/pipeline.js CHANGED
@@ -220,4 +220,3 @@ export class PipelineManager {
220
220
  }
221
221
  }
222
222
  }
223
- //# sourceMappingURL=pipeline.js.map
@@ -54,4 +54,3 @@ export async function captureScreenshot(opts) {
54
54
  export function isPlaywrightAvailable() {
55
55
  return playwrightAvailable;
56
56
  }
57
- //# sourceMappingURL=screenshot.js.map