@tjamescouch/agentchat 0.25.0 → 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.
- package/README.md +53 -4
- package/dist/lib/callback-engine.d.ts +66 -0
- package/dist/lib/callback-engine.d.ts.map +1 -0
- package/dist/lib/callback-engine.js +199 -0
- package/dist/lib/callback-engine.js.map +1 -0
- package/dist/lib/client.d.ts +3 -1
- package/dist/lib/client.d.ts.map +1 -1
- package/dist/lib/client.js +3 -2
- package/dist/lib/client.js.map +1 -1
- package/dist/lib/escalation.d.ts +99 -0
- package/dist/lib/escalation.d.ts.map +1 -0
- package/dist/lib/escalation.js +286 -0
- package/dist/lib/escalation.js.map +1 -0
- package/dist/lib/escalation.test.d.ts +8 -0
- package/dist/lib/escalation.test.d.ts.map +1 -0
- package/dist/lib/escalation.test.js +348 -0
- package/dist/lib/escalation.test.js.map +1 -0
- package/dist/lib/floor-control.d.ts +77 -0
- package/dist/lib/floor-control.d.ts.map +1 -0
- package/dist/lib/floor-control.js +166 -0
- package/dist/lib/floor-control.js.map +1 -0
- package/dist/lib/moderation-plugins/escalation-plugin.d.ts +31 -0
- package/dist/lib/moderation-plugins/escalation-plugin.d.ts.map +1 -0
- package/dist/lib/moderation-plugins/escalation-plugin.js +97 -0
- package/dist/lib/moderation-plugins/escalation-plugin.js.map +1 -0
- package/dist/lib/moderation-plugins/link-detector-plugin.d.ts +29 -0
- package/dist/lib/moderation-plugins/link-detector-plugin.d.ts.map +1 -0
- package/dist/lib/moderation-plugins/link-detector-plugin.js +61 -0
- package/dist/lib/moderation-plugins/link-detector-plugin.js.map +1 -0
- package/dist/lib/moderation.d.ts +142 -0
- package/dist/lib/moderation.d.ts.map +1 -0
- package/dist/lib/moderation.js +192 -0
- package/dist/lib/moderation.js.map +1 -0
- package/dist/lib/moderation.test.d.ts +7 -0
- package/dist/lib/moderation.test.d.ts.map +1 -0
- package/dist/lib/moderation.test.js +275 -0
- package/dist/lib/moderation.test.js.map +1 -0
- package/dist/lib/protocol.d.ts +5 -0
- package/dist/lib/protocol.d.ts.map +1 -1
- package/dist/lib/protocol.js +18 -2
- package/dist/lib/protocol.js.map +1 -1
- package/dist/lib/security.d.ts.map +1 -1
- package/dist/lib/security.js +13 -4
- package/dist/lib/security.js.map +1 -1
- package/dist/lib/server/handlers/message.d.ts.map +1 -1
- package/dist/lib/server/handlers/message.js +22 -2
- package/dist/lib/server/handlers/message.js.map +1 -1
- package/dist/lib/server.d.ts +8 -0
- package/dist/lib/server.d.ts.map +1 -1
- package/dist/lib/server.js +115 -2
- package/dist/lib/server.js.map +1 -1
- package/dist/lib/types.d.ts +13 -1
- package/dist/lib/types.d.ts.map +1 -1
- package/dist/lib/types.js +5 -0
- package/dist/lib/types.js.map +1 -1
- 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 @@
|
|
|
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
|