aegis-bridge 2.6.0 → 2.6.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 (40) hide show
  1. package/dashboard/dist/assets/index-9Hkkvm_I.css +32 -0
  2. package/dashboard/dist/assets/index-Bfabq3q-.js +262 -0
  3. package/dashboard/dist/index.html +2 -2
  4. package/dist/config.d.ts +2 -0
  5. package/dist/config.js +3 -0
  6. package/dist/continuation-pointer.d.ts +11 -0
  7. package/dist/continuation-pointer.js +64 -0
  8. package/dist/dashboard/assets/index-9Hkkvm_I.css +32 -0
  9. package/dist/dashboard/assets/index-Bfabq3q-.js +262 -0
  10. package/dist/dashboard/index.html +2 -2
  11. package/dist/diagnostics.d.ts +27 -0
  12. package/dist/diagnostics.js +95 -0
  13. package/dist/events.d.ts +14 -0
  14. package/dist/events.js +43 -14
  15. package/dist/fault-injection.d.ts +29 -0
  16. package/dist/fault-injection.js +115 -0
  17. package/dist/handshake.d.ts +21 -1
  18. package/dist/handshake.js +37 -3
  19. package/dist/hook-settings.d.ts +3 -2
  20. package/dist/hook-settings.js +6 -4
  21. package/dist/hook.js +10 -1
  22. package/dist/hooks.js +5 -0
  23. package/dist/logger.d.ts +35 -0
  24. package/dist/logger.js +65 -0
  25. package/dist/monitor.js +72 -11
  26. package/dist/permission-routes.d.ts +7 -0
  27. package/dist/permission-routes.js +28 -0
  28. package/dist/server.js +119 -44
  29. package/dist/session.d.ts +1 -0
  30. package/dist/session.js +41 -23
  31. package/dist/tmux.js +2 -2
  32. package/dist/utils/circular-buffer.d.ts +11 -0
  33. package/dist/utils/circular-buffer.js +37 -0
  34. package/dist/validation.d.ts +34 -0
  35. package/dist/validation.js +45 -3
  36. package/package.json +3 -2
  37. package/dashboard/dist/assets/index-B7DYf7vF.css +0 -32
  38. package/dashboard/dist/assets/index-DIyuyrlO.js +0 -262
  39. package/dist/dashboard/assets/index-B7DYf7vF.css +0 -32
  40. package/dist/dashboard/assets/index-DIyuyrlO.js +0 -262
@@ -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-DIyuyrlO.js"></script>
9
- <link rel="stylesheet" crossorigin href="/dashboard/assets/index-B7DYf7vF.css">
8
+ <script type="module" crossorigin src="/dashboard/assets/index-Bfabq3q-.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/dashboard/assets/index-9Hkkvm_I.css">
10
10
  </head>
11
11
  <body class="bg-[#0a0a0f] text-gray-200 antialiased">
12
12
  <div id="root"></div>
@@ -0,0 +1,27 @@
1
+ /**
2
+ * diagnostics.ts - no-PII diagnostics event stream with bounded in-memory buffer.
3
+ */
4
+ export type DiagnosticsLevel = 'info' | 'warn' | 'error';
5
+ export interface DiagnosticsEvent {
6
+ event: string;
7
+ level: DiagnosticsLevel;
8
+ component: string;
9
+ operation: string;
10
+ sessionId?: string;
11
+ errorCode?: string;
12
+ timestamp: string;
13
+ attributes: Record<string, unknown>;
14
+ }
15
+ export declare const DEFAULT_DIAGNOSTICS_BUFFER_SIZE = 100;
16
+ export declare function sanitizeDiagnosticsAttributes(attributes: Record<string, unknown> | undefined): Record<string, unknown>;
17
+ export declare class DiagnosticsBus {
18
+ private readonly maxEntries;
19
+ private readonly emitter;
20
+ private readonly buffer;
21
+ constructor(maxEntries?: number);
22
+ emit(event: DiagnosticsEvent): void;
23
+ subscribe(handler: (event: DiagnosticsEvent) => void): () => void;
24
+ getRecent(limit?: number): DiagnosticsEvent[];
25
+ clear(): void;
26
+ }
27
+ export declare const diagnosticsBus: DiagnosticsBus;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * diagnostics.ts - no-PII diagnostics event stream with bounded in-memory buffer.
3
+ */
4
+ import { EventEmitter } from 'node:events';
5
+ export const DEFAULT_DIAGNOSTICS_BUFFER_SIZE = 100;
6
+ const MAX_DIAGNOSTICS_STRING_LENGTH = 200;
7
+ const MAX_SANITIZE_DEPTH = 4;
8
+ const FORBIDDEN_KEY_FRAGMENTS = [
9
+ 'token',
10
+ 'password',
11
+ 'secret',
12
+ 'authorization',
13
+ 'cookie',
14
+ 'auth',
15
+ 'api_key',
16
+ 'apikey',
17
+ 'prompt',
18
+ 'transcript',
19
+ 'payload',
20
+ 'workdir',
21
+ ];
22
+ function isForbiddenAttribute(key) {
23
+ const normalized = key.toLowerCase();
24
+ return FORBIDDEN_KEY_FRAGMENTS.some(fragment => normalized.includes(fragment));
25
+ }
26
+ function sanitizeValue(value, depth = 0) {
27
+ if (depth > MAX_SANITIZE_DEPTH)
28
+ return '[TRUNCATED]';
29
+ if (typeof value === 'string') {
30
+ return value.length > MAX_DIAGNOSTICS_STRING_LENGTH
31
+ ? `${value.slice(0, MAX_DIAGNOSTICS_STRING_LENGTH)}...`
32
+ : value;
33
+ }
34
+ if (value === null || typeof value === 'number' || typeof value === 'boolean') {
35
+ return value;
36
+ }
37
+ if (Array.isArray(value)) {
38
+ return value.map(item => sanitizeValue(item, depth + 1));
39
+ }
40
+ if (typeof value === 'object') {
41
+ const sanitizedObject = {};
42
+ for (const [key, nested] of Object.entries(value)) {
43
+ if (isForbiddenAttribute(key))
44
+ continue;
45
+ sanitizedObject[key] = sanitizeValue(nested, depth + 1);
46
+ }
47
+ return sanitizedObject;
48
+ }
49
+ if (value === undefined)
50
+ return undefined;
51
+ return String(value);
52
+ }
53
+ export function sanitizeDiagnosticsAttributes(attributes) {
54
+ if (!attributes)
55
+ return {};
56
+ const sanitized = sanitizeValue(attributes);
57
+ return (typeof sanitized === 'object' && sanitized !== null && !Array.isArray(sanitized))
58
+ ? sanitized
59
+ : {};
60
+ }
61
+ function sanitizeDiagnosticsEvent(event) {
62
+ return {
63
+ ...event,
64
+ attributes: sanitizeDiagnosticsAttributes(event.attributes),
65
+ };
66
+ }
67
+ export class DiagnosticsBus {
68
+ maxEntries;
69
+ emitter = new EventEmitter();
70
+ buffer = [];
71
+ constructor(maxEntries = DEFAULT_DIAGNOSTICS_BUFFER_SIZE) {
72
+ this.maxEntries = maxEntries;
73
+ }
74
+ emit(event) {
75
+ const sanitizedEvent = sanitizeDiagnosticsEvent(event);
76
+ this.buffer.push(sanitizedEvent);
77
+ if (this.buffer.length > this.maxEntries) {
78
+ this.buffer.splice(0, this.buffer.length - this.maxEntries);
79
+ }
80
+ this.emitter.emit('event', sanitizedEvent);
81
+ }
82
+ subscribe(handler) {
83
+ this.emitter.on('event', handler);
84
+ return () => this.emitter.off('event', handler);
85
+ }
86
+ getRecent(limit = this.maxEntries) {
87
+ if (limit <= 0)
88
+ return [];
89
+ return this.buffer.slice(-Math.min(limit, this.maxEntries));
90
+ }
91
+ clear() {
92
+ this.buffer.length = 0;
93
+ }
94
+ }
95
+ export const diagnosticsBus = new DiagnosticsBus();
package/dist/events.d.ts CHANGED
@@ -49,6 +49,20 @@ export declare class SessionEventBus {
49
49
  emit(sessionId: string, event: SessionSSEEvent): void;
50
50
  /** Get events emitted after the given event ID for a session. */
51
51
  getEventsSince(sessionId: string, lastEventId: number): SessionSSEEvent[];
52
+ /**
53
+ * Cursor-based replay window for session events.
54
+ *
55
+ * - `beforeId` is an exclusive upper bound on event ID.
56
+ * - If omitted, returns the newest `limit` buffered events.
57
+ * - Returns events in ascending ID order.
58
+ */
59
+ getEventsBefore(sessionId: string, beforeId?: number, limit?: number): {
60
+ events: SessionSSEEvent[];
61
+ before_id: number | null;
62
+ has_more: boolean;
63
+ oldest_id: number | null;
64
+ newest_id: number | null;
65
+ };
52
66
  /** Emit a status change event. */
53
67
  emitStatus(sessionId: string, status: string, detail: string): void;
54
68
  /** Emit a message event. */
package/dist/events.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * The monitor pushes events; the SSE route consumes them.
7
7
  */
8
8
  import { EventEmitter } from 'node:events';
9
+ import { CircularBuffer } from './utils/circular-buffer.js';
9
10
  /** Map per-session event types to global event types. */
10
11
  function toGlobalEvent(event) {
11
12
  const typeMap = {
@@ -52,7 +53,7 @@ export class SessionEventBus {
52
53
  /** Per-session ring buffer for event replay. */
53
54
  eventBuffers = new Map();
54
55
  /** Global ring buffer for event replay across all sessions (Issue #301). */
55
- globalEventBuffer = [];
56
+ globalEventBuffer = new CircularBuffer(SessionEventBus.BUFFER_SIZE);
56
57
  /** Get or create the emitter for a session. */
57
58
  getEmitter(sessionId) {
58
59
  let emitter = this.emitters.get(sessionId);
@@ -89,13 +90,10 @@ export class SessionEventBus {
89
90
  // Push to ring buffer
90
91
  let buffer = this.eventBuffers.get(sessionId);
91
92
  if (!buffer) {
92
- buffer = [];
93
+ buffer = new CircularBuffer(SessionEventBus.BUFFER_SIZE);
93
94
  this.eventBuffers.set(sessionId, buffer);
94
95
  }
95
96
  buffer.push({ id: event.id, event });
96
- if (buffer.length > SessionEventBus.BUFFER_SIZE) {
97
- buffer.splice(0, buffer.length - SessionEventBus.BUFFER_SIZE);
98
- }
99
97
  const emitter = this.emitters.get(sessionId);
100
98
  if (emitter) {
101
99
  const imm = setImmediate(() => {
@@ -109,9 +107,6 @@ export class SessionEventBus {
109
107
  const globalEvent = toGlobalEvent(event);
110
108
  // Issue #301: push to global ring buffer
111
109
  this.globalEventBuffer.push({ id: event.id, event: globalEvent });
112
- if (this.globalEventBuffer.length > SessionEventBus.BUFFER_SIZE) {
113
- this.globalEventBuffer.splice(0, this.globalEventBuffer.length - SessionEventBus.BUFFER_SIZE);
114
- }
115
110
  const imm = setImmediate(() => {
116
111
  this.pendingTimers.delete(imm);
117
112
  this.globalEmitter?.emit('event', globalEvent);
@@ -124,7 +119,44 @@ export class SessionEventBus {
124
119
  const buffer = this.eventBuffers.get(sessionId);
125
120
  if (!buffer)
126
121
  return [];
127
- return buffer.filter(e => e.id > lastEventId).map(e => e.event);
122
+ return buffer.toArray().filter(e => e.id > lastEventId).map(e => e.event);
123
+ }
124
+ /**
125
+ * Cursor-based replay window for session events.
126
+ *
127
+ * - `beforeId` is an exclusive upper bound on event ID.
128
+ * - If omitted, returns the newest `limit` buffered events.
129
+ * - Returns events in ascending ID order.
130
+ */
131
+ getEventsBefore(sessionId, beforeId, limit = 50) {
132
+ const buffer = this.eventBuffers.get(sessionId);
133
+ if (!buffer) {
134
+ return {
135
+ events: [],
136
+ before_id: null,
137
+ has_more: false,
138
+ oldest_id: null,
139
+ newest_id: null,
140
+ };
141
+ }
142
+ const entries = buffer.toArray();
143
+ const clampedLimit = Math.min(SessionEventBus.BUFFER_SIZE, Math.max(1, limit));
144
+ const upperExclusive = beforeId !== undefined
145
+ ? entries.findIndex(e => e.id >= beforeId)
146
+ : entries.length;
147
+ const resolvedUpperExclusive = upperExclusive === -1 ? entries.length : upperExclusive;
148
+ const lowerInclusive = Math.max(0, resolvedUpperExclusive - clampedLimit);
149
+ const window = entries.slice(lowerInclusive, resolvedUpperExclusive);
150
+ const events = window.map(e => e.event);
151
+ const oldestId = window.length > 0 ? window[0].id : null;
152
+ const newestId = window.length > 0 ? window[window.length - 1].id : null;
153
+ return {
154
+ events,
155
+ before_id: oldestId,
156
+ has_more: lowerInclusive > 0,
157
+ oldest_id: oldestId,
158
+ newest_id: newestId,
159
+ };
128
160
  }
129
161
  /** Emit a status change event. */
130
162
  emitStatus(sessionId, status, detail) {
@@ -261,14 +293,11 @@ export class SessionEventBus {
261
293
  };
262
294
  // Issue #301: buffer global-only events
263
295
  this.globalEventBuffer.push({ id, event: globalEvent });
264
- if (this.globalEventBuffer.length > SessionEventBus.BUFFER_SIZE) {
265
- this.globalEventBuffer.splice(0, this.globalEventBuffer.length - SessionEventBus.BUFFER_SIZE);
266
- }
267
296
  this.globalEmitter.emit('event', globalEvent);
268
297
  }
269
298
  /** Get global events emitted after the given event ID (Issue #301). */
270
299
  getGlobalEventsSince(lastEventId) {
271
- return this.globalEventBuffer.filter(e => e.id > lastEventId);
300
+ return this.globalEventBuffer.toArray().filter(e => e.id > lastEventId);
272
301
  }
273
302
  /** #398: Clean up per-session state (call when session is killed). */
274
303
  cleanupSession(sessionId) {
@@ -301,7 +330,7 @@ export class SessionEventBus {
301
330
  }
302
331
  this.emitters.clear();
303
332
  this.eventBuffers.clear();
304
- this.globalEventBuffer = [];
333
+ this.globalEventBuffer.clear();
305
334
  this.globalEmitter?.removeAllListeners();
306
335
  this.globalEmitter = null;
307
336
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * fault-injection.ts — deterministic fault injection harness for integration tests.
3
+ *
4
+ * Disabled by default in production. Enable with AEGIS_FAULT_INJECTION=1
5
+ * or via test helpers.
6
+ */
7
+ export type FaultMode = 'transient' | 'fatal' | 'delay';
8
+ export interface FaultRule {
9
+ point: string;
10
+ mode: FaultMode;
11
+ every?: number;
12
+ probability?: number;
13
+ delayMs?: number;
14
+ errorMessage?: string;
15
+ }
16
+ export declare class InjectedTransientFaultError extends Error {
17
+ readonly point: string;
18
+ constructor(point: string, message?: string);
19
+ }
20
+ export declare class InjectedFatalFaultError extends Error {
21
+ readonly point: string;
22
+ constructor(point: string, message?: string);
23
+ }
24
+ export declare function maybeInjectFault(point: string): Promise<void>;
25
+ export declare function resetFaultInjection(): void;
26
+ export declare function clearFaultRules(): void;
27
+ export declare function setFaultInjectionEnabledForTest(enabled: boolean): void;
28
+ export declare function setFaultInjectionSeedForTest(seed: number): void;
29
+ export declare function addFaultRule(rule: FaultRule): void;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * fault-injection.ts — deterministic fault injection harness for integration tests.
3
+ *
4
+ * Disabled by default in production. Enable with AEGIS_FAULT_INJECTION=1
5
+ * or via test helpers.
6
+ */
7
+ export class InjectedTransientFaultError extends Error {
8
+ point;
9
+ constructor(point, message) {
10
+ super(message ?? `Injected transient fault at ${point}`);
11
+ this.name = 'InjectedTransientFaultError';
12
+ this.point = point;
13
+ }
14
+ }
15
+ export class InjectedFatalFaultError extends Error {
16
+ point;
17
+ constructor(point, message) {
18
+ super(message ?? `Injected fatal fault at ${point}`);
19
+ this.name = 'InjectedFatalFaultError';
20
+ this.point = point;
21
+ }
22
+ }
23
+ class FaultInjector {
24
+ rules = [];
25
+ hitCounts = new Map();
26
+ enabledOverride = null;
27
+ seed = 1;
28
+ rngState = 1;
29
+ constructor() {
30
+ this.reset();
31
+ }
32
+ readSeedFromEnv() {
33
+ const parsed = Number.parseInt(process.env.AEGIS_FAULT_SEED ?? '1', 10);
34
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
35
+ }
36
+ nextRandom() {
37
+ // LCG constants from Numerical Recipes, deterministic and fast.
38
+ this.rngState = (1664525 * this.rngState + 1013904223) >>> 0;
39
+ return this.rngState / 0x100000000;
40
+ }
41
+ isEnabled() {
42
+ if (this.enabledOverride !== null) {
43
+ return this.enabledOverride;
44
+ }
45
+ return process.env.AEGIS_FAULT_INJECTION === '1';
46
+ }
47
+ reset() {
48
+ this.seed = this.readSeedFromEnv();
49
+ this.rngState = this.seed;
50
+ this.hitCounts.clear();
51
+ }
52
+ clearRules() {
53
+ this.rules.length = 0;
54
+ this.hitCounts.clear();
55
+ }
56
+ setEnabledForTest(enabled) {
57
+ this.enabledOverride = enabled;
58
+ }
59
+ setSeedForTest(seed) {
60
+ this.seed = seed > 0 ? Math.floor(seed) : 1;
61
+ this.rngState = this.seed;
62
+ }
63
+ addRule(rule) {
64
+ this.rules.push(rule);
65
+ }
66
+ async inject(point) {
67
+ if (!this.isEnabled())
68
+ return;
69
+ for (const rule of this.rules) {
70
+ if (rule.point !== point)
71
+ continue;
72
+ const count = (this.hitCounts.get(point) ?? 0) + 1;
73
+ this.hitCounts.set(point, count);
74
+ let shouldTrigger = true;
75
+ if (rule.every && rule.every > 0) {
76
+ shouldTrigger = count % rule.every === 0;
77
+ }
78
+ else if (typeof rule.probability === 'number') {
79
+ shouldTrigger = this.nextRandom() < Math.max(0, Math.min(1, rule.probability));
80
+ }
81
+ if (!shouldTrigger)
82
+ continue;
83
+ if (rule.mode === 'delay') {
84
+ const ms = Math.max(0, rule.delayMs ?? 0);
85
+ if (ms > 0) {
86
+ await new Promise(resolve => setTimeout(resolve, ms));
87
+ }
88
+ return;
89
+ }
90
+ if (rule.mode === 'transient') {
91
+ throw new InjectedTransientFaultError(point, rule.errorMessage);
92
+ }
93
+ throw new InjectedFatalFaultError(point, rule.errorMessage);
94
+ }
95
+ }
96
+ }
97
+ const injector = new FaultInjector();
98
+ export function maybeInjectFault(point) {
99
+ return injector.inject(point);
100
+ }
101
+ export function resetFaultInjection() {
102
+ injector.reset();
103
+ }
104
+ export function clearFaultRules() {
105
+ injector.clearRules();
106
+ }
107
+ export function setFaultInjectionEnabledForTest(enabled) {
108
+ injector.setEnabledForTest(enabled);
109
+ }
110
+ export function setFaultInjectionSeedForTest(seed) {
111
+ injector.setSeedForTest(seed);
112
+ }
113
+ export function addFaultRule(rule) {
114
+ injector.addRule(rule);
115
+ }
@@ -13,8 +13,22 @@ export declare const AEGIS_MIN_PROTOCOL_VERSION = "1";
13
13
  * All capabilities Aegis supports in this build.
14
14
  * Capabilities are additive; absence means the feature is unavailable/disabled.
15
15
  */
16
- export declare const AEGIS_CAPABILITIES: readonly ["session.create", "session.resume", "session.approve", "session.transcript", "session.transcript.cursor", "session.events.sse", "session.screenshot", "hooks.pre_tool_use", "hooks.post_tool_use", "hooks.notification", "hooks.stop", "swarm", "metrics"];
16
+ export declare const AEGIS_CAPABILITIES: readonly ["session.create", "session.resume", "session.approve", "session.transcript", "session.transcript.cursor", "session.events.sse", "session.events.cursor", "session.screenshot", "hooks.pre_tool_use", "hooks.post_tool_use", "hooks.notification", "hooks.stop", "swarm", "metrics"];
17
17
  export type AegisCapability = (typeof AEGIS_CAPABILITIES)[number];
18
+ /**
19
+ * Feature gates that client integrations should check before enabling
20
+ * behavior that depends on newer protocol/capability support.
21
+ */
22
+ export declare const HANDSHAKE_FEATURE_REQUIREMENTS: {
23
+ readonly cursorReplay: readonly ["session.transcript.cursor"];
24
+ readonly transcriptRead: readonly ["session.transcript"];
25
+ readonly sseEvents: readonly ["session.events.sse"];
26
+ readonly permissionControl: readonly ["session.approve"];
27
+ readonly screenshots: readonly ["session.screenshot"];
28
+ readonly hookLifecycle: readonly ["hooks.pre_tool_use", "hooks.post_tool_use"];
29
+ };
30
+ export type HandshakeFeature = keyof typeof HANDSHAKE_FEATURE_REQUIREMENTS;
31
+ export type HandshakeFallbackMode = 'none' | 'legacy-defaults' | 'incompatible-protocol' | 'invalid-protocol';
18
32
  /** Request body for POST /v1/handshake */
19
33
  export interface HandshakeRequest {
20
34
  protocolVersion: string;
@@ -26,9 +40,15 @@ export interface HandshakeResponse {
26
40
  protocolVersion: string;
27
41
  serverCapabilities: AegisCapability[];
28
42
  negotiatedCapabilities: AegisCapability[];
43
+ featureGates: Record<HandshakeFeature, boolean>;
44
+ fallbackMode: HandshakeFallbackMode;
29
45
  warnings: string[];
30
46
  compatible: boolean;
31
47
  }
48
+ /** Compute boolean feature gates from a negotiated capability set. */
49
+ export declare function computeFeatureGates(capabilities: readonly AegisCapability[]): Record<HandshakeFeature, boolean>;
50
+ /** Helper for checking one feature gate directly from a handshake response. */
51
+ export declare function isFeatureEnabled(response: HandshakeResponse, feature: HandshakeFeature): boolean;
32
52
  /**
33
53
  * Negotiate capabilities between a client request and this Aegis build.
34
54
  *
package/dist/handshake.js CHANGED
@@ -20,6 +20,7 @@ export const AEGIS_CAPABILITIES = [
20
20
  'session.transcript',
21
21
  'session.transcript.cursor', // Issue #883: cursor-based replay
22
22
  'session.events.sse',
23
+ 'session.events.cursor',
23
24
  'session.screenshot',
24
25
  'hooks.pre_tool_use',
25
26
  'hooks.post_tool_use',
@@ -28,6 +29,30 @@ export const AEGIS_CAPABILITIES = [
28
29
  'swarm',
29
30
  'metrics',
30
31
  ];
32
+ /**
33
+ * Feature gates that client integrations should check before enabling
34
+ * behavior that depends on newer protocol/capability support.
35
+ */
36
+ export const HANDSHAKE_FEATURE_REQUIREMENTS = {
37
+ cursorReplay: ['session.transcript.cursor'],
38
+ transcriptRead: ['session.transcript'],
39
+ sseEvents: ['session.events.sse'],
40
+ permissionControl: ['session.approve'],
41
+ screenshots: ['session.screenshot'],
42
+ hookLifecycle: ['hooks.pre_tool_use', 'hooks.post_tool_use'],
43
+ };
44
+ /** Compute boolean feature gates from a negotiated capability set. */
45
+ export function computeFeatureGates(capabilities) {
46
+ const enabled = new Set(capabilities);
47
+ return Object.fromEntries(Object.entries(HANDSHAKE_FEATURE_REQUIREMENTS).map(([feature, required]) => [
48
+ feature,
49
+ required.every(capability => enabled.has(capability)),
50
+ ]));
51
+ }
52
+ /** Helper for checking one feature gate directly from a handshake response. */
53
+ export function isFeatureEnabled(response, feature) {
54
+ return response.featureGates[feature] === true;
55
+ }
31
56
  /**
32
57
  * Negotiate capabilities between a client request and this Aegis build.
33
58
  *
@@ -44,19 +69,25 @@ export function negotiate(req) {
44
69
  const serverMajor = parseInt(AEGIS_PROTOCOL_VERSION, 10);
45
70
  const minMajor = parseInt(AEGIS_MIN_PROTOCOL_VERSION, 10);
46
71
  if (isNaN(clientMajor)) {
72
+ const negotiatedCapabilities = [];
47
73
  return {
48
74
  protocolVersion: AEGIS_PROTOCOL_VERSION,
49
75
  serverCapabilities,
50
- negotiatedCapabilities: [],
76
+ negotiatedCapabilities,
77
+ featureGates: computeFeatureGates(negotiatedCapabilities),
78
+ fallbackMode: 'invalid-protocol',
51
79
  warnings: [`Unrecognized protocolVersion format: "${req.protocolVersion}". Expected integer string.`],
52
80
  compatible: false,
53
81
  };
54
82
  }
55
83
  if (clientMajor < minMajor) {
84
+ const negotiatedCapabilities = [];
56
85
  return {
57
86
  protocolVersion: AEGIS_PROTOCOL_VERSION,
58
87
  serverCapabilities,
59
- negotiatedCapabilities: [],
88
+ negotiatedCapabilities,
89
+ featureGates: computeFeatureGates(negotiatedCapabilities),
90
+ fallbackMode: 'incompatible-protocol',
60
91
  warnings: [
61
92
  `Client protocolVersion ${req.protocolVersion} is below minimum supported version ${AEGIS_MIN_PROTOCOL_VERSION}. Upgrade required.`,
62
93
  ],
@@ -69,8 +100,9 @@ export function negotiate(req) {
69
100
  // Intersect: client declares what it supports; server only enables what it also supports
70
101
  let negotiatedCapabilities;
71
102
  if (!req.clientCapabilities || req.clientCapabilities.length === 0) {
72
- // Client omitted capabilities → assume full server capability set
103
+ // Client omitted capabilities → default to full set for backward compatibility.
73
104
  negotiatedCapabilities = serverCapabilities;
105
+ warnings.push('Client did not provide clientCapabilities; using legacy-default capability negotiation.');
74
106
  }
75
107
  else {
76
108
  const serverSet = new Set(serverCapabilities);
@@ -84,6 +116,8 @@ export function negotiate(req) {
84
116
  protocolVersion: AEGIS_PROTOCOL_VERSION,
85
117
  serverCapabilities,
86
118
  negotiatedCapabilities,
119
+ featureGates: computeFeatureGates(negotiatedCapabilities),
120
+ fallbackMode: req.clientCapabilities && req.clientCapabilities.length > 0 ? 'none' : 'legacy-defaults',
87
121
  warnings,
88
122
  compatible: true,
89
123
  };
@@ -44,8 +44,9 @@ export interface HookSettings {
44
44
  *
45
45
  * @param baseUrl - Aegis base URL (e.g. "http://localhost:9100")
46
46
  * @param sessionId - Aegis session ID (used as query param for routing)
47
+ * @param hookSecret - Per-session secret for hook URL authentication (Issue #629)
47
48
  */
48
- export declare function generateHookSettings(baseUrl: string, sessionId: string): HookSettings;
49
+ export declare function generateHookSettings(baseUrl: string, sessionId: string, hookSecret?: string): HookSettings;
49
50
  /**
50
51
  * Write hook settings to a temporary file and return its path.
51
52
  *
@@ -58,7 +59,7 @@ export declare function generateHookSettings(baseUrl: string, sessionId: string)
58
59
  * @param workDir - Project working directory (to read settings.local.json from)
59
60
  * @returns Path to the temporary settings file
60
61
  */
61
- export declare function writeHookSettingsFile(baseUrl: string, sessionId: string, workDir?: string): Promise<string>;
62
+ export declare function writeHookSettingsFile(baseUrl: string, sessionId: string, hookSecret: string, workDir?: string): Promise<string>;
62
63
  /**
63
64
  * Clean up a hook settings temp file.
64
65
  *
@@ -74,16 +74,18 @@ export { HTTP_HOOK_EVENTS };
74
74
  *
75
75
  * @param baseUrl - Aegis base URL (e.g. "http://localhost:9100")
76
76
  * @param sessionId - Aegis session ID (used as query param for routing)
77
+ * @param hookSecret - Per-session secret for hook URL authentication (Issue #629)
77
78
  */
78
- export function generateHookSettings(baseUrl, sessionId) {
79
+ export function generateHookSettings(baseUrl, sessionId, hookSecret) {
79
80
  const hooks = {};
80
81
  for (const event of HTTP_HOOK_EVENTS) {
82
+ const secretParam = hookSecret ? `&secret=${hookSecret}` : '';
81
83
  hooks[event] = [
82
84
  {
83
85
  hooks: [
84
86
  {
85
87
  type: 'http',
86
- url: `${baseUrl}/v1/hooks/${event}?sessionId=${sessionId}`,
88
+ url: `${baseUrl}/v1/hooks/${event}?sessionId=${sessionId}${secretParam}`,
87
89
  },
88
90
  ],
89
91
  },
@@ -103,8 +105,8 @@ export function generateHookSettings(baseUrl, sessionId) {
103
105
  * @param workDir - Project working directory (to read settings.local.json from)
104
106
  * @returns Path to the temporary settings file
105
107
  */
106
- export async function writeHookSettingsFile(baseUrl, sessionId, workDir) {
107
- const hookSettings = generateHookSettings(baseUrl, sessionId);
108
+ export async function writeHookSettingsFile(baseUrl, sessionId, hookSecret, workDir) {
109
+ const hookSettings = generateHookSettings(baseUrl, sessionId, hookSecret);
108
110
  // Issue #339: Read project's settings.local.json and merge hooks into it.
109
111
  // This ensures CC gets env vars, permissions, and bypassPermissions alongside hooks.
110
112
  // Issue #847: Validate workDir path to prevent traversal attacks.
package/dist/hook.js CHANGED
@@ -30,6 +30,12 @@ const BRIDGE_DIR = existsSync(AEGIS_DIR) ? AEGIS_DIR : MANUS_DIR;
30
30
  const MAP_FILE = join(BRIDGE_DIR, 'session_map.json');
31
31
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
32
32
  const TMUX_PANE_RE = /^%\d+$/;
33
+ const DEFAULT_POINTER_TTL_MS = 24 * 60 * 60 * 1000;
34
+ function getPointerTtlMs() {
35
+ const raw = process.env.AEGIS_CONTINUATION_POINTER_TTL_MS ?? process.env.MANUS_CONTINUATION_POINTER_TTL_MS;
36
+ const parsed = raw ? Number(raw) : NaN;
37
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_POINTER_TTL_MS;
38
+ }
33
39
  /** Handle Stop/StopFailure events.
34
40
  * Writes a signal file that the Aegis monitor can detect.
35
41
  * Issue #15: StopFailure fires on API errors (rate limit, auth failure).
@@ -137,6 +143,7 @@ function main() {
137
143
  }
138
144
  catch { /* fresh map */ }
139
145
  }
146
+ const writtenAt = Date.now();
140
147
  sessionMap[key] = {
141
148
  session_id: sessionId,
142
149
  cwd,
@@ -147,7 +154,9 @@ function main() {
147
154
  source: payload.source || null,
148
155
  agent_type: payload.agent_type || null,
149
156
  model: payload.model || null,
150
- written_at: Date.now(),
157
+ written_at: writtenAt,
158
+ schema_version: 1,
159
+ expires_at: writtenAt + getPointerTtlMs(),
151
160
  };
152
161
  // Atomic write: write to temp file then rename (prevents race-condition data loss)
153
162
  const tmpMapFile = MAP_FILE + '.tmp';
package/dist/hooks.js CHANGED
@@ -114,6 +114,11 @@ 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 #629: Validate per-session hook secret (defense in depth — also checked in auth middleware)
118
+ const hookSecret = req.query?.secret;
119
+ if (session.hookSecret && hookSecret !== session.hookSecret) {
120
+ return reply.status(401).send({ error: 'Unauthorized — invalid hook secret' });
121
+ }
117
122
  // Issue #665: Validate hook body with Zod instead of unsafe casts
118
123
  const parseResult = hookBodySchema.safeParse(req.body);
119
124
  if (!parseResult.success) {
@@ -0,0 +1,35 @@
1
+ /**
2
+ * logger.ts - structured logger that also emits sanitized diagnostics events.
3
+ */
4
+ import { type DiagnosticsBus, type DiagnosticsLevel } from './diagnostics.js';
5
+ export interface LogContext {
6
+ component: string;
7
+ operation: string;
8
+ sessionId?: string;
9
+ errorCode?: string;
10
+ attributes?: Record<string, unknown>;
11
+ }
12
+ export interface StructuredLogRecord {
13
+ timestamp: string;
14
+ level: DiagnosticsLevel;
15
+ component: string;
16
+ operation: string;
17
+ sessionId?: string;
18
+ errorCode?: string;
19
+ attributes: Record<string, unknown>;
20
+ }
21
+ export interface StructuredLogSink {
22
+ info?: (record: StructuredLogRecord) => void;
23
+ warn?: (record: StructuredLogRecord) => void;
24
+ error?: (record: StructuredLogRecord) => void;
25
+ }
26
+ export declare function setStructuredLogSink(nextSink: StructuredLogSink): void;
27
+ export declare class StructuredLogger {
28
+ private readonly bus;
29
+ constructor(bus?: DiagnosticsBus);
30
+ info(ctx: LogContext): void;
31
+ warn(ctx: LogContext): void;
32
+ error(ctx: LogContext): void;
33
+ private log;
34
+ }
35
+ export declare const logger: StructuredLogger;