@openbmb/clawxrouter 1.0.4

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,97 @@
1
+ {
2
+ "id": "ClawXRouter",
3
+ "name": "ClawXRouter",
4
+ "description": "Edge-cloud collaborative routing plugin that keeps sensitive data local and routes tasks to cost-effective models — protects user privacy by classifying requests into three safety levels and redacting PII before any cloud forwarding",
5
+ "configSchema": {
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "privacy": {
10
+ "type": "object",
11
+ "properties": {
12
+ "enabled": { "type": "boolean" },
13
+ "s2Policy": {
14
+ "type": "string",
15
+ "enum": ["proxy", "local"],
16
+ "description": "S2 handling policy: 'proxy' strips PII via local HTTP proxy before forwarding to cloud (default), 'local' routes S2 to local model entirely"
17
+ },
18
+ "proxyPort": {
19
+ "type": "number",
20
+ "description": "Port for the privacy proxy HTTP server (default: 8403)"
21
+ },
22
+ "checkpoints": {
23
+ "type": "object",
24
+ "properties": {
25
+ "onUserMessage": { "type": "array", "items": { "type": "string" } },
26
+ "onToolCallProposed": { "type": "array", "items": { "type": "string" } },
27
+ "onToolCallExecuted": { "type": "array", "items": { "type": "string" } }
28
+ }
29
+ },
30
+ "rules": {
31
+ "type": "object",
32
+ "properties": {
33
+ "keywords": {
34
+ "type": "object",
35
+ "properties": {
36
+ "S2": { "type": "array", "items": { "type": "string" } },
37
+ "S3": { "type": "array", "items": { "type": "string" } }
38
+ }
39
+ },
40
+ "patterns": {
41
+ "type": "object",
42
+ "description": "Regex patterns for matching sensitive content",
43
+ "properties": {
44
+ "S2": { "type": "array", "items": { "type": "string" } },
45
+ "S3": { "type": "array", "items": { "type": "string" } }
46
+ }
47
+ },
48
+ "tools": {
49
+ "type": "object",
50
+ "properties": {
51
+ "S2": {
52
+ "type": "object",
53
+ "properties": {
54
+ "tools": { "type": "array", "items": { "type": "string" } },
55
+ "paths": { "type": "array", "items": { "type": "string" } }
56
+ }
57
+ },
58
+ "S3": {
59
+ "type": "object",
60
+ "properties": {
61
+ "tools": { "type": "array", "items": { "type": "string" } },
62
+ "paths": { "type": "array", "items": { "type": "string" } }
63
+ }
64
+ }
65
+ }
66
+ }
67
+ }
68
+ },
69
+ "localModel": {
70
+ "type": "object",
71
+ "properties": {
72
+ "enabled": { "type": "boolean" },
73
+ "provider": { "type": "string" },
74
+ "model": { "type": "string" },
75
+ "endpoint": { "type": "string" }
76
+ }
77
+ },
78
+ "guardAgent": {
79
+ "type": "object",
80
+ "properties": {
81
+ "id": { "type": "string" },
82
+ "workspace": { "type": "string" },
83
+ "model": { "type": "string" }
84
+ }
85
+ },
86
+ "session": {
87
+ "type": "object",
88
+ "properties": {
89
+ "isolateGuardHistory": { "type": "boolean" },
90
+ "baseDir": { "type": "string" }
91
+ }
92
+ }
93
+ }
94
+ }
95
+ }
96
+ }
97
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@openbmb/clawxrouter",
3
+ "version": "1.0.4",
4
+ "description": "Edge-cloud collaborative routing plugin that keeps sensitive data local and routes tasks to cost-effective models — protects user privacy by classifying requests into three safety levels and redacting PII before any cloud forwarding",
5
+ "type": "module",
6
+ "main": "./index.ts",
7
+ "exports": {
8
+ ".": "./index.ts"
9
+ },
10
+ "files": [
11
+ "index.ts",
12
+ "src/",
13
+ "prompts/",
14
+ "config.example.json",
15
+ "openclaw.plugin.json"
16
+ ],
17
+ "keywords": [
18
+ "openclaw",
19
+ "privacy",
20
+ "router",
21
+ "guard-agent",
22
+ "clawxrouter"
23
+ ],
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/OpenBMB/ClawXRouter"
28
+ },
29
+ "dependencies": {
30
+ "@sinclair/typebox": "0.34.48",
31
+ "synckit": "^0.11.12",
32
+ "tsx": "^4.21.0"
33
+ },
34
+ "openclaw": {
35
+ "extensions": [
36
+ "./index.ts"
37
+ ],
38
+ "compat": {
39
+ "pluginApi": ">=2026.3.22"
40
+ },
41
+ "build": {
42
+ "openclawVersion": "2026.3.28"
43
+ }
44
+ },
45
+ "devDependencies": {
46
+ "vitest": "^4.1.0"
47
+ }
48
+ }
@@ -0,0 +1,50 @@
1
+ [SYSTEM] You are a strict privacy classifier. Output ONLY a single JSON object — nothing else.
2
+
3
+ Classify based on ACTUAL data present in the message. Do NOT speculate about unknown file contents.
4
+
5
+ S3 = PRIVATE (local only, never cloud):
6
+ - Financial: payslip, salary, tax, bank account, SSN, 工资单, 报销单, 税表
7
+ - Medical: health records, diagnoses, prescriptions, lab results, 病历, 体检报告
8
+ - Credentials: passwords, API keys, secrets, tokens, private keys, .env files, config with credentials
9
+ - The message itself CONTAINS or EXPLICITLY MENTIONS the above data → S3
10
+ "evaluate these passwords" → S3
11
+ "check my payslip" → S3
12
+ "summarize the medical record in patient_records.pdf" → S3
13
+ "read my .env file" → S3
14
+ "the secret code is XYZ" → S3
15
+
16
+ S2 = SENSITIVE (redact PII, then send to cloud):
17
+ - Addresses (ANY physical address, 地址, 住址, street, road, apartment, 路, 街, 小区, 弄, 号)
18
+ - Gate/door codes, pickup codes, delivery tracking numbers
19
+ - Phone numbers, email addresses, real names used as contact PII
20
+ - License plates, SSN/ID mixed with personal context, chat logs with PII
21
+ - File content containing the above PII → S2
22
+ - ANY mention of "address"/"地址" with actual location data → S2
23
+ "1847 Elm St, gate code 4523#" → S2
24
+ "我的地址是北京市朝阳区xxx" → S2
25
+ "张伟 手机13912345678" → S2
26
+ "schedule meeting with john@example.com" → S2
27
+
28
+ S1 = SAFE: No sensitive data or intent.
29
+ "write a poem about spring" → S1
30
+ "how to read Excel with pandas" → S1
31
+ "read summary_source.txt and write a summary" → S1
32
+ "read notes.md and answer a question" → S1
33
+ "create a market research report about observability tools" → S1
34
+ "find upcoming tech conferences" → S1
35
+ "create a Python project structure" → S1
36
+
37
+ Rules:
38
+ - Passwords/credentials → ALWAYS S3 (never S2)
39
+ - Medical data → ALWAYS S3 (never S2)
40
+ - Gate/access/pickup codes → S2 (not S3)
41
+ - If file content is provided and contains PII → at least S2
42
+ - Generic file operations (read/write .txt, .md, .csv with NEUTRAL names) → S1 unless the message itself contains PII
43
+ - Do NOT escalate just because a file MIGHT contain sensitive data — only escalate when evidence exists in the message
44
+ - "Read X file and summarize" with no PII in the request → S1
45
+ - "Analyze quarterly_sales.csv" or "company_expenses.xlsx" with NO actual financial PII in the message → S1
46
+ - Tool calls (read, write, exec, shell) within the agent workspace are NORMAL operations → S1 unless parameters explicitly contain credentials or PII
47
+ - Reading config.json, settings.json, database.yml for task purposes → S1 (classify based on actual content, not filename speculation)
48
+ - When genuinely unsure AND the filename/context suggests sensitivity → pick higher level
49
+
50
+ Output format: {"level":"S1|S2|S3","reason":"brief"}
@@ -0,0 +1,25 @@
1
+ You are a task complexity classifier for an AI coding agent. Classify each task into exactly one of five tiers based on the nature of the work.
2
+
3
+ ## Tiers
4
+
5
+ SIMPLE — Pure text transformation. Takes existing text and produces modified text: summarizing a single document, rewriting or humanizing content, simple Q&A, greetings.
6
+
7
+ MEDIUM — Standard agent work (default). Writing emails, coding scripts, data analysis (CSV/Excel), project scaffolding, image generation, factual lookups, researching events or conferences, competitive/market research and analysis reports, search-and-replace, memory management.
8
+
9
+ COMPLEX — Structured multi-item processing. Systematically processes a collection or extracts precise information: triaging or searching through multiple emails, creating multiple files and directories as a structured tree, extracting facts or structured data from documents and reports.
10
+
11
+ RESEARCH — Creative synthesis. Original long-form writing or multi-source combination: blog posts, articles, multi-step workflows (read → code → document), briefings from multiple source files.
12
+
13
+ REASONING — Deep PDF analysis. Reading, understanding, and explaining PDF documents in simplified terms.
14
+
15
+ ## Disambiguation
16
+
17
+ - Summarizing ONE text file → SIMPLE; synthesizing MULTIPLE text/research source files into a briefing → RESEARCH.
18
+ - Data analysis (CSV, Excel, spreadsheets) → MEDIUM, regardless of file count.
19
+ - Scaffolding a project or library → MEDIUM; creating multiple files and directories from a spec → COMPLEX.
20
+ - Explaining or simplifying a PDF (ELI5) → REASONING; extracting structured data points from a document → COMPLEX.
21
+ - Market/competitive analysis or event/conference research → MEDIUM.
22
+ - When unsure, choose MEDIUM.
23
+
24
+ CRITICAL: Output ONLY a raw JSON object. No markdown, no explanation.
25
+ {"tier":"SIMPLE|MEDIUM|COMPLEX|RESEARCH|REASONING"}
@@ -0,0 +1,210 @@
1
+ import { Type } from "@sinclair/typebox";
2
+
3
+ export const clawXrouterConfigSchema = Type.Object({
4
+ privacy: Type.Optional(
5
+ Type.Object({
6
+ enabled: Type.Optional(Type.Boolean()),
7
+ s2Policy: Type.Optional(
8
+ Type.Union([Type.Literal("proxy"), Type.Literal("local")]),
9
+ ),
10
+ proxyPort: Type.Optional(Type.Number()),
11
+ checkpoints: Type.Optional(
12
+ Type.Object({
13
+ onUserMessage: Type.Optional(
14
+ Type.Array(
15
+ Type.Union([Type.Literal("ruleDetector"), Type.Literal("localModelDetector")]),
16
+ ),
17
+ ),
18
+ onToolCallProposed: Type.Optional(
19
+ Type.Array(
20
+ Type.Union([Type.Literal("ruleDetector"), Type.Literal("localModelDetector")]),
21
+ ),
22
+ ),
23
+ onToolCallExecuted: Type.Optional(
24
+ Type.Array(
25
+ Type.Union([Type.Literal("ruleDetector"), Type.Literal("localModelDetector")]),
26
+ ),
27
+ ),
28
+ }),
29
+ ),
30
+ rules: Type.Optional(
31
+ Type.Object({
32
+ keywords: Type.Optional(
33
+ Type.Object({
34
+ S2: Type.Optional(Type.Array(Type.String())),
35
+ S3: Type.Optional(Type.Array(Type.String())),
36
+ }),
37
+ ),
38
+ patterns: Type.Optional(
39
+ Type.Object({
40
+ S2: Type.Optional(Type.Array(Type.String())),
41
+ S3: Type.Optional(Type.Array(Type.String())),
42
+ }),
43
+ ),
44
+ tools: Type.Optional(
45
+ Type.Object({
46
+ S2: Type.Optional(
47
+ Type.Object({
48
+ tools: Type.Optional(Type.Array(Type.String())),
49
+ paths: Type.Optional(Type.Array(Type.String())),
50
+ }),
51
+ ),
52
+ S3: Type.Optional(
53
+ Type.Object({
54
+ tools: Type.Optional(Type.Array(Type.String())),
55
+ paths: Type.Optional(Type.Array(Type.String())),
56
+ }),
57
+ ),
58
+ }),
59
+ ),
60
+ }),
61
+ ),
62
+ localModel: Type.Optional(
63
+ Type.Object({
64
+ enabled: Type.Optional(Type.Boolean()),
65
+ type: Type.Optional(
66
+ Type.Union([
67
+ Type.Literal("openai-compatible"),
68
+ Type.Literal("ollama-native"),
69
+ Type.Literal("custom"),
70
+ ]),
71
+ ),
72
+ provider: Type.Optional(Type.String()),
73
+ model: Type.Optional(Type.String()),
74
+ endpoint: Type.Optional(Type.String()),
75
+ apiKey: Type.Optional(Type.String()),
76
+ module: Type.Optional(Type.String()),
77
+ }),
78
+ ),
79
+ guardAgent: Type.Optional(
80
+ Type.Object({
81
+ id: Type.Optional(Type.String()),
82
+ workspace: Type.Optional(Type.String()),
83
+ model: Type.Optional(Type.String()),
84
+ }),
85
+ ),
86
+ localProviders: Type.Optional(Type.Array(Type.String())),
87
+ toolAllowlist: Type.Optional(Type.Array(Type.String())),
88
+ modelPricing: Type.Optional(
89
+ Type.Record(
90
+ Type.String(),
91
+ Type.Object({
92
+ inputPer1M: Type.Optional(Type.Number()),
93
+ outputPer1M: Type.Optional(Type.Number()),
94
+ }),
95
+ ),
96
+ ),
97
+ session: Type.Optional(
98
+ Type.Object({
99
+ isolateGuardHistory: Type.Optional(Type.Boolean()),
100
+ baseDir: Type.Optional(Type.String()),
101
+ injectDualHistory: Type.Optional(Type.Boolean()),
102
+ historyLimit: Type.Optional(Type.Number()),
103
+ }),
104
+ ),
105
+ routers: Type.Optional(
106
+ Type.Record(
107
+ Type.String(),
108
+ Type.Object({
109
+ enabled: Type.Optional(Type.Boolean()),
110
+ type: Type.Optional(Type.Union([Type.Literal("builtin"), Type.Literal("custom"), Type.Literal("configurable")])),
111
+ module: Type.Optional(Type.String()),
112
+ weight: Type.Optional(Type.Number()),
113
+ options: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
114
+ }),
115
+ ),
116
+ ),
117
+ pipeline: Type.Optional(
118
+ Type.Object({
119
+ onUserMessage: Type.Optional(Type.Array(Type.String())),
120
+ onToolCallProposed: Type.Optional(Type.Array(Type.String())),
121
+ onToolCallExecuted: Type.Optional(Type.Array(Type.String())),
122
+ }),
123
+ ),
124
+ redaction: Type.Optional(
125
+ Type.Object({
126
+ internalIp: Type.Optional(Type.Boolean()),
127
+ email: Type.Optional(Type.Boolean()),
128
+ envVar: Type.Optional(Type.Boolean()),
129
+ creditCard: Type.Optional(Type.Boolean()),
130
+ chinesePhone: Type.Optional(Type.Boolean()),
131
+ chineseId: Type.Optional(Type.Boolean()),
132
+ chineseAddress: Type.Optional(Type.Boolean()),
133
+ pin: Type.Optional(Type.Boolean()),
134
+ }),
135
+ ),
136
+ }),
137
+ ),
138
+ });
139
+
140
+ export const defaultPrivacyConfig = {
141
+ enabled: true,
142
+ s2Policy: "proxy" as "proxy" | "local",
143
+ proxyPort: 8403,
144
+ checkpoints: {
145
+ onUserMessage: ["ruleDetector" as const, "localModelDetector" as const],
146
+ onToolCallProposed: ["ruleDetector" as const],
147
+ onToolCallExecuted: ["ruleDetector" as const],
148
+ },
149
+ rules: {
150
+ keywords: {
151
+ S2: [] as string[],
152
+ S3: [] as string[],
153
+ },
154
+ patterns: {
155
+ S2: [] as string[],
156
+ S3: [] as string[],
157
+ },
158
+ tools: {
159
+ S2: { tools: [] as string[], paths: [] as string[] },
160
+ S3: { tools: [] as string[], paths: [] as string[] },
161
+ },
162
+ },
163
+ localModel: {
164
+ enabled: true,
165
+ type: "openai-compatible" as const,
166
+ model: "openbmb/minicpm4.1",
167
+ endpoint: "http://localhost:11434",
168
+ },
169
+ guardAgent: {
170
+ id: "guard",
171
+ workspace: "~/.openclaw/workspace-guard",
172
+ model: "ollama/openbmb/minicpm4.1",
173
+ },
174
+ localProviders: [] as string[],
175
+ toolAllowlist: [] as string[],
176
+ modelPricing: {
177
+ "claude-sonnet-4.6": { inputPer1M: 3, outputPer1M: 15 },
178
+ "claude-3.5-sonnet": { inputPer1M: 3, outputPer1M: 15 },
179
+ "claude-3.5-haiku": { inputPer1M: 0.8, outputPer1M: 4 },
180
+ "gpt-4o": { inputPer1M: 2.5, outputPer1M: 10 },
181
+ "gpt-4o-mini": { inputPer1M: 0.15, outputPer1M: 0.6 },
182
+ "o4-mini": { inputPer1M: 1.1, outputPer1M: 4.4 },
183
+ "gemini-2.0-flash": { inputPer1M: 0.1, outputPer1M: 0.4 },
184
+ "deepseek-chat": { inputPer1M: 0.27, outputPer1M: 1.1 },
185
+ } as Record<string, { inputPer1M?: number; outputPer1M?: number }>,
186
+ redaction: {
187
+ internalIp: false,
188
+ email: false,
189
+ envVar: false,
190
+ creditCard: false,
191
+ chinesePhone: false,
192
+ chineseId: false,
193
+ chineseAddress: false,
194
+ pin: false,
195
+ },
196
+ session: {
197
+ isolateGuardHistory: true,
198
+ baseDir: "~/.openclaw",
199
+ injectDualHistory: true,
200
+ historyLimit: 20,
201
+ },
202
+ routers: {
203
+ privacy: { enabled: true, type: "builtin" as const },
204
+ } as Record<string, { enabled?: boolean; type?: "builtin" | "custom" | "configurable"; module?: string; weight?: number; options?: Record<string, unknown> }>,
205
+ pipeline: {
206
+ onUserMessage: ["privacy"],
207
+ onToolCallProposed: ["privacy"],
208
+ onToolCallExecuted: ["privacy"],
209
+ },
210
+ };
@@ -0,0 +1,25 @@
1
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ const HOME_DIR = process.env.HOME ?? "/tmp";
5
+
6
+ export const CLAWXROUTER_CONFIG_PATH = join(
7
+ HOME_DIR,
8
+ ".openclaw",
9
+ "clawxrouter.json",
10
+ );
11
+
12
+ export function saveClawXrouterConfig(privacy: Record<string, unknown>): void {
13
+ try {
14
+ const dir = join(HOME_DIR, ".openclaw");
15
+ mkdirSync(dir, { recursive: true });
16
+ let existing: Record<string, unknown> = {};
17
+ try {
18
+ existing = JSON.parse(readFileSync(CLAWXROUTER_CONFIG_PATH, "utf-8")) as Record<string, unknown>;
19
+ } catch { /* file may not exist yet */ }
20
+ const updated = { ...existing, privacy };
21
+ writeFileSync(CLAWXROUTER_CONFIG_PATH, JSON.stringify(updated, null, 2), "utf-8");
22
+ } catch {
23
+ // best-effort persistence
24
+ }
25
+ }
@@ -0,0 +1,230 @@
1
+ import type {
2
+ Checkpoint,
3
+ DetectionContext,
4
+ DetectionResult,
5
+ DetectorType,
6
+ PrivacyConfig,
7
+ SensitivityLevel
8
+ } from "./types.js";
9
+ import { maxLevel } from "./types.js";
10
+ import { detectByRules } from "./rules.js";
11
+ import { detectByLocalModel } from "./local-model.js";
12
+ import { defaultPrivacyConfig } from "./config-schema.js";
13
+
14
+ /**
15
+ * Main detection function that coordinates all detectors.
16
+ *
17
+ * Accepts either a raw `pluginConfig` (legacy — will merge with defaults)
18
+ * or a pre-merged `PrivacyConfig` via the `resolvedConfig` option to avoid
19
+ * double-merging when called from routers that already merged config.
20
+ */
21
+ export async function detectSensitivityLevel(
22
+ context: DetectionContext,
23
+ pluginConfig: Record<string, unknown>,
24
+ resolvedConfig?: PrivacyConfig,
25
+ ): Promise<DetectionResult> {
26
+ const privacyConfig = resolvedConfig ?? mergeWithDefaults(
27
+ (pluginConfig?.privacy as PrivacyConfig) ?? {},
28
+ defaultPrivacyConfig
29
+ );
30
+
31
+ // Check if privacy is enabled (skip when dry-run so dashboards get real classification)
32
+ if (privacyConfig.enabled === false && !context.dryRun) {
33
+ return {
34
+ level: "S1",
35
+ levelNumeric: 1,
36
+ reason: "Privacy detection disabled",
37
+ detectorType: "ruleDetector",
38
+ confidence: 1.0,
39
+ };
40
+ }
41
+
42
+ // Get detectors for this checkpoint
43
+ const detectors = getDetectorsForCheckpoint(context.checkpoint, privacyConfig);
44
+
45
+ if (detectors.length === 0) {
46
+ // No detectors configured for this checkpoint, default to S1
47
+ return {
48
+ level: "S1",
49
+ levelNumeric: 1,
50
+ reason: "No detectors configured",
51
+ detectorType: "ruleDetector",
52
+ confidence: 1.0,
53
+ };
54
+ }
55
+
56
+ // Run all configured detectors
57
+ const results = await runDetectors(detectors, context, privacyConfig);
58
+
59
+ // Merge results (take maximum level)
60
+ return mergeDetectionResults(results);
61
+ }
62
+
63
+ /**
64
+ * Get configured detectors for a specific checkpoint
65
+ */
66
+ function getDetectorsForCheckpoint(
67
+ checkpoint: Checkpoint,
68
+ config: PrivacyConfig
69
+ ): DetectorType[] {
70
+ const checkpoints = config.checkpoints ?? {};
71
+
72
+ switch (checkpoint) {
73
+ case "onUserMessage":
74
+ return checkpoints.onUserMessage ?? ["ruleDetector", "localModelDetector"];
75
+ case "onToolCallProposed":
76
+ return checkpoints.onToolCallProposed ?? ["ruleDetector"];
77
+ case "onToolCallExecuted":
78
+ return checkpoints.onToolCallExecuted ?? ["ruleDetector"];
79
+ default:
80
+ return ["ruleDetector"];
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Run detectors and collect results.
86
+ *
87
+ * Short-circuits on S3: once any detector returns S3 (highest level),
88
+ * remaining detectors are skipped — no further detection can raise the
89
+ * level and running an LLM judge for a message that will stay local is
90
+ * both wasteful and a needless exposure of sensitive content.
91
+ */
92
+ async function runDetectors(
93
+ detectors: DetectorType[],
94
+ context: DetectionContext,
95
+ config: PrivacyConfig
96
+ ): Promise<DetectionResult[]> {
97
+ const results: DetectionResult[] = [];
98
+
99
+ for (const detector of detectors) {
100
+ try {
101
+ let result: DetectionResult;
102
+
103
+ switch (detector) {
104
+ case "ruleDetector":
105
+ result = detectByRules(context, config);
106
+ break;
107
+ case "localModelDetector":
108
+ result = await detectByLocalModel(context, config);
109
+ break;
110
+ default:
111
+ console.warn(`[ClawXrouter] Unknown detector type: ${detector}`);
112
+ continue;
113
+ }
114
+
115
+ results.push(result);
116
+
117
+ if (result.level === "S3") break;
118
+ } catch (err) {
119
+ console.error(`[ClawXrouter] Detector ${detector} failed:`, err);
120
+ }
121
+ }
122
+
123
+ return results;
124
+ }
125
+
126
+ /**
127
+ * Merge multiple detection results into a single result
128
+ * Takes the highest severity level and combines reasons
129
+ */
130
+ function mergeDetectionResults(results: DetectionResult[]): DetectionResult {
131
+ if (results.length === 0) {
132
+ return {
133
+ level: "S1",
134
+ levelNumeric: 1,
135
+ reason: "No detection results",
136
+ detectorType: "ruleDetector",
137
+ confidence: 0,
138
+ };
139
+ }
140
+
141
+ if (results.length === 1) {
142
+ return results[0];
143
+ }
144
+
145
+ // Find the highest level
146
+ const levels = results.map((r) => r.level);
147
+ const finalLevel = maxLevel(...levels);
148
+
149
+ // Collect reasons from all detectors that contributed to the decision
150
+ const relevantResults = results.filter((r) => r.level === finalLevel);
151
+ const reasons = relevantResults
152
+ .map((r) => r.reason)
153
+ .filter((r): r is string => Boolean(r));
154
+
155
+ // Calculate average confidence
156
+ const confidences = results.map((r) => r.confidence ?? 0.5);
157
+ const avgConfidence = confidences.reduce((a, b) => a + b, 0) / confidences.length;
158
+
159
+ // Determine primary detector type (the one that found the highest level)
160
+ const primaryDetector = relevantResults[0]?.detectorType ?? "ruleDetector";
161
+
162
+ return {
163
+ level: finalLevel,
164
+ levelNumeric: results.find((r) => r.level === finalLevel)?.levelNumeric ?? 1,
165
+ reason: reasons.length > 0 ? reasons.join("; ") : undefined,
166
+ detectorType: primaryDetector,
167
+ confidence: avgConfidence,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Merge user config with defaults
173
+ */
174
+ function mergeWithDefaults(
175
+ userConfig: PrivacyConfig,
176
+ defaults: PrivacyConfig
177
+ ): PrivacyConfig {
178
+ return {
179
+ enabled: userConfig.enabled ?? defaults.enabled,
180
+ checkpoints: {
181
+ onUserMessage: userConfig.checkpoints?.onUserMessage ?? defaults.checkpoints?.onUserMessage,
182
+ onToolCallProposed:
183
+ userConfig.checkpoints?.onToolCallProposed ?? defaults.checkpoints?.onToolCallProposed,
184
+ onToolCallExecuted:
185
+ userConfig.checkpoints?.onToolCallExecuted ?? defaults.checkpoints?.onToolCallExecuted,
186
+ },
187
+ rules: {
188
+ keywords: {
189
+ S2: userConfig.rules?.keywords?.S2 ?? defaults.rules?.keywords?.S2,
190
+ S3: userConfig.rules?.keywords?.S3 ?? defaults.rules?.keywords?.S3,
191
+ },
192
+ patterns: {
193
+ S2: userConfig.rules?.patterns?.S2 ?? defaults.rules?.patterns?.S2,
194
+ S3: userConfig.rules?.patterns?.S3 ?? defaults.rules?.patterns?.S3,
195
+ },
196
+ tools: {
197
+ S2: {
198
+ tools: userConfig.rules?.tools?.S2?.tools ?? defaults.rules?.tools?.S2?.tools,
199
+ paths: userConfig.rules?.tools?.S2?.paths ?? defaults.rules?.tools?.S2?.paths,
200
+ },
201
+ S3: {
202
+ tools: userConfig.rules?.tools?.S3?.tools ?? defaults.rules?.tools?.S3?.tools,
203
+ paths: userConfig.rules?.tools?.S3?.paths ?? defaults.rules?.tools?.S3?.paths,
204
+ },
205
+ },
206
+ },
207
+ localModel: {
208
+ enabled: userConfig.localModel?.enabled ?? defaults.localModel?.enabled,
209
+ type: userConfig.localModel?.type ?? defaults.localModel?.type,
210
+ provider: userConfig.localModel?.provider ?? defaults.localModel?.provider,
211
+ model: userConfig.localModel?.model ?? defaults.localModel?.model,
212
+ endpoint: userConfig.localModel?.endpoint ?? defaults.localModel?.endpoint,
213
+ apiKey: userConfig.localModel?.apiKey ?? defaults.localModel?.apiKey,
214
+ module: userConfig.localModel?.module ?? defaults.localModel?.module,
215
+ },
216
+ guardAgent: {
217
+ id: userConfig.guardAgent?.id ?? defaults.guardAgent?.id,
218
+ workspace: userConfig.guardAgent?.workspace ?? defaults.guardAgent?.workspace,
219
+ model: userConfig.guardAgent?.model ?? defaults.guardAgent?.model,
220
+ },
221
+ session: {
222
+ isolateGuardHistory:
223
+ userConfig.session?.isolateGuardHistory ?? defaults.session?.isolateGuardHistory,
224
+ baseDir: userConfig.session?.baseDir ?? defaults.session?.baseDir,
225
+ injectDualHistory:
226
+ userConfig.session?.injectDualHistory ?? defaults.session?.injectDualHistory,
227
+ historyLimit: userConfig.session?.historyLimit ?? defaults.session?.historyLimit,
228
+ },
229
+ };
230
+ }