@execlave/sdk 1.0.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.
- package/README.md +131 -0
- package/dist/agent.d.ts +65 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +108 -0
- package/dist/agent.js.map +1 -0
- package/dist/client.d.ts +201 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +985 -0
- package/dist/client.js.map +1 -0
- package/dist/connectors.d.ts +39 -0
- package/dist/connectors.d.ts.map +1 -0
- package/dist/connectors.js +67 -0
- package/dist/connectors.js.map +1 -0
- package/dist/errors.d.ts +81 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +125 -0
- package/dist/errors.js.map +1 -0
- package/dist/http.d.ts +20 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +79 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/otel.d.ts +18 -0
- package/dist/otel.d.ts.map +1 -0
- package/dist/otel.js +147 -0
- package/dist/otel.js.map +1 -0
- package/dist/trace.d.ts +62 -0
- package/dist/trace.d.ts.map +1 -0
- package/dist/trace.js +104 -0
- package/dist/trace.js.map +1 -0
- package/dist/types.d.ts +203 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/package.json +88 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Execlave SDK — Main client.
|
|
4
|
+
*
|
|
5
|
+
* Provides the Execlave class for agent registration, tracing, and governance.
|
|
6
|
+
* Implements non-blocking trace ingestion with an in-memory circular buffer
|
|
7
|
+
* and a background flush interval.
|
|
8
|
+
*/
|
|
9
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
12
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
13
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
14
|
+
}
|
|
15
|
+
Object.defineProperty(o, k2, desc);
|
|
16
|
+
}) : (function(o, m, k, k2) {
|
|
17
|
+
if (k2 === undefined) k2 = k;
|
|
18
|
+
o[k2] = m[k];
|
|
19
|
+
}));
|
|
20
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
21
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
22
|
+
}) : function(o, v) {
|
|
23
|
+
o["default"] = v;
|
|
24
|
+
});
|
|
25
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
26
|
+
var ownKeys = function(o) {
|
|
27
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
28
|
+
var ar = [];
|
|
29
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
30
|
+
return ar;
|
|
31
|
+
};
|
|
32
|
+
return ownKeys(o);
|
|
33
|
+
};
|
|
34
|
+
return function (mod) {
|
|
35
|
+
if (mod && mod.__esModule) return mod;
|
|
36
|
+
var result = {};
|
|
37
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
38
|
+
__setModuleDefault(result, mod);
|
|
39
|
+
return result;
|
|
40
|
+
};
|
|
41
|
+
})();
|
|
42
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
43
|
+
exports.Execlave = void 0;
|
|
44
|
+
const http_1 = require("./http");
|
|
45
|
+
const agent_1 = require("./agent");
|
|
46
|
+
const trace_1 = require("./trace");
|
|
47
|
+
const errors_1 = require("./errors");
|
|
48
|
+
const crypto_1 = require("crypto");
|
|
49
|
+
const MAX_BUFFER_SIZE = 10000;
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// PII Patterns (mirrors processing service)
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
const PII_PATTERNS = {
|
|
54
|
+
email: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/gi,
|
|
55
|
+
ssn: /\b\d{3}-\d{2}-\d{4}\b/g,
|
|
56
|
+
credit_card: /\b(?:\d{4}[- ]?){3}\d{4}\b/g,
|
|
57
|
+
phone_us: /\b(?:\+1[-.\s]?)?\(?[2-9]\d{2}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g,
|
|
58
|
+
ip_address: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g,
|
|
59
|
+
api_key: /\b(?:sk|pk|ag)_[a-zA-Z0-9]{20,}\b/g,
|
|
60
|
+
};
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Injection Patterns (common prompt-injection signatures)
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
const INJECTION_PATTERNS = [
|
|
65
|
+
/ignore\s+(all\s+)?previous\s+instructions/i,
|
|
66
|
+
/ignore\s+(all\s+)?above\s+instructions/i,
|
|
67
|
+
/disregard\s+(all\s+)?previous/i,
|
|
68
|
+
/you\s+are\s+now\s+(?:a\s+)?(?:DAN|evil|unrestricted)/i,
|
|
69
|
+
/forget\s+(all\s+)?(?:previous|earlier|your)\s+(?:instructions|rules|guidelines)/i,
|
|
70
|
+
/system\s*:\s*you\s+are/i,
|
|
71
|
+
/\[SYSTEM\]|\[INST\]|\[\/INST\]/i,
|
|
72
|
+
/<\|(?:system|im_start|im_end)\|>/i,
|
|
73
|
+
/(?:reveal|show|display|print|output)\s+(?:your\s+)?(?:system\s+)?(?:prompt|instructions|rules)/i,
|
|
74
|
+
/(?:act|behave|respond)\s+as\s+(?:if|though)\s+(?:you\s+(?:are|were|have))/i,
|
|
75
|
+
/do\s+anything\s+now/i,
|
|
76
|
+
/jailbreak/i,
|
|
77
|
+
/bypass\s+(?:your\s+)?(?:filters?|restrictions?|safety|guidelines?)/i,
|
|
78
|
+
];
|
|
79
|
+
/**
|
|
80
|
+
* Main entry point for the Execlave JavaScript SDK.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```ts
|
|
84
|
+
* const ag = new Execlave({ apiKey: 'exe_prod_xxx', environment: 'production' });
|
|
85
|
+
* const agent = await exe.registerAgent({ agentId: 'my-bot', name: 'My Bot' });
|
|
86
|
+
*
|
|
87
|
+
* const trace = exe.startTrace({ agentId: 'my-bot' });
|
|
88
|
+
* trace.setInput(question);
|
|
89
|
+
* const answer = await llm.call(question);
|
|
90
|
+
* trace.setOutput(answer).setModel('gpt-4').setTokens(100, 200);
|
|
91
|
+
* trace.finish();
|
|
92
|
+
*
|
|
93
|
+
* // Before process exit
|
|
94
|
+
* await exe.shutdown();
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
class Execlave {
|
|
98
|
+
constructor(config = {}) {
|
|
99
|
+
this._otelExporter = null;
|
|
100
|
+
this._otelReady = null;
|
|
101
|
+
this._state = 'INITIALIZING';
|
|
102
|
+
this._buffer = [];
|
|
103
|
+
this._agents = new Map();
|
|
104
|
+
this._flushTimer = null;
|
|
105
|
+
this._pollTimer = null;
|
|
106
|
+
this._heartbeatTimer = null;
|
|
107
|
+
this._socket = null;
|
|
108
|
+
// Circuit breaker state
|
|
109
|
+
this._cbFailures = 0;
|
|
110
|
+
this._cbThreshold = 3;
|
|
111
|
+
this._cbOpen = false;
|
|
112
|
+
this._cbOpenAt = 0;
|
|
113
|
+
this._cbResetAfterMs = 60000;
|
|
114
|
+
// Policy cache
|
|
115
|
+
this._policyCache = new Map();
|
|
116
|
+
this._policyCacheTtlMs = 60000;
|
|
117
|
+
// Trace quota-exhausted cache (fail fast for 60 seconds)
|
|
118
|
+
this._quotaExceeded = null;
|
|
119
|
+
this._quotaCacheTtlMs = 60000;
|
|
120
|
+
// Enforcement outage behaviour
|
|
121
|
+
this._enforcementOnOutage = 'fail_open';
|
|
122
|
+
// Plan limit behaviour
|
|
123
|
+
this._planLimitBehavior = 'fail_open';
|
|
124
|
+
this._heartbeatIntervalMs = 600000;
|
|
125
|
+
this._apiKey = config.apiKey ?? process.env.EXECLAVE_API_KEY ?? '';
|
|
126
|
+
if (!this._apiKey) {
|
|
127
|
+
throw new Error('apiKey must be provided or EXECLAVE_API_KEY env var must be set');
|
|
128
|
+
}
|
|
129
|
+
this._baseUrl = (config.baseUrl ?? process.env.EXECLAVE_BASE_URL ?? 'https://api.execlave.com').replace(/\/+$/, '');
|
|
130
|
+
this._apiVersion = config.apiVersion !== undefined ? config.apiVersion || undefined : 'v1';
|
|
131
|
+
this._environment = config.environment ?? 'production';
|
|
132
|
+
this._asyncMode = config.asyncMode ?? true;
|
|
133
|
+
this._batchSize = config.batchSize ?? 100;
|
|
134
|
+
this._flushIntervalMs = config.flushIntervalMs ?? 10000;
|
|
135
|
+
this._debug = config.debug ?? false;
|
|
136
|
+
this._enableControlChannel = config.enableControlChannel ?? true;
|
|
137
|
+
this._pollIntervalMs = config.pollIntervalMs ?? 15000;
|
|
138
|
+
this._privacy = config.privacy ?? { enabled: false };
|
|
139
|
+
this._enableInjectionScan = config.enableInjectionScan ?? true;
|
|
140
|
+
this._mode = config.mode ?? 'native';
|
|
141
|
+
this._otlpEndpoint = config.otlpEndpoint;
|
|
142
|
+
this._enforcementOnOutage = config.enforcementOnOutage ?? 'fail_open';
|
|
143
|
+
this._planLimitBehavior = config.planLimitBehavior ?? 'fail_open';
|
|
144
|
+
this._heartbeatIntervalMs = config.heartbeatIntervalMs ?? 600000;
|
|
145
|
+
this._policyCacheTtlMs = config.policyCacheTtlMs ?? 60000;
|
|
146
|
+
// Initialise OTel exporter when running in OTLP mode
|
|
147
|
+
if (this._mode === 'otlp') {
|
|
148
|
+
if (!this._otlpEndpoint) {
|
|
149
|
+
throw new Error('otlpEndpoint is required when mode is "otlp"');
|
|
150
|
+
}
|
|
151
|
+
this._otelReady = Promise.resolve().then(() => __importStar(require('./otel'))).then(({ OTelExporter }) => OTelExporter.create(this._otlpEndpoint, this._apiKey, `Execlave-${this._environment}`))
|
|
152
|
+
.then((exp) => {
|
|
153
|
+
this._otelExporter = exp;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
// Start background flush
|
|
157
|
+
if (this._asyncMode) {
|
|
158
|
+
this._flushTimer = setInterval(() => {
|
|
159
|
+
this._doFlush().catch(this._logError.bind(this));
|
|
160
|
+
}, this._flushIntervalMs);
|
|
161
|
+
// Unref so the timer doesn't prevent process exit
|
|
162
|
+
if (this._flushTimer && typeof this._flushTimer === 'object' && 'unref' in this._flushTimer) {
|
|
163
|
+
this._flushTimer.unref();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// Start background status polling
|
|
167
|
+
if (this._enableControlChannel) {
|
|
168
|
+
this._pollTimer = setInterval(() => {
|
|
169
|
+
this._statusPoll().catch(this._logError.bind(this));
|
|
170
|
+
}, this._pollIntervalMs);
|
|
171
|
+
if (this._pollTimer && typeof this._pollTimer === 'object' && 'unref' in this._pollTimer) {
|
|
172
|
+
this._pollTimer.unref();
|
|
173
|
+
}
|
|
174
|
+
// Attempt WebSocket connection for real-time control (<500ms latency)
|
|
175
|
+
this._connectWebSocket();
|
|
176
|
+
// Heartbeat timer
|
|
177
|
+
this._heartbeatTimer = setInterval(() => {
|
|
178
|
+
this._sendHeartbeats().catch(this._logError.bind(this));
|
|
179
|
+
}, this._heartbeatIntervalMs);
|
|
180
|
+
if (this._heartbeatTimer && typeof this._heartbeatTimer === 'object' && 'unref' in this._heartbeatTimer) {
|
|
181
|
+
this._heartbeatTimer.unref();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
this._state = 'ACTIVE';
|
|
185
|
+
this._log('Execlave SDK initialized (env=%s, async=%s)', this._environment, this._asyncMode);
|
|
186
|
+
// Register graceful shutdown handlers for SIGTERM/SIGINT
|
|
187
|
+
const gracefulShutdown = () => {
|
|
188
|
+
this.shutdown().catch(() => { }).finally(() => process.exit(0));
|
|
189
|
+
};
|
|
190
|
+
process.once('SIGTERM', gracefulShutdown);
|
|
191
|
+
process.once('SIGINT', gracefulShutdown);
|
|
192
|
+
}
|
|
193
|
+
// ========================================================================
|
|
194
|
+
// API path helper
|
|
195
|
+
// ========================================================================
|
|
196
|
+
/**
|
|
197
|
+
* Build a versioned API path.
|
|
198
|
+
*
|
|
199
|
+
* If `apiVersion` is set (e.g. `'v1'`), returns `/api/v1${path}`.
|
|
200
|
+
* Otherwise falls back to the legacy `/api${path}` format.
|
|
201
|
+
*/
|
|
202
|
+
apiPath(path) {
|
|
203
|
+
if (this._apiVersion) {
|
|
204
|
+
return `/api/${this._apiVersion}${path}`;
|
|
205
|
+
}
|
|
206
|
+
return `/api${path}`;
|
|
207
|
+
}
|
|
208
|
+
// ========================================================================
|
|
209
|
+
// Public API
|
|
210
|
+
// ========================================================================
|
|
211
|
+
/** Check if the Execlave API is reachable. */
|
|
212
|
+
async ping() {
|
|
213
|
+
try {
|
|
214
|
+
// Use unversioned /health (the versioned /api/v1/health doesn't exist)
|
|
215
|
+
const resp = await (0, http_1.request)({
|
|
216
|
+
method: 'GET',
|
|
217
|
+
url: `${this._baseUrl}/health`,
|
|
218
|
+
headers: { Authorization: `Bearer ${this._apiKey}` },
|
|
219
|
+
timeout: 5000,
|
|
220
|
+
});
|
|
221
|
+
return resp.status === 200;
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Register (or re-register) an AI agent. Idempotent — call on startup.
|
|
229
|
+
*
|
|
230
|
+
* @returns An Agent object with prompt management methods.
|
|
231
|
+
*/
|
|
232
|
+
async registerAgent(opts) {
|
|
233
|
+
const payload = {
|
|
234
|
+
agentId: opts.agentId,
|
|
235
|
+
name: opts.name,
|
|
236
|
+
type: opts.type ?? 'chatbot',
|
|
237
|
+
platform: opts.platform ?? 'custom',
|
|
238
|
+
environment: opts.environment ?? this._environment,
|
|
239
|
+
};
|
|
240
|
+
if (opts.description)
|
|
241
|
+
payload.description = opts.description;
|
|
242
|
+
if (opts.ownerEmail)
|
|
243
|
+
payload.ownerEmail = opts.ownerEmail;
|
|
244
|
+
if (opts.allowedDataSources)
|
|
245
|
+
payload.allowedDataSources = opts.allowedDataSources;
|
|
246
|
+
if (opts.allowedActions)
|
|
247
|
+
payload.allowedActions = opts.allowedActions;
|
|
248
|
+
if (opts.requiresHumanApprovalFor)
|
|
249
|
+
payload.requiresHumanApprovalFor = opts.requiresHumanApprovalFor;
|
|
250
|
+
if (opts.tags)
|
|
251
|
+
payload.tags = opts.tags;
|
|
252
|
+
if (opts.metadata)
|
|
253
|
+
payload.metadata = opts.metadata;
|
|
254
|
+
try {
|
|
255
|
+
const resp = await this._request('POST', this.apiPath('/agents'), payload);
|
|
256
|
+
const data = (resp.data ?? resp);
|
|
257
|
+
const agent = new agent_1.Agent(this, data);
|
|
258
|
+
this._agents.set(opts.agentId, agent);
|
|
259
|
+
return agent;
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
// If agent already exists, try to fetch it
|
|
263
|
+
if (err instanceof errors_1.ExeclaveError && err.message.includes('already exists')) {
|
|
264
|
+
const listResp = await this._request('GET', `${this.apiPath('/agents')}?search=${encodeURIComponent(opts.agentId)}`);
|
|
265
|
+
const agents = (listResp.data ?? []);
|
|
266
|
+
const match = agents.find((a) => a.agentId === opts.agentId);
|
|
267
|
+
if (match) {
|
|
268
|
+
const agent = new agent_1.Agent(this, match);
|
|
269
|
+
this._agents.set(opts.agentId, agent);
|
|
270
|
+
return agent;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
throw err;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Start a manual trace. Call `trace.finish()` when done.
|
|
278
|
+
*
|
|
279
|
+
* @returns A Trace handle with chainable setters.
|
|
280
|
+
*/
|
|
281
|
+
startTrace(opts = {}) {
|
|
282
|
+
this._ensureNotShutdown();
|
|
283
|
+
const resolvedAgentId = opts.agentId ?? this._firstAgentId();
|
|
284
|
+
if (this._state === 'PAUSED') {
|
|
285
|
+
throw new errors_1.AgentPausedError(resolvedAgentId ?? 'unknown');
|
|
286
|
+
}
|
|
287
|
+
return new trace_1.Trace(this, {
|
|
288
|
+
agentId: resolvedAgentId,
|
|
289
|
+
traceId: opts.traceId,
|
|
290
|
+
sessionId: opts.sessionId,
|
|
291
|
+
userId: opts.userId,
|
|
292
|
+
metadata: opts.metadata,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Wrap an async function with automatic tracing.
|
|
297
|
+
*
|
|
298
|
+
* @example
|
|
299
|
+
* ```ts
|
|
300
|
+
* const tracedAnswer = exe.wrap(async (question: string) => {
|
|
301
|
+
* return await llm.call(question);
|
|
302
|
+
* }, { agentId: 'my-bot' });
|
|
303
|
+
*
|
|
304
|
+
* const answer = await tracedAnswer('Hello?');
|
|
305
|
+
* ```
|
|
306
|
+
*/
|
|
307
|
+
wrap(fn, opts = {}) {
|
|
308
|
+
return async (...args) => {
|
|
309
|
+
const trace = this.startTrace(opts);
|
|
310
|
+
trace.setInput(args.length === 1 ? args[0] : args);
|
|
311
|
+
try {
|
|
312
|
+
const result = await fn(...args);
|
|
313
|
+
trace.setOutput(result);
|
|
314
|
+
trace.finish('success');
|
|
315
|
+
return result;
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
const error = err;
|
|
319
|
+
trace.finish('error', error.message, error.name);
|
|
320
|
+
throw err;
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Check the current status of a registered agent.
|
|
326
|
+
*
|
|
327
|
+
* @returns 'active', 'paused', or 'error'.
|
|
328
|
+
*/
|
|
329
|
+
async checkAgentStatus(agentId) {
|
|
330
|
+
const agent = agentId ? this._agents.get(agentId) : this._firstAgent();
|
|
331
|
+
if (!agent)
|
|
332
|
+
return 'unknown';
|
|
333
|
+
try {
|
|
334
|
+
const resp = await this._request('GET', this.apiPath(`/agents/${agent.id}/status-poll`));
|
|
335
|
+
const status = resp.data?.status ?? 'active';
|
|
336
|
+
agent.status = status;
|
|
337
|
+
return status;
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return 'error';
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/** Flush all buffered traces to the API. */
|
|
344
|
+
async flush() {
|
|
345
|
+
await this._doFlush();
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Pre-execution policy enforcement.
|
|
349
|
+
*
|
|
350
|
+
* Call this **before** running the LLM to check whether policies allow execution.
|
|
351
|
+
* Throws `PolicyBlockedError` if any policy with `enforcement_mode='block'` is violated.
|
|
352
|
+
* Returns warnings for `warn`-mode violations.
|
|
353
|
+
*
|
|
354
|
+
* @example
|
|
355
|
+
* ```ts
|
|
356
|
+
* try {
|
|
357
|
+
* const result = await exe.enforcePolicy({
|
|
358
|
+
* agentId: agent.id,
|
|
359
|
+
* input: userQuestion,
|
|
360
|
+
* tools: ['web_search'],
|
|
361
|
+
* });
|
|
362
|
+
* if (result.warnings?.length) console.warn('Policy warnings:', result.warnings);
|
|
363
|
+
* // Safe to proceed
|
|
364
|
+
* const answer = await llm.call(userQuestion);
|
|
365
|
+
* } catch (err) {
|
|
366
|
+
* if (err instanceof PolicyBlockedError) {
|
|
367
|
+
* return 'Sorry, I cannot process that request.';
|
|
368
|
+
* }
|
|
369
|
+
* }
|
|
370
|
+
* ```
|
|
371
|
+
*/
|
|
372
|
+
async enforcePolicy(opts) {
|
|
373
|
+
this._ensureNotShutdown();
|
|
374
|
+
this._throwIfQuotaExceeded();
|
|
375
|
+
// 1. Check cache
|
|
376
|
+
const cacheKey = this._policyCacheKey(opts.agentId, opts.input);
|
|
377
|
+
const cached = this._policyCacheGet(cacheKey);
|
|
378
|
+
if (cached) {
|
|
379
|
+
this._log('Policy cache hit for %s', opts.agentId);
|
|
380
|
+
return cached;
|
|
381
|
+
}
|
|
382
|
+
// 2. Check circuit breaker
|
|
383
|
+
if (this._cbIsOpen()) {
|
|
384
|
+
if (this._enforcementOnOutage === 'fail_closed') {
|
|
385
|
+
throw new errors_1.EnforcementUnavailableError(this._cbFailures, this._cbLastError);
|
|
386
|
+
}
|
|
387
|
+
this._log('Circuit breaker open — fail_open, allowing execution for %s', opts.agentId);
|
|
388
|
+
return { allowed: true };
|
|
389
|
+
}
|
|
390
|
+
// 3. Build payload and make HTTP call
|
|
391
|
+
// Resolve external agentId to internal UUID if we have a cached agent
|
|
392
|
+
const resolvedAgentId = this._resolveAgentId(opts.agentId);
|
|
393
|
+
const payload = {
|
|
394
|
+
agentId: resolvedAgentId,
|
|
395
|
+
input: opts.input,
|
|
396
|
+
environment: opts.environment ?? this._environment,
|
|
397
|
+
metadata: opts.metadata,
|
|
398
|
+
estimatedCost: opts.estimatedCost,
|
|
399
|
+
tools: opts.tools,
|
|
400
|
+
};
|
|
401
|
+
const url = `${this._baseUrl}${this.apiPath('/policies/enforce')}`;
|
|
402
|
+
let resp;
|
|
403
|
+
try {
|
|
404
|
+
resp = await (0, http_1.request)({
|
|
405
|
+
method: 'POST',
|
|
406
|
+
url,
|
|
407
|
+
headers: { Authorization: `Bearer ${this._apiKey}` },
|
|
408
|
+
body: payload,
|
|
409
|
+
resolveOnClientError: true,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
catch (err) {
|
|
413
|
+
// Network failure → circuit breaker
|
|
414
|
+
this._cbRecordFailure(err.message ?? String(err));
|
|
415
|
+
if (this._enforcementOnOutage === 'fail_closed' && this._cbIsOpen()) {
|
|
416
|
+
throw new errors_1.EnforcementUnavailableError(this._cbFailures, err.message);
|
|
417
|
+
}
|
|
418
|
+
this._log('Network error in enforcePolicy (fail_open): %s', err.message);
|
|
419
|
+
return { allowed: true };
|
|
420
|
+
}
|
|
421
|
+
// 4. Record success in circuit breaker
|
|
422
|
+
this._cbRecordSuccess();
|
|
423
|
+
// 5. Handle response codes
|
|
424
|
+
// 403 → blocked by policy
|
|
425
|
+
if (resp.status === 403 && resp.data?.allowed === false) {
|
|
426
|
+
throw new errors_1.PolicyBlockedError(resp.data.violations ?? []);
|
|
427
|
+
}
|
|
428
|
+
// 202 → require approval (never cached)
|
|
429
|
+
if (resp.status === 202 && resp.data?.approvalRequestId) {
|
|
430
|
+
const approvalRequestId = resp.data.approvalRequestId;
|
|
431
|
+
return this._pollApprovalDecision(approvalRequestId);
|
|
432
|
+
}
|
|
433
|
+
// 402 → plan quota exhausted
|
|
434
|
+
if (resp.status === 402) {
|
|
435
|
+
const quotaError = this._quotaErrorFromBody(resp.data);
|
|
436
|
+
this._setQuotaExceeded(quotaError);
|
|
437
|
+
if (this._planLimitBehavior === 'fail_open') {
|
|
438
|
+
this._log(`[warn] Plan limit exceeded for ${quotaError.resource} (${quotaError.current}/${quotaError.max}) — continuing unmonitored`);
|
|
439
|
+
return { allowed: true, warnings: [{ policyId: 'plan_limit', policyName: 'Plan Limit', policyType: 'plan_limit', message: quotaError.message, enforcementMode: 'warn' }] };
|
|
440
|
+
}
|
|
441
|
+
throw new errors_1.PlanLimitExceededError(quotaError.resource, quotaError.current, quotaError.max, quotaError.message);
|
|
442
|
+
}
|
|
443
|
+
// Other client errors
|
|
444
|
+
if (resp.status >= 400) {
|
|
445
|
+
throw new errors_1.ExeclaveError(`Enforce policy failed (${resp.status}): ${resp.data?.error?.message ?? 'Unknown error'}`);
|
|
446
|
+
}
|
|
447
|
+
// 6. Cache and return
|
|
448
|
+
const result = resp.data;
|
|
449
|
+
this._policyCacheSet(cacheKey, result);
|
|
450
|
+
return result;
|
|
451
|
+
}
|
|
452
|
+
// ========================================================================
|
|
453
|
+
// Circuit Breaker Helpers
|
|
454
|
+
// ========================================================================
|
|
455
|
+
_cbRecordSuccess() {
|
|
456
|
+
this._cbFailures = 0;
|
|
457
|
+
this._cbOpen = false;
|
|
458
|
+
this._cbLastError = undefined;
|
|
459
|
+
}
|
|
460
|
+
_cbRecordFailure(errorMsg) {
|
|
461
|
+
this._cbFailures++;
|
|
462
|
+
this._cbLastError = errorMsg;
|
|
463
|
+
if (this._cbFailures >= this._cbThreshold) {
|
|
464
|
+
this._cbOpen = true;
|
|
465
|
+
this._cbOpenAt = Date.now();
|
|
466
|
+
this._log('Circuit breaker OPEN after %d failures (mode=%s)', this._cbFailures, this._enforcementOnOutage);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
_cbIsOpen() {
|
|
470
|
+
if (!this._cbOpen)
|
|
471
|
+
return false;
|
|
472
|
+
// Half-open: allow retry after reset period
|
|
473
|
+
if (Date.now() - this._cbOpenAt > this._cbResetAfterMs) {
|
|
474
|
+
this._log('Circuit breaker half-open — retrying');
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
// ========================================================================
|
|
480
|
+
// Policy Cache Helpers
|
|
481
|
+
// ========================================================================
|
|
482
|
+
_policyCacheKey(agentId, input) {
|
|
483
|
+
const hash = (0, crypto_1.createHash)('sha256').update(`${agentId}:${input}`).digest('hex').slice(0, 16);
|
|
484
|
+
return `policy:${hash}`;
|
|
485
|
+
}
|
|
486
|
+
_policyCacheGet(key) {
|
|
487
|
+
const entry = this._policyCache.get(key);
|
|
488
|
+
if (entry && entry.expiresAt > Date.now())
|
|
489
|
+
return entry.response;
|
|
490
|
+
if (entry)
|
|
491
|
+
this._policyCache.delete(key);
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
_policyCacheSet(key, response) {
|
|
495
|
+
this._policyCache.set(key, { response, expiresAt: Date.now() + this._policyCacheTtlMs });
|
|
496
|
+
// Evict old entries (keep max 500)
|
|
497
|
+
if (this._policyCache.size > 500) {
|
|
498
|
+
const entries = [...this._policyCache.entries()].sort((a, b) => a[1].expiresAt - b[1].expiresAt);
|
|
499
|
+
for (let i = 0; i < 100 && i < entries.length; i++) {
|
|
500
|
+
this._policyCache.delete(entries[i][0]);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
_toInt(value, fallback = 0) {
|
|
505
|
+
const parsed = Number(value);
|
|
506
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
507
|
+
}
|
|
508
|
+
_quotaErrorFromBody(body) {
|
|
509
|
+
const err = body?.error ?? {};
|
|
510
|
+
return new errors_1.QuotaExceededError(String(err.resource ?? 'unknown'), this._toInt(err.current, 0), this._toInt(err.max, 0), String(err.message ?? ''));
|
|
511
|
+
}
|
|
512
|
+
_setQuotaExceeded(error) {
|
|
513
|
+
// Cache only trace quota; this drives fail-fast for enforce/trace hot paths.
|
|
514
|
+
if (error.resource !== 'maxTracesPerMonth')
|
|
515
|
+
return;
|
|
516
|
+
this._quotaExceeded = {
|
|
517
|
+
error,
|
|
518
|
+
expiresAt: Date.now() + this._quotaCacheTtlMs,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
_getCachedQuotaExceeded() {
|
|
522
|
+
if (!this._quotaExceeded)
|
|
523
|
+
return null;
|
|
524
|
+
if (Date.now() >= this._quotaExceeded.expiresAt) {
|
|
525
|
+
this._quotaExceeded = null;
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
return this._quotaExceeded.error;
|
|
529
|
+
}
|
|
530
|
+
_throwIfQuotaExceeded() {
|
|
531
|
+
const cached = this._getCachedQuotaExceeded();
|
|
532
|
+
if (!cached)
|
|
533
|
+
return;
|
|
534
|
+
if (this._planLimitBehavior === 'fail_open')
|
|
535
|
+
return;
|
|
536
|
+
throw new errors_1.PlanLimitExceededError(cached.resource, cached.current, cached.max, cached.message);
|
|
537
|
+
}
|
|
538
|
+
// ========================================================================
|
|
539
|
+
// Heartbeat
|
|
540
|
+
// ========================================================================
|
|
541
|
+
async _sendHeartbeats() {
|
|
542
|
+
for (const [, agent] of this._agents) {
|
|
543
|
+
try {
|
|
544
|
+
await (0, http_1.request)({
|
|
545
|
+
method: 'POST',
|
|
546
|
+
url: `${this._baseUrl}${this.apiPath(`/agents/${agent.id}/heartbeat`)}`,
|
|
547
|
+
headers: { Authorization: `Bearer ${this._apiKey}` },
|
|
548
|
+
body: { lastPolicyCheckAt: null },
|
|
549
|
+
timeout: 10000,
|
|
550
|
+
});
|
|
551
|
+
this._log('Heartbeat sent for agent %s', agent.id);
|
|
552
|
+
}
|
|
553
|
+
catch (err) {
|
|
554
|
+
this._log('Heartbeat failed for agent %s: %s', agent.id, err.message);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
async _pollApprovalDecision(approvalRequestId) {
|
|
559
|
+
const startedAt = Date.now();
|
|
560
|
+
const timeoutMs = 30 * 60 * 1000;
|
|
561
|
+
const pollIntervalMs = 5000;
|
|
562
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
563
|
+
const url = `${this._baseUrl}${this.apiPath(`/approvals/${approvalRequestId}`)}`;
|
|
564
|
+
const resp = await (0, http_1.request)({
|
|
565
|
+
method: 'GET',
|
|
566
|
+
url,
|
|
567
|
+
headers: { Authorization: `Bearer ${this._apiKey}` },
|
|
568
|
+
resolveOnClientError: true,
|
|
569
|
+
});
|
|
570
|
+
if (resp.status >= 400) {
|
|
571
|
+
throw new errors_1.ExeclaveError(`Approval polling failed (${resp.status}): ${resp.data?.error?.message ?? 'Unknown error'}`);
|
|
572
|
+
}
|
|
573
|
+
const approval = resp.data?.data;
|
|
574
|
+
if (!approval) {
|
|
575
|
+
throw new errors_1.ExeclaveError('Approval polling returned no approval payload');
|
|
576
|
+
}
|
|
577
|
+
if (approval.status === 'approved') {
|
|
578
|
+
return { allowed: true, approvalRequestId };
|
|
579
|
+
}
|
|
580
|
+
if (approval.status === 'denied') {
|
|
581
|
+
throw new errors_1.PolicyDeniedError(approvalRequestId, approval.decisionReason);
|
|
582
|
+
}
|
|
583
|
+
if (approval.status === 'expired') {
|
|
584
|
+
throw new errors_1.ApprovalTimeoutError(approvalRequestId);
|
|
585
|
+
}
|
|
586
|
+
await this._sleep(pollIntervalMs);
|
|
587
|
+
}
|
|
588
|
+
throw new errors_1.ApprovalTimeoutError(approvalRequestId);
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Check if one agent is authorized to call another.
|
|
592
|
+
*
|
|
593
|
+
* @returns Authorization result. Throws `ExeclaveAuthError` on 403.
|
|
594
|
+
*/
|
|
595
|
+
async authorizeAgentCall(opts) {
|
|
596
|
+
this._ensureNotShutdown();
|
|
597
|
+
const resp = await this._request('POST', this.apiPath('/agents/authorize'), {
|
|
598
|
+
callerAgentId: opts.callerAgentId,
|
|
599
|
+
calleeAgentId: opts.calleeAgentId,
|
|
600
|
+
action: opts.action,
|
|
601
|
+
});
|
|
602
|
+
return resp;
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Discover agents by capability.
|
|
606
|
+
*
|
|
607
|
+
* @param capability Optional capability to filter by (e.g. 'send_email').
|
|
608
|
+
* If omitted, returns all agents with capabilities.
|
|
609
|
+
*/
|
|
610
|
+
async discoverAgents(capability) {
|
|
611
|
+
this._ensureNotShutdown();
|
|
612
|
+
const qs = capability ? `?capability=${encodeURIComponent(capability)}` : '';
|
|
613
|
+
const resp = await this._request('GET', `${this.apiPath('/agents/discover')}${qs}`);
|
|
614
|
+
return (resp.data ?? []);
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Return current plan usage and limits.
|
|
618
|
+
*/
|
|
619
|
+
async checkUsage() {
|
|
620
|
+
this._ensureNotShutdown();
|
|
621
|
+
const resp = await this._requestRaw('GET', this.apiPath('/billing/usage'));
|
|
622
|
+
const data = (resp.data?.data ?? resp.data ?? {});
|
|
623
|
+
const nestedUsage = data.usage ?? {};
|
|
624
|
+
const pickBucket = (resource) => {
|
|
625
|
+
const fromNested = nestedUsage?.[resource];
|
|
626
|
+
if (fromNested && typeof fromNested === 'object') {
|
|
627
|
+
return {
|
|
628
|
+
current: this._toInt(fromNested.current, 0),
|
|
629
|
+
max: this._toInt(fromNested.max, 0),
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
const fromTopLevel = data?.[resource] ?? {};
|
|
633
|
+
return {
|
|
634
|
+
current: this._toInt(fromTopLevel.current, 0),
|
|
635
|
+
max: this._toInt(fromTopLevel.max, 0),
|
|
636
|
+
};
|
|
637
|
+
};
|
|
638
|
+
return {
|
|
639
|
+
plan: String(data.plan ?? 'unknown'),
|
|
640
|
+
agents: pickBucket('agents'),
|
|
641
|
+
traces: pickBucket('traces'),
|
|
642
|
+
users: pickBucket('users'),
|
|
643
|
+
policies: pickBucket('policies'),
|
|
644
|
+
upgradeUrl: String(data.upgradeUrl ?? '') || 'https://www.execlave.com/dashboard/billing',
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
/** Flush remaining traces and shut down the SDK. */
|
|
648
|
+
async shutdown() {
|
|
649
|
+
this._state = 'SHUTDOWN';
|
|
650
|
+
if (this._flushTimer) {
|
|
651
|
+
clearInterval(this._flushTimer);
|
|
652
|
+
this._flushTimer = null;
|
|
653
|
+
}
|
|
654
|
+
if (this._pollTimer) {
|
|
655
|
+
clearInterval(this._pollTimer);
|
|
656
|
+
this._pollTimer = null;
|
|
657
|
+
}
|
|
658
|
+
if (this._socket) {
|
|
659
|
+
this._socket.disconnect();
|
|
660
|
+
this._socket = null;
|
|
661
|
+
}
|
|
662
|
+
await this._doFlush();
|
|
663
|
+
await this._otelExporter?.shutdown();
|
|
664
|
+
this._log('Execlave SDK shut down');
|
|
665
|
+
}
|
|
666
|
+
// ========================================================================
|
|
667
|
+
// Internal — called by Trace and Agent
|
|
668
|
+
// ========================================================================
|
|
669
|
+
/** @internal */
|
|
670
|
+
_bufferTrace(payload) {
|
|
671
|
+
this._throwIfQuotaExceeded();
|
|
672
|
+
// Client-side PII scrubbing
|
|
673
|
+
if (this._privacy.enabled) {
|
|
674
|
+
this._applyPrivacy(payload);
|
|
675
|
+
}
|
|
676
|
+
// Client-side injection scanning
|
|
677
|
+
if (this._enableInjectionScan) {
|
|
678
|
+
const injection = this._scanInjection(payload);
|
|
679
|
+
if (injection.detected) {
|
|
680
|
+
if (!payload.metadata)
|
|
681
|
+
payload.metadata = {};
|
|
682
|
+
payload.metadata.injection_scan = injection;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
// Circular buffer — drop oldest when full
|
|
686
|
+
if (this._buffer.length >= MAX_BUFFER_SIZE) {
|
|
687
|
+
this._buffer.shift();
|
|
688
|
+
}
|
|
689
|
+
this._buffer.push(payload);
|
|
690
|
+
this._log('Buffered trace %s (size: %d)', payload.traceId, this._buffer.length);
|
|
691
|
+
// If sync mode or buffer is full, flush immediately
|
|
692
|
+
if (!this._asyncMode || this._buffer.length >= this._batchSize) {
|
|
693
|
+
this._doFlush().catch(this._logError.bind(this));
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
/** @internal */
|
|
697
|
+
_apiPath(path) {
|
|
698
|
+
return this.apiPath(path);
|
|
699
|
+
}
|
|
700
|
+
/** @internal */
|
|
701
|
+
async _request(method, path, body) {
|
|
702
|
+
const resp = await this._requestRaw(method, path, body);
|
|
703
|
+
return resp.data;
|
|
704
|
+
}
|
|
705
|
+
async _requestRaw(method, path, body, resolveOnClientError = false) {
|
|
706
|
+
const url = `${this._baseUrl}${path}`;
|
|
707
|
+
return (0, http_1.request)({
|
|
708
|
+
method,
|
|
709
|
+
url,
|
|
710
|
+
headers: {
|
|
711
|
+
Authorization: `Bearer ${this._apiKey}`,
|
|
712
|
+
},
|
|
713
|
+
body,
|
|
714
|
+
resolveOnClientError,
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
// ========================================================================
|
|
718
|
+
// Private
|
|
719
|
+
// ========================================================================
|
|
720
|
+
/**
|
|
721
|
+
* Resolve an external agentId string to the internal UUID.
|
|
722
|
+
* The API endpoints like /policies/enforce expect the internal UUID,
|
|
723
|
+
* but users naturally pass the external agentId (e.g. "my-bot").
|
|
724
|
+
* This looks up the cached Agent and returns its UUID (.id).
|
|
725
|
+
* If no match is found, returns the original value unchanged.
|
|
726
|
+
*/
|
|
727
|
+
_resolveAgentId(agentId) {
|
|
728
|
+
const agent = this._agents.get(agentId);
|
|
729
|
+
if (agent) {
|
|
730
|
+
return agent.id; // internal UUID
|
|
731
|
+
}
|
|
732
|
+
// Maybe the caller already passed a UUID — return as-is
|
|
733
|
+
return agentId;
|
|
734
|
+
}
|
|
735
|
+
async _doFlush() {
|
|
736
|
+
if (this._buffer.length === 0)
|
|
737
|
+
return;
|
|
738
|
+
const batch = this._buffer.splice(0, this._buffer.length);
|
|
739
|
+
// OTLP mode — delegate to OTel exporter
|
|
740
|
+
if (this._mode === 'otlp') {
|
|
741
|
+
if (this._otelReady)
|
|
742
|
+
await this._otelReady;
|
|
743
|
+
if (this._otelExporter) {
|
|
744
|
+
for (let i = 0; i < batch.length; i += this._batchSize) {
|
|
745
|
+
const chunk = batch.slice(i, i + this._batchSize);
|
|
746
|
+
try {
|
|
747
|
+
this._otelExporter.exportTraces(chunk);
|
|
748
|
+
this._log('Exported %d traces via OTLP', chunk.length);
|
|
749
|
+
}
|
|
750
|
+
catch (err) {
|
|
751
|
+
this._logError(`OTLP export failed for ${chunk.length} traces: ${err.message}`);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
else {
|
|
756
|
+
this._logError('OTel exporter not ready — dropping traces');
|
|
757
|
+
}
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
// Native mode — POST to Execlave API
|
|
761
|
+
for (let i = 0; i < batch.length; i += this._batchSize) {
|
|
762
|
+
const chunk = batch.slice(i, i + this._batchSize);
|
|
763
|
+
let retries = 0;
|
|
764
|
+
while (retries < 3) {
|
|
765
|
+
try {
|
|
766
|
+
const resp = await this._requestRaw('POST', this.apiPath('/traces/ingest'), { traces: chunk }, true);
|
|
767
|
+
if (resp.status === 402) {
|
|
768
|
+
const quotaError = this._quotaErrorFromBody(resp.data);
|
|
769
|
+
this._setQuotaExceeded(quotaError);
|
|
770
|
+
this._logError(`Trace quota exceeded while flushing ${chunk.length} traces: ${quotaError.message}`);
|
|
771
|
+
break;
|
|
772
|
+
}
|
|
773
|
+
if (resp.status >= 400) {
|
|
774
|
+
throw new errors_1.ExeclaveError(`Trace ingestion failed (${resp.status}): ${resp.data?.error?.message ?? 'Unknown error'}`);
|
|
775
|
+
}
|
|
776
|
+
this._log('Flushed %d traces', chunk.length);
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
catch (err) {
|
|
780
|
+
retries++;
|
|
781
|
+
if (retries >= 3) {
|
|
782
|
+
this._logError(`Failed to flush ${chunk.length} traces after 3 retries: ${err.message}`);
|
|
783
|
+
}
|
|
784
|
+
else {
|
|
785
|
+
await this._sleep(2 ** retries * 500);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
async _statusPoll() {
|
|
792
|
+
for (const [agentId, agent] of this._agents) {
|
|
793
|
+
try {
|
|
794
|
+
const resp = await this._request('GET', this.apiPath(`/agents/${agent.id}/status-poll`));
|
|
795
|
+
const newStatus = resp.data?.status ?? 'active';
|
|
796
|
+
if (newStatus === 'paused' && this._state === 'ACTIVE') {
|
|
797
|
+
this._state = 'PAUSED';
|
|
798
|
+
agent.status = 'paused';
|
|
799
|
+
this._log('Agent %s has been PAUSED via kill switch', agentId);
|
|
800
|
+
}
|
|
801
|
+
else if (newStatus === 'active' && this._state === 'PAUSED') {
|
|
802
|
+
this._state = 'ACTIVE';
|
|
803
|
+
agent.status = 'active';
|
|
804
|
+
this._log('Agent %s has been RESUMED', agentId);
|
|
805
|
+
}
|
|
806
|
+
agent.status = newStatus;
|
|
807
|
+
}
|
|
808
|
+
catch {
|
|
809
|
+
this._log('Status poll failed for agent %s', agentId);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
// ========================================================================
|
|
814
|
+
// Privacy & Injection Scanning
|
|
815
|
+
// ========================================================================
|
|
816
|
+
_hashPii(value) {
|
|
817
|
+
return (0, crypto_1.createHash)('sha256').update(value).digest('hex').slice(0, 16);
|
|
818
|
+
}
|
|
819
|
+
_toText(data) {
|
|
820
|
+
if (data == null)
|
|
821
|
+
return '';
|
|
822
|
+
if (typeof data === 'string')
|
|
823
|
+
return data;
|
|
824
|
+
if (typeof data === 'object') {
|
|
825
|
+
if (Array.isArray(data))
|
|
826
|
+
return data.map(String).join(' ');
|
|
827
|
+
return Object.values(data).map(String).join(' ');
|
|
828
|
+
}
|
|
829
|
+
return String(data);
|
|
830
|
+
}
|
|
831
|
+
_scrubText(text) {
|
|
832
|
+
if (!text)
|
|
833
|
+
return text ?? '';
|
|
834
|
+
let result = text;
|
|
835
|
+
for (const [piiType, pattern] of Object.entries(PII_PATTERNS)) {
|
|
836
|
+
result = result.replace(new RegExp(pattern.source, pattern.flags), `[${piiType.toUpperCase()}_REDACTED]`);
|
|
837
|
+
}
|
|
838
|
+
return result;
|
|
839
|
+
}
|
|
840
|
+
_applyPrivacy(payload) {
|
|
841
|
+
const scrubFields = this._privacy.scrubFields ?? ['input', 'output'];
|
|
842
|
+
const hashPii = this._privacy.hashPii ?? true;
|
|
843
|
+
const piiSummary = {};
|
|
844
|
+
for (const field of scrubFields) {
|
|
845
|
+
const value = payload[field];
|
|
846
|
+
if (!value)
|
|
847
|
+
continue;
|
|
848
|
+
const text = this._toText(value);
|
|
849
|
+
if (!text)
|
|
850
|
+
continue;
|
|
851
|
+
// Detect PII
|
|
852
|
+
for (const [piiType, pattern] of Object.entries(PII_PATTERNS)) {
|
|
853
|
+
const matches = text.match(new RegExp(pattern.source, pattern.flags));
|
|
854
|
+
if (matches && matches.length > 0) {
|
|
855
|
+
if (!piiSummary[piiType])
|
|
856
|
+
piiSummary[piiType] = { count: 0, hashes: [] };
|
|
857
|
+
piiSummary[piiType].count += matches.length;
|
|
858
|
+
if (hashPii) {
|
|
859
|
+
piiSummary[piiType].hashes.push(...matches.map((m) => this._hashPii(m)));
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
// Replace PII with placeholders
|
|
864
|
+
if (typeof value === 'string') {
|
|
865
|
+
payload[field] = this._scrubText(value);
|
|
866
|
+
}
|
|
867
|
+
else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
868
|
+
const scrubbed = {};
|
|
869
|
+
for (const [k, v] of Object.entries(value)) {
|
|
870
|
+
scrubbed[k] = typeof v === 'string' ? this._scrubText(v) : v;
|
|
871
|
+
}
|
|
872
|
+
payload[field] = scrubbed;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
if (Object.keys(piiSummary).length > 0) {
|
|
876
|
+
if (!payload.metadata)
|
|
877
|
+
payload.metadata = {};
|
|
878
|
+
payload.metadata.pii_detected = piiSummary;
|
|
879
|
+
payload.metadata.pii_scrubbed = true;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
_scanInjection(payload) {
|
|
883
|
+
const text = this._toText(payload.input);
|
|
884
|
+
if (!text)
|
|
885
|
+
return { detected: false, risk_level: 'none', patterns_matched: [] };
|
|
886
|
+
const matched = [];
|
|
887
|
+
for (const pattern of INJECTION_PATTERNS) {
|
|
888
|
+
if (pattern.test(text)) {
|
|
889
|
+
matched.push(pattern.source);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
const count = matched.length;
|
|
893
|
+
let risk;
|
|
894
|
+
if (count === 0)
|
|
895
|
+
risk = 'none';
|
|
896
|
+
else if (count === 1)
|
|
897
|
+
risk = 'low';
|
|
898
|
+
else if (count <= 3)
|
|
899
|
+
risk = 'medium';
|
|
900
|
+
else if (count <= 5)
|
|
901
|
+
risk = 'high';
|
|
902
|
+
else
|
|
903
|
+
risk = 'critical';
|
|
904
|
+
return { detected: count > 0, risk_level: risk, patterns_matched: matched };
|
|
905
|
+
}
|
|
906
|
+
// ========================================================================
|
|
907
|
+
// Helpers
|
|
908
|
+
// ========================================================================
|
|
909
|
+
_firstAgentId() {
|
|
910
|
+
const first = this._agents.values().next();
|
|
911
|
+
return first.done ? undefined : first.value.agentId;
|
|
912
|
+
}
|
|
913
|
+
_firstAgent() {
|
|
914
|
+
const first = this._agents.values().next();
|
|
915
|
+
return first.done ? undefined : first.value;
|
|
916
|
+
}
|
|
917
|
+
_ensureNotShutdown() {
|
|
918
|
+
if (this._state === 'SHUTDOWN') {
|
|
919
|
+
throw new errors_1.ExeclaveError('SDK has been shut down. Call not allowed.');
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
_sleep(ms) {
|
|
923
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* Connect to the Socket.IO /sdk namespace for real-time control channel.
|
|
927
|
+
* Falls back silently to HTTP polling if socket.io-client is not installed.
|
|
928
|
+
*/
|
|
929
|
+
_connectWebSocket() {
|
|
930
|
+
try {
|
|
931
|
+
// Dynamic require — socket.io-client is an optional peer dependency
|
|
932
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
933
|
+
const { io } = require('socket.io-client');
|
|
934
|
+
// Use the first registered agent's agentId if available
|
|
935
|
+
const agentId = this._firstAgentId();
|
|
936
|
+
this._socket = io(`${this._baseUrl}/sdk`, {
|
|
937
|
+
auth: {
|
|
938
|
+
apiKey: this._apiKey,
|
|
939
|
+
agentId,
|
|
940
|
+
},
|
|
941
|
+
transports: ['websocket'],
|
|
942
|
+
reconnection: true,
|
|
943
|
+
reconnectionDelay: 2000,
|
|
944
|
+
reconnectionAttempts: 10,
|
|
945
|
+
});
|
|
946
|
+
this._socket.on('agent.status_updated', (data) => {
|
|
947
|
+
const agent = data.agentId ? this._agents.get(data.agentId) : undefined;
|
|
948
|
+
if (data.status === 'paused' && this._state === 'ACTIVE') {
|
|
949
|
+
this._state = 'PAUSED';
|
|
950
|
+
if (agent)
|
|
951
|
+
agent.status = 'paused';
|
|
952
|
+
this._log('Agent %s PAUSED via WebSocket kill switch (reason: %s)', data.agentId, data.reason ?? 'none');
|
|
953
|
+
}
|
|
954
|
+
else if (data.status === 'active' && this._state === 'PAUSED') {
|
|
955
|
+
this._state = 'ACTIVE';
|
|
956
|
+
if (agent)
|
|
957
|
+
agent.status = 'active';
|
|
958
|
+
this._log('Agent %s RESUMED via WebSocket', data.agentId);
|
|
959
|
+
}
|
|
960
|
+
});
|
|
961
|
+
this._socket.on('connect', () => {
|
|
962
|
+
this._log('WebSocket control channel connected');
|
|
963
|
+
});
|
|
964
|
+
this._socket.on('connect_error', (err) => {
|
|
965
|
+
// Silently fall back to HTTP polling — no user action needed
|
|
966
|
+
this._log('WebSocket connect error: %s — falling back to HTTP polling', err.message);
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
catch {
|
|
970
|
+
// socket.io-client not installed — HTTP polling continues as fallback
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
_log(msg, ...args) {
|
|
974
|
+
if (this._debug) {
|
|
975
|
+
console.debug(`[Execlave] ${msg}`, ...args);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
_logError(msg) {
|
|
979
|
+
if (this._debug) {
|
|
980
|
+
console.error(`[Execlave] ${msg}`);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
exports.Execlave = Execlave;
|
|
985
|
+
//# sourceMappingURL=client.js.map
|