@opentrust/guards 7.3.3

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.
@@ -0,0 +1,421 @@
1
+ /**
2
+ * 行为异常检测器 — 在 before_tool_call 时运行
3
+ *
4
+ * 核心功能:
5
+ * 1. 将 tool call 发送到 Core 进行分类评估
6
+ * 2. 记录已完成的 tool call 到链历史
7
+ * 3. 本地扫描 tool 结果中的内容注入模式
8
+ * 4. Core 负责所有分类、信号计算和风险决策
9
+ * 5. 失败开放:如果 Core 不可用,允许执行(不阻断)
10
+ */
11
+
12
+ import { randomBytes } from "node:crypto";
13
+ import type { CoreCredentials } from "./config.js";
14
+ import type {
15
+ ToolChainEntry,
16
+ BehaviorAssessRequest,
17
+ BehaviorAssessResponse,
18
+ PendingToolCall,
19
+ ContentInjectionFinding,
20
+ Logger,
21
+ DetectionFinding,
22
+ } from "./types.js";
23
+ import { sanitizeContent } from "./sanitizer.js";
24
+ import { scanForInjection, type InjectionScanResult } from "./content-injection-scanner.js";
25
+
26
+ // ── 工具分类集合 ─────────────────────────────────────
27
+ // 用于决定哪些 tool call 需要发送到 Core 进行评估
28
+
29
+ /** 文件读取类工具 */
30
+ export const FILE_READ_TOOLS = new Set([
31
+ "Read", "read_file", "read", "cat", "head", "tail", "view",
32
+ "get_file_contents", "open_file",
33
+ ]);
34
+
35
+ /** Shell 执行类工具 */
36
+ export const SHELL_TOOLS = new Set([
37
+ "Bash", "bash", "shell", "run_command", "execute", "terminal",
38
+ "cmd", "powershell",
39
+ ]);
40
+
41
+ /** 网络请求类工具 */
42
+ export const WEB_FETCH_TOOLS = new Set([
43
+ "WebFetch", "web_fetch", "fetch", "http_request", "get_url",
44
+ "browser_navigate", "navigate",
45
+ ]);
46
+
47
+ // ── 会话状态(轻量级)─────────────────────────────────
48
+
49
+ /**
50
+ * 会话状态接口
51
+ * 只保存链历史和内容注入发现,避免过度占用内存
52
+ */
53
+ interface SessionState {
54
+ /** 会话唯一标识 */
55
+ sessionKey: string;
56
+ /** 本次运行 ID(用于关联同一次对话的多个评估) */
57
+ runId: string;
58
+ /** 用户意图(首个用户消息) */
59
+ userIntent: string;
60
+ /** 最近的用户消息列表(用于上下文分析) */
61
+ recentUserMessages: string[];
62
+ /** 已完成的 tool call 链 */
63
+ completedChain: ToolChainEntry[];
64
+ /** 下一个序号 */
65
+ nextSeq: number;
66
+ /** 内容注入发现列表 */
67
+ contentInjectionFindings: ContentInjectionFinding[];
68
+ /** 会话开始时间 */
69
+ startedAt: number;
70
+ }
71
+
72
+ /**
73
+ * 脱敏参数
74
+ * 将 tool 参数中的敏感信息替换为占位符,防止泄露
75
+ *
76
+ * @param params - 原始参数
77
+ * @returns 脱敏后的参数
78
+ */
79
+ function sanitizeParams(params: Record<string, unknown>): Record<string, string> {
80
+ const result: Record<string, string> = {};
81
+ for (const [key, value] of Object.entries(params)) {
82
+ const raw = typeof value === "string" ? value : JSON.stringify(value ?? "");
83
+ // 截断到 500 字符并脱敏
84
+ result[key] = sanitizeContent(raw.slice(0, 500)).sanitized;
85
+ }
86
+ return result;
87
+ }
88
+
89
+ /** 阻断决策 */
90
+ export type BlockDecision = { block: true; blockReason: string; findings?: DetectionFinding[] };
91
+
92
+ /** 检测器配置 */
93
+ export type DetectionConfig = {
94
+ /** Core API 地址 */
95
+ coreUrl: string;
96
+ /** 评估超时时间(毫秒) */
97
+ assessTimeoutMs: number;
98
+ /** 是否在检测到风险时阻断 */
99
+ blockOnRisk: boolean;
100
+ /** 插件版本号 */
101
+ pluginVersion: string;
102
+ };
103
+
104
+ /** 最大会话数量(LRU 淘汰) */
105
+ const MAX_SESSIONS = 200;
106
+ /** 最大 tool chain 长度 */
107
+ const MAX_CHAIN_ENTRIES = 50;
108
+
109
+ /**
110
+ * 行为检测器类
111
+ *
112
+ * 负责:
113
+ * - 管理会话状态
114
+ * - 调用 Core API 进行行为评估
115
+ * - 记录 tool call 历史
116
+ * - 扫描内容注入
117
+ */
118
+ export class BehaviorDetector {
119
+ /** 会话状态映射表 */
120
+ private sessions = new Map<string, SessionState>();
121
+ /** Core 平台凭证 */
122
+ private coreCredentials: CoreCredentials | null = null;
123
+ /** 配置 */
124
+ private config: DetectionConfig;
125
+ /** 日志器 */
126
+ private log: Logger;
127
+ /** 已警告的状态码(避免重复警告) */
128
+ private warnedStatuses = new Set<number>();
129
+
130
+ constructor(config: DetectionConfig, log: Logger) {
131
+ this.config = config;
132
+ this.log = log;
133
+ }
134
+
135
+ /**
136
+ * 设置凭证
137
+ * @param creds - Core 平台凭证
138
+ */
139
+ setCredentials(creds: CoreCredentials | null): void {
140
+ this.coreCredentials = creds;
141
+ }
142
+
143
+ /**
144
+ * 设置用户意图
145
+ * 记录用户的原始意图,用于后续行为分析
146
+ *
147
+ * @param sessionKey - 会话 key
148
+ * @param message - 用户消息
149
+ */
150
+ setUserIntent(sessionKey: string, message: string): void {
151
+ const state = this.getOrCreate(sessionKey);
152
+ // 只保存首个用户意图
153
+ if (!state.userIntent) state.userIntent = message.slice(0, 500);
154
+ // 保存最近 5 条用户消息
155
+ state.recentUserMessages = [...state.recentUserMessages.slice(-4), message.slice(0, 200)];
156
+ }
157
+
158
+ /**
159
+ * 清理会话
160
+ * @param sessionKey - 会话 key
161
+ */
162
+ clearSession(sessionKey: string): void {
163
+ this.sessions.delete(sessionKey);
164
+ }
165
+
166
+ /**
167
+ * 扫描 tool 结果中的注入模式
168
+ *
169
+ * @param sessionKey - 会话 key
170
+ * @param _toolName - 工具名称(暂未使用)
171
+ * @param textContent - 要扫描的文本内容
172
+ * @returns 扫描结果
173
+ */
174
+ scanToolResult(sessionKey: string, _toolName: string, textContent: string): InjectionScanResult {
175
+ const result = scanForInjection(textContent);
176
+ // 如果检测到注入,记录到会话状态
177
+ if (result.detected) {
178
+ const state = this.getOrCreate(sessionKey);
179
+ for (const match of result.matches) {
180
+ state.contentInjectionFindings.push({
181
+ category: match.category,
182
+ confidence: match.confidence,
183
+ matchedText: match.matchedText,
184
+ pattern: match.pattern,
185
+ });
186
+ }
187
+ }
188
+ return result;
189
+ }
190
+
191
+ /**
192
+ * 标记内容注入(用于后备扫描场景)
193
+ *
194
+ * @param sessionKey - 会话 key
195
+ * @param labels - 注入类别标签
196
+ */
197
+ flagContentInjection(sessionKey: string, labels: string[]): void {
198
+ const state = this.getOrCreate(sessionKey);
199
+ for (const label of labels) {
200
+ state.contentInjectionFindings.push({
201
+ category: label,
202
+ confidence: "high",
203
+ matchedText: "",
204
+ pattern: label,
205
+ });
206
+ }
207
+ }
208
+
209
+ /**
210
+ * 检查会话是否有内容注入
211
+ * @param sessionKey - 会话 key
212
+ * @returns 是否有内容注入
213
+ */
214
+ hasContentInjection(sessionKey: string): boolean {
215
+ return (this.sessions.get(sessionKey)?.contentInjectionFindings.length ?? 0) > 0;
216
+ }
217
+
218
+ /**
219
+ * Tool 调用前处理
220
+ * 调用 Core API 进行行为评估,决定是否阻断
221
+ *
222
+ * @param ctx - 上下文(会话 key、Agent ID)
223
+ * @param event - 事件(工具名称、参数)
224
+ * @returns 阻断决策(如果需要阻断)
225
+ */
226
+ async onBeforeToolCall(
227
+ ctx: { sessionKey: string; agentId?: string },
228
+ event: { toolName: string; params: Record<string, unknown> },
229
+ ): Promise<BlockDecision | undefined> {
230
+ // 无凭证时跳过检测
231
+ if (!this.coreCredentials) return undefined;
232
+
233
+ const state = this.getOrCreate(ctx.sessionKey);
234
+
235
+ // 构建待评估的 tool call
236
+ const pendingTool: PendingToolCall = {
237
+ toolName: event.toolName,
238
+ params: sanitizeParams(event.params),
239
+ };
240
+
241
+ // 收集内容注入发现
242
+ const contentFindings = state.contentInjectionFindings.length > 0
243
+ ? [...state.contentInjectionFindings]
244
+ : undefined;
245
+
246
+ // 构建评估请求
247
+ const req: BehaviorAssessRequest = {
248
+ agentId: this.coreCredentials.agentId,
249
+ sessionKey: ctx.sessionKey,
250
+ runId: state.runId,
251
+ userIntent: state.userIntent,
252
+ toolChain: state.completedChain,
253
+ pendingTool,
254
+ contentFindings,
255
+ context: {
256
+ messageHistoryLength: state.recentUserMessages.length,
257
+ recentUserMessages: state.recentUserMessages.slice(-3),
258
+ },
259
+ meta: {
260
+ pluginVersion: this.config.pluginVersion,
261
+ clientTimestamp: new Date().toISOString(),
262
+ },
263
+ };
264
+
265
+ // 调用 Core API
266
+ const verdict = await this.callAssessApi(req);
267
+ if (!verdict) return undefined;
268
+
269
+ // 根据评估结果决定是否阻断
270
+ if (verdict.action === "block" && this.config.blockOnRisk) {
271
+ return {
272
+ block: true,
273
+ blockReason:
274
+ `OpenTrust blocked [${verdict.riskLevel}]: ${verdict.explanation} ` +
275
+ `(confidence: ${Math.round(verdict.confidence * 100)}%)`,
276
+ findings: verdict.findings,
277
+ };
278
+ }
279
+
280
+ // 记录警告(不阻断但有风险)
281
+ if (verdict.action === "block" || verdict.action === "alert") {
282
+ this.log.warn(
283
+ `Behavioral anomaly [${verdict.riskLevel}/${Math.round(verdict.confidence * 100)}%]: ${verdict.explanation}`,
284
+ );
285
+ }
286
+
287
+ return undefined;
288
+ }
289
+
290
+ /**
291
+ * Tool 调用后处理
292
+ * 记录 tool call 完成信息到链历史
293
+ *
294
+ * @param ctx - 上下文
295
+ * @param event - 事件(工具名称、参数、结果、错误、耗时)
296
+ */
297
+ onAfterToolCall(
298
+ ctx: { sessionKey: string },
299
+ event: {
300
+ toolName: string;
301
+ params: Record<string, unknown>;
302
+ result?: unknown;
303
+ error?: string;
304
+ durationMs?: number;
305
+ },
306
+ ): void {
307
+ const state = this.sessions.get(ctx.sessionKey);
308
+ if (!state) return;
309
+
310
+ // 计算结果大小和类别
311
+ const resultStr = typeof event.result === "string" ? event.result : JSON.stringify(event.result ?? "");
312
+ const resultSizeBytes = Buffer.byteLength(resultStr, "utf-8");
313
+
314
+ let resultCategory: ToolChainEntry["resultCategory"] = "empty";
315
+ if (event.error) resultCategory = "error";
316
+ else if (resultSizeBytes > 100_000) resultCategory = "text_large";
317
+ else if (resultSizeBytes > 0) resultCategory = "text_small";
318
+
319
+ // 构建链条目
320
+ const entry: ToolChainEntry = {
321
+ seq: state.nextSeq++,
322
+ toolName: event.toolName,
323
+ sanitizedParams: sanitizeParams(event.params),
324
+ outcome: event.error ? "error" : "success",
325
+ durationMs: event.durationMs ?? 0,
326
+ resultCategory,
327
+ resultSizeBytes,
328
+ };
329
+
330
+ // 添加到链历史(保持最大长度)
331
+ state.completedChain.push(entry);
332
+ if (state.completedChain.length > MAX_CHAIN_ENTRIES) state.completedChain.shift();
333
+ }
334
+
335
+ /**
336
+ * 获取或创建会话状态
337
+ * 实现 LRU 淘汰策略,防止内存泄漏
338
+ *
339
+ * @param sessionKey - 会话 key
340
+ * @returns 会话状态
341
+ */
342
+ private getOrCreate(sessionKey: string): SessionState {
343
+ if (!this.sessions.has(sessionKey)) {
344
+ // 超过最大会话数时,淘汰最旧的会话
345
+ if (this.sessions.size >= MAX_SESSIONS) {
346
+ let oldest: string | null = null;
347
+ let oldestTime = Infinity;
348
+ for (const [key, state] of this.sessions) {
349
+ if (state.startedAt < oldestTime) { oldestTime = state.startedAt; oldest = key; }
350
+ }
351
+ if (oldest) this.sessions.delete(oldest);
352
+ }
353
+ // 创建新会话
354
+ this.sessions.set(sessionKey, {
355
+ sessionKey,
356
+ runId: `run-${randomBytes(8).toString("hex")}`,
357
+ userIntent: "",
358
+ recentUserMessages: [],
359
+ completedChain: [],
360
+ nextSeq: 0,
361
+ contentInjectionFindings: [],
362
+ startedAt: Date.now(),
363
+ });
364
+ }
365
+ return this.sessions.get(sessionKey)!;
366
+ }
367
+
368
+ /**
369
+ * 调用 Core 行为评估 API
370
+ *
371
+ * @param req - 评估请求
372
+ * @returns 评估响应(失败时返回 null)
373
+ */
374
+ private async callAssessApi(req: BehaviorAssessRequest): Promise<BehaviorAssessResponse | null> {
375
+ if (!this.coreCredentials) return null;
376
+
377
+ // 设置超时控制
378
+ const controller = new AbortController();
379
+ const timer = setTimeout(() => controller.abort(), this.config.assessTimeoutMs);
380
+
381
+ try {
382
+ const response = await fetch(`${this.config.coreUrl}/api/v1/behavior/assess`, {
383
+ method: "POST",
384
+ headers: {
385
+ "Content-Type": "application/json",
386
+ Authorization: `Bearer ${this.coreCredentials.apiKey}`,
387
+ },
388
+ body: JSON.stringify(req),
389
+ signal: controller.signal,
390
+ });
391
+
392
+ // 处理错误响应
393
+ if (!response.ok) {
394
+ // 避免重复警告同一状态码
395
+ if (!this.warnedStatuses.has(response.status)) {
396
+ this.warnedStatuses.add(response.status);
397
+ if (response.status === 401) {
398
+ this.log.warn("Platform: API key invalid or agent not found — run /og_activate to re-register");
399
+ } else if (response.status === 402) {
400
+ this.log.warn("Platform: agent not activated — run /og_activate to complete setup");
401
+ } else if (response.status === 403) {
402
+ this.log.warn(`Platform: detection quota exceeded — visit ${this.config.coreUrl} to upgrade your plan`);
403
+ } else {
404
+ this.log.debug?.(`Platform: assess returned ${response.status}`);
405
+ }
406
+ }
407
+ return null;
408
+ }
409
+
410
+ // 解析响应
411
+ const json = (await response.json()) as { success: boolean; data?: BehaviorAssessResponse };
412
+ return json.success ? (json.data ?? null) : null;
413
+ } catch (err) {
414
+ // 超时错误静默处理
415
+ if ((err as Error).name !== "AbortError") this.log.debug?.(`Assess API error: ${err}`);
416
+ return null;
417
+ } finally {
418
+ clearTimeout(timer);
419
+ }
420
+ }
421
+ }