@tjamescouch/agentchat 0.25.2 → 0.26.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 (56) hide show
  1. package/README.md +53 -4
  2. package/dist/lib/callback-engine.d.ts +66 -0
  3. package/dist/lib/callback-engine.d.ts.map +1 -0
  4. package/dist/lib/callback-engine.js +199 -0
  5. package/dist/lib/callback-engine.js.map +1 -0
  6. package/dist/lib/client.d.ts +3 -1
  7. package/dist/lib/client.d.ts.map +1 -1
  8. package/dist/lib/client.js +3 -2
  9. package/dist/lib/client.js.map +1 -1
  10. package/dist/lib/escalation.d.ts +99 -0
  11. package/dist/lib/escalation.d.ts.map +1 -0
  12. package/dist/lib/escalation.js +286 -0
  13. package/dist/lib/escalation.js.map +1 -0
  14. package/dist/lib/escalation.test.d.ts +8 -0
  15. package/dist/lib/escalation.test.d.ts.map +1 -0
  16. package/dist/lib/escalation.test.js +348 -0
  17. package/dist/lib/escalation.test.js.map +1 -0
  18. package/dist/lib/floor-control.d.ts +77 -0
  19. package/dist/lib/floor-control.d.ts.map +1 -0
  20. package/dist/lib/floor-control.js +166 -0
  21. package/dist/lib/floor-control.js.map +1 -0
  22. package/dist/lib/moderation-plugins/escalation-plugin.d.ts +31 -0
  23. package/dist/lib/moderation-plugins/escalation-plugin.d.ts.map +1 -0
  24. package/dist/lib/moderation-plugins/escalation-plugin.js +97 -0
  25. package/dist/lib/moderation-plugins/escalation-plugin.js.map +1 -0
  26. package/dist/lib/moderation-plugins/link-detector-plugin.d.ts +29 -0
  27. package/dist/lib/moderation-plugins/link-detector-plugin.d.ts.map +1 -0
  28. package/dist/lib/moderation-plugins/link-detector-plugin.js +61 -0
  29. package/dist/lib/moderation-plugins/link-detector-plugin.js.map +1 -0
  30. package/dist/lib/moderation.d.ts +142 -0
  31. package/dist/lib/moderation.d.ts.map +1 -0
  32. package/dist/lib/moderation.js +192 -0
  33. package/dist/lib/moderation.js.map +1 -0
  34. package/dist/lib/moderation.test.d.ts +7 -0
  35. package/dist/lib/moderation.test.d.ts.map +1 -0
  36. package/dist/lib/moderation.test.js +275 -0
  37. package/dist/lib/moderation.test.js.map +1 -0
  38. package/dist/lib/protocol.d.ts +5 -0
  39. package/dist/lib/protocol.d.ts.map +1 -1
  40. package/dist/lib/protocol.js +18 -2
  41. package/dist/lib/protocol.js.map +1 -1
  42. package/dist/lib/security.d.ts.map +1 -1
  43. package/dist/lib/security.js +13 -4
  44. package/dist/lib/security.js.map +1 -1
  45. package/dist/lib/server/handlers/message.d.ts.map +1 -1
  46. package/dist/lib/server/handlers/message.js +22 -2
  47. package/dist/lib/server/handlers/message.js.map +1 -1
  48. package/dist/lib/server.d.ts +8 -0
  49. package/dist/lib/server.d.ts.map +1 -1
  50. package/dist/lib/server.js +106 -2
  51. package/dist/lib/server.js.map +1 -1
  52. package/dist/lib/types.d.ts +13 -1
  53. package/dist/lib/types.d.ts.map +1 -1
  54. package/dist/lib/types.js +5 -0
  55. package/dist/lib/types.js.map +1 -1
  56. package/package.json +1 -1
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Escalation Engine
3
+ * Progressive moderation: warn -> throttle -> timeout -> kick
4
+ *
5
+ * Tracks per-connection violation state and escalates enforcement
6
+ * as agents continue to exceed rate limits.
7
+ */
8
+ // Escalation levels in order of severity
9
+ export var EscalationLevel;
10
+ (function (EscalationLevel) {
11
+ EscalationLevel["NONE"] = "none";
12
+ EscalationLevel["WARNED"] = "warned";
13
+ EscalationLevel["THROTTLED"] = "throttled";
14
+ EscalationLevel["TIMED_OUT"] = "timed_out";
15
+ EscalationLevel["KICKED"] = "kicked";
16
+ })(EscalationLevel || (EscalationLevel = {}));
17
+ const DEFAULTS = {
18
+ warnAfterViolations: 3,
19
+ throttleAfterViolations: 6,
20
+ timeoutAfterViolations: 10,
21
+ kickAfterTimeouts: 3,
22
+ throttleDurationMs: 5000, // 5 seconds between messages when throttled
23
+ timeoutDurationMs: 60000, // 1 minute timeout
24
+ violationWindowMs: 60000, // 1 minute violation window
25
+ cooldownMs: 300000, // 5 minutes clean = decay one level
26
+ timeoutMemoryMs: 3600000, // 1 hour: timeout history lingers even after level decay
27
+ };
28
+ export class EscalationEngine {
29
+ states = new Map();
30
+ options;
31
+ logger;
32
+ constructor(options = {}, logger) {
33
+ this.options = { ...DEFAULTS, ...options };
34
+ this.logger = logger || (() => { });
35
+ }
36
+ /**
37
+ * Record a rate limit violation and return the action to take.
38
+ * Called whenever a connection exceeds the rate limit.
39
+ */
40
+ recordViolation(connectionId, agentId) {
41
+ const now = Date.now();
42
+ let state = this.states.get(connectionId);
43
+ if (!state) {
44
+ state = this._createState(now);
45
+ this.states.set(connectionId, state);
46
+ }
47
+ // Apply gradual decay based on time since last violation.
48
+ // Each cooldown period of inactivity drops one escalation level.
49
+ this._applyDecay(state, now);
50
+ // Reset violation count if the violation window has expired
51
+ if (now - state.windowStart > this.options.violationWindowMs) {
52
+ state.violations = 0;
53
+ state.windowStart = now;
54
+ }
55
+ state.violations++;
56
+ state.lastViolationAt = now;
57
+ const meta = { connectionId, agentId, violations: state.violations, level: state.level };
58
+ // Check if currently timed out
59
+ if (state.level === EscalationLevel.TIMED_OUT && now < state.timeoutUntil) {
60
+ return {
61
+ type: 'timeout',
62
+ message: `You are timed out. Try again in ${Math.ceil((state.timeoutUntil - now) / 1000)} seconds.`,
63
+ timeoutMs: state.timeoutUntil - now,
64
+ };
65
+ }
66
+ // Determine escalation based on violation count
67
+ if (state.violations >= this.options.timeoutAfterViolations) {
68
+ state.timeoutCount++;
69
+ if (state.timeoutCount >= this.options.kickAfterTimeouts) {
70
+ state.level = EscalationLevel.KICKED;
71
+ this.logger('escalation_kick', { ...meta, timeoutCount: state.timeoutCount });
72
+ return {
73
+ type: 'kick',
74
+ message: `Kicked for repeated violations (${state.timeoutCount} timeouts). Please moderate your message rate.`,
75
+ };
76
+ }
77
+ state.level = EscalationLevel.TIMED_OUT;
78
+ state.timeoutUntil = now + this.options.timeoutDurationMs;
79
+ state.violations = 0; // reset violations for next window
80
+ state.windowStart = now;
81
+ this.logger('escalation_timeout', { ...meta, timeoutCount: state.timeoutCount, durationMs: this.options.timeoutDurationMs });
82
+ return {
83
+ type: 'timeout',
84
+ message: `Timed out for ${Math.ceil(this.options.timeoutDurationMs / 1000)} seconds due to excessive messaging. (Timeout ${state.timeoutCount}/${this.options.kickAfterTimeouts})`,
85
+ timeoutMs: this.options.timeoutDurationMs,
86
+ };
87
+ }
88
+ if (state.violations >= this.options.throttleAfterViolations) {
89
+ state.level = EscalationLevel.THROTTLED;
90
+ state.throttleUntil = now + this.options.throttleDurationMs;
91
+ this.logger('escalation_throttle', { ...meta, throttleMs: this.options.throttleDurationMs });
92
+ return {
93
+ type: 'throttle',
94
+ message: `Throttled: your messages are being rate-limited to 1 per ${Math.ceil(this.options.throttleDurationMs / 1000)} seconds.`,
95
+ throttleMs: this.options.throttleDurationMs,
96
+ };
97
+ }
98
+ if (state.violations >= this.options.warnAfterViolations) {
99
+ state.warningsSent++;
100
+ if (state.level === EscalationLevel.NONE) {
101
+ state.level = EscalationLevel.WARNED;
102
+ }
103
+ this.logger('escalation_warn', { ...meta, warningsSent: state.warningsSent });
104
+ return {
105
+ type: 'warn',
106
+ message: `Warning: you are sending messages too quickly. Continued violations will result in throttling. (${state.violations}/${this.options.throttleAfterViolations} before throttle)`,
107
+ };
108
+ }
109
+ // Below warning threshold — just silently rate limit
110
+ return {
111
+ type: 'allow',
112
+ message: '',
113
+ };
114
+ }
115
+ /**
116
+ * Check if a connection is currently throttled and should have messages delayed.
117
+ * Returns the additional delay in ms, or 0 if not throttled.
118
+ */
119
+ getThrottleDelay(connectionId) {
120
+ const state = this.states.get(connectionId);
121
+ if (!state)
122
+ return 0;
123
+ // Apply gradual decay first
124
+ this._applyDecay(state, Date.now());
125
+ if (state.level === EscalationLevel.THROTTLED) {
126
+ return this.options.throttleDurationMs;
127
+ }
128
+ return 0;
129
+ }
130
+ /**
131
+ * Check if a connection is currently timed out.
132
+ * Returns true if the connection should be rejected.
133
+ */
134
+ isTimedOut(connectionId) {
135
+ const state = this.states.get(connectionId);
136
+ if (!state)
137
+ return false;
138
+ const now = Date.now();
139
+ // Apply gradual decay first
140
+ this._applyDecay(state, now);
141
+ if (state.level === EscalationLevel.TIMED_OUT && now < state.timeoutUntil) {
142
+ return true;
143
+ }
144
+ // Timeout expired — move back to throttled level
145
+ if (state.level === EscalationLevel.TIMED_OUT && now >= state.timeoutUntil) {
146
+ state.level = EscalationLevel.THROTTLED;
147
+ }
148
+ return false;
149
+ }
150
+ /**
151
+ * Get the current escalation level for a connection.
152
+ */
153
+ getLevel(connectionId) {
154
+ const state = this.states.get(connectionId);
155
+ if (!state)
156
+ return EscalationLevel.NONE;
157
+ // Apply gradual decay
158
+ this._applyDecay(state, Date.now());
159
+ return state.level;
160
+ }
161
+ /**
162
+ * Get the full state for a connection (for debugging/logging).
163
+ */
164
+ getState(connectionId) {
165
+ return this.states.get(connectionId);
166
+ }
167
+ /**
168
+ * Remove tracking for a disconnected connection.
169
+ */
170
+ remove(connectionId) {
171
+ this.states.delete(connectionId);
172
+ }
173
+ /**
174
+ * Reset a connection's escalation state (e.g., after admin intervention).
175
+ */
176
+ reset(connectionId) {
177
+ this.states.delete(connectionId);
178
+ }
179
+ /**
180
+ * Clean up stale entries (connections that have been clean for a while).
181
+ * Call periodically to prevent memory leaks.
182
+ */
183
+ cleanup() {
184
+ const now = Date.now();
185
+ let removed = 0;
186
+ for (const [id, state] of this.states) {
187
+ // Apply decay first
188
+ this._applyDecay(state, now);
189
+ // Only remove if fully decayed to NONE AND timeout memory has expired
190
+ if (state.level === EscalationLevel.NONE && now - state.lastViolationAt > this.options.timeoutMemoryMs) {
191
+ this.states.delete(id);
192
+ removed++;
193
+ }
194
+ }
195
+ return removed;
196
+ }
197
+ /**
198
+ * Get stats for monitoring.
199
+ */
200
+ stats() {
201
+ let warned = 0;
202
+ let throttled = 0;
203
+ let timedOut = 0;
204
+ for (const state of this.states.values()) {
205
+ switch (state.level) {
206
+ case EscalationLevel.WARNED:
207
+ warned++;
208
+ break;
209
+ case EscalationLevel.THROTTLED:
210
+ throttled++;
211
+ break;
212
+ case EscalationLevel.TIMED_OUT:
213
+ timedOut++;
214
+ break;
215
+ }
216
+ }
217
+ return {
218
+ tracked: this.states.size,
219
+ warned,
220
+ throttled,
221
+ timedOut,
222
+ };
223
+ }
224
+ /**
225
+ * Gradual decay: drop one escalation level per cooldown period of inactivity.
226
+ * KICKED → TIMED_OUT → THROTTLED → WARNED → NONE
227
+ */
228
+ _applyDecay(state, now) {
229
+ if (state.lastViolationAt === 0)
230
+ return; // no violations yet
231
+ const elapsed = now - state.lastViolationAt;
232
+ if (elapsed < this.options.cooldownMs)
233
+ return; // not enough time
234
+ const levelsToDrop = Math.floor(elapsed / this.options.cooldownMs);
235
+ const ladder = [
236
+ EscalationLevel.NONE,
237
+ EscalationLevel.WARNED,
238
+ EscalationLevel.THROTTLED,
239
+ EscalationLevel.TIMED_OUT,
240
+ EscalationLevel.KICKED,
241
+ ];
242
+ const currentIdx = ladder.indexOf(state.level);
243
+ if (currentIdx <= 0)
244
+ return; // already at NONE
245
+ const newIdx = Math.max(0, currentIdx - levelsToDrop);
246
+ const oldLevel = state.level;
247
+ state.level = ladder[newIdx];
248
+ // If we decayed past TIMED_OUT, clear timeout state
249
+ if (newIdx < ladder.indexOf(EscalationLevel.TIMED_OUT)) {
250
+ state.timeoutUntil = 0;
251
+ }
252
+ // Reset violations when decaying (fresh window)
253
+ if (newIdx !== currentIdx) {
254
+ state.violations = 0;
255
+ state.windowStart = now;
256
+ if (newIdx === 0) {
257
+ // Level fully decayed to NONE — reset warnings
258
+ state.warningsSent = 0;
259
+ // timeoutCount has its own longer TTL to prevent patient attacker gaming
260
+ if (elapsed >= this.options.timeoutMemoryMs) {
261
+ state.timeoutCount = 0;
262
+ }
263
+ }
264
+ this.logger('escalation_decay', {
265
+ connectionId: 'unknown', // caller doesn't pass this, but it's just for logging
266
+ from: oldLevel,
267
+ to: state.level,
268
+ levelsToDrop,
269
+ elapsedMs: elapsed,
270
+ });
271
+ }
272
+ }
273
+ _createState(now) {
274
+ return {
275
+ level: EscalationLevel.NONE,
276
+ violations: 0,
277
+ warningsSent: 0,
278
+ throttleUntil: 0,
279
+ timeoutUntil: 0,
280
+ timeoutCount: 0,
281
+ lastViolationAt: 0,
282
+ windowStart: now,
283
+ };
284
+ }
285
+ }
286
+ //# sourceMappingURL=escalation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"escalation.js","sourceRoot":"","sources":["../../lib/escalation.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Tests for EscalationEngine
3
+ * Progressive moderation: warn -> throttle -> timeout -> kick
4
+ *
5
+ * Run with: npx tsx lib/escalation.test.ts
6
+ */
7
+ export {};
8
+ //# sourceMappingURL=escalation.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"escalation.test.d.ts","sourceRoot":"","sources":["../../lib/escalation.test.ts"],"names":[],"mappings":"AAAA;;;;;GAKG"}
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Tests for EscalationEngine
3
+ * Progressive moderation: warn -> throttle -> timeout -> kick
4
+ *
5
+ * Run with: npx tsx lib/escalation.test.ts
6
+ */
7
+ import { describe, it } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import { EscalationEngine, EscalationLevel } from './escalation.js';
10
+ describe('EscalationEngine', () => {
11
+ function makeEngine() {
12
+ return new EscalationEngine({
13
+ warnAfterViolations: 3,
14
+ throttleAfterViolations: 6,
15
+ timeoutAfterViolations: 10,
16
+ kickAfterTimeouts: 3,
17
+ throttleDurationMs: 5000,
18
+ timeoutDurationMs: 60000,
19
+ violationWindowMs: 60000,
20
+ cooldownMs: 300000,
21
+ });
22
+ }
23
+ describe('basic escalation ladder', () => {
24
+ it('allows first few violations silently', () => {
25
+ const engine = makeEngine();
26
+ const r1 = engine.recordViolation('conn1');
27
+ const r2 = engine.recordViolation('conn1');
28
+ assert.equal(r1.type, 'allow');
29
+ assert.equal(r2.type, 'allow');
30
+ assert.equal(engine.getLevel('conn1'), EscalationLevel.NONE);
31
+ });
32
+ it('warns after threshold violations', () => {
33
+ const engine = makeEngine();
34
+ engine.recordViolation('conn1');
35
+ engine.recordViolation('conn1');
36
+ const r3 = engine.recordViolation('conn1');
37
+ assert.equal(r3.type, 'warn');
38
+ assert.ok(r3.message.includes('Warning'));
39
+ assert.equal(engine.getLevel('conn1'), EscalationLevel.WARNED);
40
+ });
41
+ it('throttles after more violations', () => {
42
+ const engine = makeEngine();
43
+ for (let i = 0; i < 5; i++)
44
+ engine.recordViolation('conn1');
45
+ const r6 = engine.recordViolation('conn1');
46
+ assert.equal(r6.type, 'throttle');
47
+ assert.equal(r6.throttleMs, 5000);
48
+ assert.equal(engine.getLevel('conn1'), EscalationLevel.THROTTLED);
49
+ });
50
+ it('times out after continued violations', () => {
51
+ const engine = makeEngine();
52
+ for (let i = 0; i < 9; i++)
53
+ engine.recordViolation('conn1');
54
+ const r10 = engine.recordViolation('conn1');
55
+ assert.equal(r10.type, 'timeout');
56
+ assert.equal(r10.timeoutMs, 60000);
57
+ assert.ok(r10.message.includes('Timed out'));
58
+ assert.equal(engine.getLevel('conn1'), EscalationLevel.TIMED_OUT);
59
+ });
60
+ it('kicks after repeated timeouts', async () => {
61
+ // Use a very short timeout so we can wait it out
62
+ const engine = new EscalationEngine({
63
+ warnAfterViolations: 3,
64
+ throttleAfterViolations: 6,
65
+ timeoutAfterViolations: 10,
66
+ kickAfterTimeouts: 3,
67
+ throttleDurationMs: 5000,
68
+ timeoutDurationMs: 50, // 50ms timeout
69
+ violationWindowMs: 60000,
70
+ cooldownMs: 300000,
71
+ });
72
+ let lastAction;
73
+ for (let round = 0; round < 3; round++) {
74
+ for (let i = 0; i < 10; i++) {
75
+ lastAction = engine.recordViolation('conn1');
76
+ }
77
+ // Wait for the timeout to expire before next round
78
+ if (round < 2) {
79
+ await new Promise(r => setTimeout(r, 60));
80
+ }
81
+ }
82
+ assert.equal(lastAction.type, 'kick');
83
+ const state = engine.getState('conn1');
84
+ assert.equal(state.level, EscalationLevel.KICKED);
85
+ });
86
+ });
87
+ describe('timeout behavior', () => {
88
+ it('reports timed out when checking during timeout period', () => {
89
+ const engine = makeEngine();
90
+ for (let i = 0; i < 10; i++)
91
+ engine.recordViolation('conn1');
92
+ assert.equal(engine.isTimedOut('conn1'), true);
93
+ });
94
+ it('returns false for non-existent connections', () => {
95
+ const engine = makeEngine();
96
+ assert.equal(engine.isTimedOut('nonexistent'), false);
97
+ });
98
+ });
99
+ describe('throttle delay', () => {
100
+ it('returns 0 for untracked connections', () => {
101
+ const engine = makeEngine();
102
+ assert.equal(engine.getThrottleDelay('unknown'), 0);
103
+ });
104
+ it('returns throttle duration when throttled', () => {
105
+ const engine = makeEngine();
106
+ for (let i = 0; i < 6; i++)
107
+ engine.recordViolation('conn1');
108
+ assert.equal(engine.getThrottleDelay('conn1'), 5000);
109
+ });
110
+ it('returns 0 when not yet throttled', () => {
111
+ const engine = makeEngine();
112
+ engine.recordViolation('conn1');
113
+ assert.equal(engine.getThrottleDelay('conn1'), 0);
114
+ });
115
+ });
116
+ describe('isolation between connections', () => {
117
+ it('tracks connections independently', () => {
118
+ const engine = makeEngine();
119
+ for (let i = 0; i < 3; i++)
120
+ engine.recordViolation('conn1');
121
+ engine.recordViolation('conn2');
122
+ assert.equal(engine.getLevel('conn1'), EscalationLevel.WARNED);
123
+ assert.equal(engine.getLevel('conn2'), EscalationLevel.NONE);
124
+ });
125
+ });
126
+ describe('cleanup and removal', () => {
127
+ it('removes connection state', () => {
128
+ const engine = makeEngine();
129
+ engine.recordViolation('conn1');
130
+ engine.remove('conn1');
131
+ assert.equal(engine.getLevel('conn1'), EscalationLevel.NONE);
132
+ });
133
+ it('resets connection state', () => {
134
+ const engine = makeEngine();
135
+ for (let i = 0; i < 6; i++)
136
+ engine.recordViolation('conn1');
137
+ assert.equal(engine.getLevel('conn1'), EscalationLevel.THROTTLED);
138
+ engine.reset('conn1');
139
+ assert.equal(engine.getLevel('conn1'), EscalationLevel.NONE);
140
+ });
141
+ });
142
+ describe('stats', () => {
143
+ it('reports correct counts', () => {
144
+ const engine = makeEngine();
145
+ for (let i = 0; i < 3; i++)
146
+ engine.recordViolation('conn1');
147
+ for (let i = 0; i < 6; i++)
148
+ engine.recordViolation('conn2');
149
+ for (let i = 0; i < 10; i++)
150
+ engine.recordViolation('conn3');
151
+ const stats = engine.stats();
152
+ assert.equal(stats.tracked, 3);
153
+ assert.equal(stats.warned, 1);
154
+ assert.equal(stats.throttled, 1);
155
+ assert.equal(stats.timedOut, 1);
156
+ });
157
+ });
158
+ describe('logging', () => {
159
+ it('calls logger on escalation events', () => {
160
+ const logs = [];
161
+ const engine = new EscalationEngine({ warnAfterViolations: 2 }, (event, data) => logs.push({ event, data }));
162
+ engine.recordViolation('conn1');
163
+ engine.recordViolation('conn1');
164
+ assert.equal(logs.length, 1);
165
+ assert.equal(logs[0].event, 'escalation_warn');
166
+ });
167
+ });
168
+ describe('gradual decay', () => {
169
+ it('decays one level per cooldown period', async () => {
170
+ // Use very short cooldown so we can actually wait it out
171
+ const engine = new EscalationEngine({
172
+ warnAfterViolations: 2,
173
+ throttleAfterViolations: 4,
174
+ timeoutAfterViolations: 6,
175
+ kickAfterTimeouts: 3,
176
+ throttleDurationMs: 5000,
177
+ timeoutDurationMs: 30, // very short so timeout expires quickly
178
+ violationWindowMs: 60000,
179
+ cooldownMs: 50, // 50ms cooldown
180
+ });
181
+ // Escalate to THROTTLED
182
+ for (let i = 0; i < 4; i++)
183
+ engine.recordViolation('conn1');
184
+ assert.equal(engine.getLevel('conn1'), EscalationLevel.THROTTLED);
185
+ // Wait one cooldown period — should decay to WARNED
186
+ await new Promise(r => setTimeout(r, 60));
187
+ assert.equal(engine.getLevel('conn1'), EscalationLevel.WARNED);
188
+ // Wait another cooldown period — should decay to NONE
189
+ await new Promise(r => setTimeout(r, 60));
190
+ assert.equal(engine.getLevel('conn1'), EscalationLevel.NONE);
191
+ });
192
+ it('decays multiple levels when enough time passes', async () => {
193
+ const engine = new EscalationEngine({
194
+ warnAfterViolations: 2,
195
+ throttleAfterViolations: 4,
196
+ timeoutAfterViolations: 6,
197
+ kickAfterTimeouts: 3,
198
+ throttleDurationMs: 5000,
199
+ timeoutDurationMs: 30,
200
+ violationWindowMs: 60000,
201
+ cooldownMs: 50,
202
+ });
203
+ // Escalate to THROTTLED (level 2)
204
+ for (let i = 0; i < 4; i++)
205
+ engine.recordViolation('conn1');
206
+ assert.equal(engine.getLevel('conn1'), EscalationLevel.THROTTLED);
207
+ // Wait 2 cooldown periods — should decay straight to NONE
208
+ await new Promise(r => setTimeout(r, 120));
209
+ assert.equal(engine.getLevel('conn1'), EscalationLevel.NONE);
210
+ });
211
+ it('does not decay during active violations', () => {
212
+ const engine = new EscalationEngine({
213
+ warnAfterViolations: 2,
214
+ throttleAfterViolations: 4,
215
+ cooldownMs: 50,
216
+ });
217
+ // Rapid violations — no time for decay
218
+ for (let i = 0; i < 4; i++)
219
+ engine.recordViolation('conn1');
220
+ assert.equal(engine.getLevel('conn1'), EscalationLevel.THROTTLED);
221
+ });
222
+ it('re-escalates after partial decay', async () => {
223
+ const engine = new EscalationEngine({
224
+ warnAfterViolations: 2,
225
+ throttleAfterViolations: 4,
226
+ cooldownMs: 50,
227
+ violationWindowMs: 200,
228
+ });
229
+ // Escalate to THROTTLED
230
+ for (let i = 0; i < 4; i++)
231
+ engine.recordViolation('conn1');
232
+ assert.equal(engine.getLevel('conn1'), EscalationLevel.THROTTLED);
233
+ // Wait for decay to WARNED
234
+ await new Promise(r => setTimeout(r, 60));
235
+ assert.equal(engine.getLevel('conn1'), EscalationLevel.WARNED);
236
+ // New violations should re-escalate
237
+ for (let i = 0; i < 4; i++)
238
+ engine.recordViolation('conn1');
239
+ assert.equal(engine.getLevel('conn1'), EscalationLevel.THROTTLED);
240
+ });
241
+ it('timeoutCount survives level decay (patient attacker defense)', async () => {
242
+ const engine = new EscalationEngine({
243
+ warnAfterViolations: 2,
244
+ throttleAfterViolations: 4,
245
+ timeoutAfterViolations: 6,
246
+ kickAfterTimeouts: 2,
247
+ throttleDurationMs: 5000,
248
+ timeoutDurationMs: 30, // very short timeout
249
+ violationWindowMs: 200,
250
+ cooldownMs: 50, // fast decay
251
+ timeoutMemoryMs: 500, // timeout history lasts 500ms (much longer than cooldown)
252
+ });
253
+ // Round 1: escalate to timeout
254
+ for (let i = 0; i < 6; i++)
255
+ engine.recordViolation('conn1');
256
+ assert.equal(engine.getLevel('conn1'), EscalationLevel.TIMED_OUT);
257
+ const state1 = engine.getState('conn1');
258
+ assert.equal(state1.timeoutCount, 1);
259
+ // Wait for timeout to expire + full level decay to NONE
260
+ // cooldown=50ms, 3 levels (TIMED_OUT→THROTTLED→WARNED→NONE) = 150ms
261
+ await new Promise(r => setTimeout(r, 200));
262
+ assert.equal(engine.getLevel('conn1'), EscalationLevel.NONE);
263
+ // timeoutCount should STILL be 1 (timeoutMemoryMs=500 hasn't elapsed)
264
+ const state2 = engine.getState('conn1');
265
+ assert.equal(state2.timeoutCount, 1);
266
+ // Round 2: re-spam. One more timeout should trigger kick (2/2)
267
+ for (let i = 0; i < 6; i++)
268
+ engine.recordViolation('conn1');
269
+ const state3 = engine.getState('conn1');
270
+ assert.equal(state3.level, EscalationLevel.KICKED);
271
+ });
272
+ it('timeoutCount resets after timeoutMemoryMs expires', async () => {
273
+ const engine = new EscalationEngine({
274
+ warnAfterViolations: 2,
275
+ throttleAfterViolations: 4,
276
+ timeoutAfterViolations: 6,
277
+ kickAfterTimeouts: 2,
278
+ throttleDurationMs: 5000,
279
+ timeoutDurationMs: 30,
280
+ violationWindowMs: 200,
281
+ cooldownMs: 50,
282
+ timeoutMemoryMs: 100, // short memory for testing
283
+ });
284
+ // Escalate to timeout
285
+ for (let i = 0; i < 6; i++)
286
+ engine.recordViolation('conn1');
287
+ assert.equal(engine.getState('conn1').timeoutCount, 1);
288
+ // Wait for full decay AND timeout memory expiry
289
+ await new Promise(r => setTimeout(r, 200));
290
+ assert.equal(engine.getLevel('conn1'), EscalationLevel.NONE);
291
+ // timeoutCount should now be 0 (memory expired)
292
+ assert.equal(engine.getState('conn1').timeoutCount, 0);
293
+ });
294
+ });
295
+ describe('identity invariants', () => {
296
+ it('persistent identity preserves escalation across reconnections', () => {
297
+ const engine = makeEngine();
298
+ // Agent with persistent ID escalates to warned
299
+ const persistentId = 'ed25519:abc123def456';
300
+ for (let i = 0; i < 3; i++)
301
+ engine.recordViolation(persistentId);
302
+ assert.equal(engine.getLevel(persistentId), EscalationLevel.WARNED);
303
+ // Simulate disconnect (don't remove state — this is intentional)
304
+ // Reconnect with same persistent ID
305
+ // State should still be warned
306
+ assert.equal(engine.getLevel(persistentId), EscalationLevel.WARNED);
307
+ // Further violations continue from existing state
308
+ for (let i = 0; i < 3; i++)
309
+ engine.recordViolation(persistentId);
310
+ assert.equal(engine.getLevel(persistentId), EscalationLevel.THROTTLED);
311
+ });
312
+ it('ephemeral agents get fresh state with new IDs', () => {
313
+ const engine = makeEngine();
314
+ // Ephemeral agent escalates
315
+ const ephemeralId1 = 'anon_abc123';
316
+ for (let i = 0; i < 3; i++)
317
+ engine.recordViolation(ephemeralId1);
318
+ assert.equal(engine.getLevel(ephemeralId1), EscalationLevel.WARNED);
319
+ // Ephemeral agent disconnects and reconnects with new random ID
320
+ const ephemeralId2 = 'anon_xyz789';
321
+ // New ID has no state — starts fresh
322
+ assert.equal(engine.getLevel(ephemeralId2), EscalationLevel.NONE);
323
+ const result = engine.recordViolation(ephemeralId2);
324
+ assert.equal(result.type, 'allow'); // first violation, no escalation
325
+ });
326
+ it('old ephemeral state does not leak to new connections', () => {
327
+ const engine = makeEngine();
328
+ // Escalate an ephemeral agent to throttled
329
+ const oldId = 'anon_old';
330
+ for (let i = 0; i < 6; i++)
331
+ engine.recordViolation(oldId);
332
+ assert.equal(engine.getLevel(oldId), EscalationLevel.THROTTLED);
333
+ // A completely different ephemeral agent should not be affected
334
+ const newId = 'anon_new';
335
+ assert.equal(engine.getLevel(newId), EscalationLevel.NONE);
336
+ assert.equal(engine.getThrottleDelay(newId), 0);
337
+ assert.equal(engine.isTimedOut(newId), false);
338
+ });
339
+ });
340
+ describe('default options', () => {
341
+ it('works with default configuration', () => {
342
+ const engine = new EscalationEngine();
343
+ const result = engine.recordViolation('conn1');
344
+ assert.equal(result.type, 'allow');
345
+ });
346
+ });
347
+ });
348
+ //# sourceMappingURL=escalation.test.js.map