@dsh-edge/dsh-edge-agent 0.0.0-stage → 0.1.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/CHANGELOG.md +158 -0
- package/LICENSE +21 -0
- package/README.md +181 -2
- package/config/edge.example.json +179 -0
- package/config/privacy-policy.json +202 -0
- package/cordis.desktop-safe.patch.yml +129 -0
- package/cordis.patch.yml +382 -0
- package/cordis.yml +374 -0
- package/lib/attestation.d.ts +2 -0
- package/lib/attestation.d.ts.map +1 -0
- package/lib/attestation.js +2 -0
- package/lib/audit-log.d.ts +2 -0
- package/lib/audit-log.d.ts.map +1 -0
- package/lib/audit-log.js +2 -0
- package/lib/client.js +87 -0
- package/lib/cloud-llm-adapter.d.ts +2 -0
- package/lib/cloud-llm-adapter.d.ts.map +1 -0
- package/lib/cloud-llm-adapter.js +2 -0
- package/lib/dsh-adapter/attestation.d.ts +42 -0
- package/lib/dsh-adapter/attestation.d.ts.map +1 -0
- package/lib/dsh-adapter/attestation.js +40 -0
- package/lib/dsh-adapter/audit-log.d.ts +139 -0
- package/lib/dsh-adapter/audit-log.d.ts.map +1 -0
- package/lib/dsh-adapter/audit-log.js +376 -0
- package/lib/dsh-adapter/cloud-egress.d.ts +181 -0
- package/lib/dsh-adapter/cloud-egress.d.ts.map +1 -0
- package/lib/dsh-adapter/cloud-egress.js +257 -0
- package/lib/dsh-adapter/cloud-llm-adapter.d.ts +416 -0
- package/lib/dsh-adapter/cloud-llm-adapter.d.ts.map +1 -0
- package/lib/dsh-adapter/cloud-llm-adapter.js +1472 -0
- package/lib/dsh-adapter/contract.d.ts +80 -0
- package/lib/dsh-adapter/contract.d.ts.map +1 -0
- package/lib/dsh-adapter/contract.js +194 -0
- package/lib/dsh-adapter/desktop-cookie-janitor.d.ts +214 -0
- package/lib/dsh-adapter/desktop-cookie-janitor.d.ts.map +1 -0
- package/lib/dsh-adapter/desktop-cookie-janitor.js +409 -0
- package/lib/dsh-adapter/dsh-edge-agent.d.ts +48 -0
- package/lib/dsh-adapter/dsh-edge-agent.d.ts.map +1 -0
- package/lib/dsh-adapter/dsh-edge-agent.js +64 -0
- package/lib/dsh-adapter/edge-config.d.ts +484 -0
- package/lib/dsh-adapter/edge-config.d.ts.map +1 -0
- package/lib/dsh-adapter/edge-config.js +898 -0
- package/lib/dsh-adapter/edge-context-view.d.ts +184 -0
- package/lib/dsh-adapter/edge-context-view.d.ts.map +1 -0
- package/lib/dsh-adapter/edge-context-view.js +483 -0
- package/lib/dsh-adapter/effective-model.d.ts +85 -0
- package/lib/dsh-adapter/effective-model.d.ts.map +1 -0
- package/lib/dsh-adapter/effective-model.js +171 -0
- package/lib/dsh-adapter/etscli-shell-guard.d.ts +244 -0
- package/lib/dsh-adapter/etscli-shell-guard.d.ts.map +1 -0
- package/lib/dsh-adapter/etscli-shell-guard.js +420 -0
- package/lib/dsh-adapter/etscli-tools.d.ts +112 -0
- package/lib/dsh-adapter/etscli-tools.d.ts.map +1 -0
- package/lib/dsh-adapter/etscli-tools.js +393 -0
- package/lib/dsh-adapter/file-logger.d.ts +294 -0
- package/lib/dsh-adapter/file-logger.d.ts.map +1 -0
- package/lib/dsh-adapter/file-logger.js +564 -0
- package/lib/dsh-adapter/function-call-external.d.ts +357 -0
- package/lib/dsh-adapter/function-call-external.d.ts.map +1 -0
- package/lib/dsh-adapter/function-call-external.js +1005 -0
- package/lib/dsh-adapter/host-egress-guard.d.ts +161 -0
- package/lib/dsh-adapter/host-egress-guard.d.ts.map +1 -0
- package/lib/dsh-adapter/host-egress-guard.js +213 -0
- package/lib/dsh-adapter/host-llm-trace.d.ts +198 -0
- package/lib/dsh-adapter/host-llm-trace.d.ts.map +1 -0
- package/lib/dsh-adapter/host-llm-trace.js +344 -0
- package/lib/dsh-adapter/http-api-routes.d.ts +61 -0
- package/lib/dsh-adapter/http-api-routes.d.ts.map +1 -0
- package/lib/dsh-adapter/http-api-routes.js +79 -0
- package/lib/dsh-adapter/http-api-sessions.d.ts +105 -0
- package/lib/dsh-adapter/http-api-sessions.d.ts.map +1 -0
- package/lib/dsh-adapter/http-api-sessions.js +173 -0
- package/lib/dsh-adapter/http-api.d.ts +221 -0
- package/lib/dsh-adapter/http-api.d.ts.map +1 -0
- package/lib/dsh-adapter/http-api.js +1811 -0
- package/lib/dsh-adapter/index.d.ts +62 -0
- package/lib/dsh-adapter/index.d.ts.map +1 -0
- package/lib/dsh-adapter/index.js +97 -0
- package/lib/dsh-adapter/input-guard-messages.d.ts +56 -0
- package/lib/dsh-adapter/input-guard-messages.d.ts.map +1 -0
- package/lib/dsh-adapter/input-guard-messages.js +136 -0
- package/lib/dsh-adapter/input-guard.d.ts +508 -0
- package/lib/dsh-adapter/input-guard.d.ts.map +1 -0
- package/lib/dsh-adapter/input-guard.js +2174 -0
- package/lib/dsh-adapter/intent-prompts.d.ts +77 -0
- package/lib/dsh-adapter/intent-prompts.d.ts.map +1 -0
- package/lib/dsh-adapter/intent-prompts.js +97 -0
- package/lib/dsh-adapter/intent-router.d.ts +710 -0
- package/lib/dsh-adapter/intent-router.d.ts.map +1 -0
- package/lib/dsh-adapter/intent-router.js +2802 -0
- package/lib/dsh-adapter/intent-signals.d.ts +208 -0
- package/lib/dsh-adapter/intent-signals.d.ts.map +1 -0
- package/lib/dsh-adapter/intent-signals.js +391 -0
- package/lib/dsh-adapter/invariant-install.d.ts +16 -0
- package/lib/dsh-adapter/invariant-install.d.ts.map +1 -0
- package/lib/dsh-adapter/invariant-install.js +15 -0
- package/lib/dsh-adapter/invariant.d.ts +16 -0
- package/lib/dsh-adapter/invariant.d.ts.map +1 -0
- package/lib/dsh-adapter/invariant.js +22 -0
- package/lib/dsh-adapter/llm-adapters.d.ts +19 -0
- package/lib/dsh-adapter/llm-adapters.d.ts.map +1 -0
- package/lib/dsh-adapter/llm-adapters.js +16 -0
- package/lib/dsh-adapter/llm-internal-caller.d.ts +92 -0
- package/lib/dsh-adapter/llm-internal-caller.d.ts.map +1 -0
- package/lib/dsh-adapter/llm-internal-caller.js +100 -0
- package/lib/dsh-adapter/llm-max-turns.d.ts +55 -0
- package/lib/dsh-adapter/llm-max-turns.d.ts.map +1 -0
- package/lib/dsh-adapter/llm-max-turns.js +127 -0
- package/lib/dsh-adapter/llm-trace-store.d.ts +131 -0
- package/lib/dsh-adapter/llm-trace-store.d.ts.map +1 -0
- package/lib/dsh-adapter/llm-trace-store.js +205 -0
- package/lib/dsh-adapter/llm-trace.d.ts +450 -0
- package/lib/dsh-adapter/llm-trace.d.ts.map +1 -0
- package/lib/dsh-adapter/llm-trace.js +463 -0
- package/lib/dsh-adapter/local-llm-adapter.d.ts +141 -0
- package/lib/dsh-adapter/local-llm-adapter.d.ts.map +1 -0
- package/lib/dsh-adapter/local-llm-adapter.js +787 -0
- package/lib/dsh-adapter/local-storage.d.ts +96 -0
- package/lib/dsh-adapter/local-storage.d.ts.map +1 -0
- package/lib/dsh-adapter/local-storage.js +198 -0
- package/lib/dsh-adapter/model-router.d.ts +79 -0
- package/lib/dsh-adapter/model-router.d.ts.map +1 -0
- package/lib/dsh-adapter/model-router.js +107 -0
- package/lib/dsh-adapter/ohttp-gateway.d.ts +49 -0
- package/lib/dsh-adapter/ohttp-gateway.d.ts.map +1 -0
- package/lib/dsh-adapter/ohttp-gateway.js +32 -0
- package/lib/dsh-adapter/openai-sse.d.ts +85 -0
- package/lib/dsh-adapter/openai-sse.d.ts.map +1 -0
- package/lib/dsh-adapter/openai-sse.js +462 -0
- package/lib/dsh-adapter/pii-utils.d.ts +96 -0
- package/lib/dsh-adapter/pii-utils.d.ts.map +1 -0
- package/lib/dsh-adapter/pii-utils.js +324 -0
- package/lib/dsh-adapter/plugin-host.d.ts +38 -0
- package/lib/dsh-adapter/plugin-host.d.ts.map +1 -0
- package/lib/dsh-adapter/plugin-host.js +38 -0
- package/lib/dsh-adapter/plugin.d.ts +46 -0
- package/lib/dsh-adapter/plugin.d.ts.map +1 -0
- package/lib/dsh-adapter/plugin.js +100 -0
- package/lib/dsh-adapter/privacy-policy.d.ts +237 -0
- package/lib/dsh-adapter/privacy-policy.d.ts.map +1 -0
- package/lib/dsh-adapter/privacy-policy.js +391 -0
- package/lib/dsh-adapter/prompt-guard.d.ts +152 -0
- package/lib/dsh-adapter/prompt-guard.d.ts.map +1 -0
- package/lib/dsh-adapter/prompt-guard.js +872 -0
- package/lib/dsh-adapter/realm-skill-bridge.d.ts +365 -0
- package/lib/dsh-adapter/realm-skill-bridge.d.ts.map +1 -0
- package/lib/dsh-adapter/realm-skill-bridge.js +1180 -0
- package/lib/dsh-adapter/sanitizer-pipeline.d.ts +140 -0
- package/lib/dsh-adapter/sanitizer-pipeline.d.ts.map +1 -0
- package/lib/dsh-adapter/sanitizer-pipeline.js +431 -0
- package/lib/dsh-adapter/secure-bridge.d.ts +47 -0
- package/lib/dsh-adapter/secure-bridge.d.ts.map +1 -0
- package/lib/dsh-adapter/secure-bridge.js +35 -0
- package/lib/dsh-adapter/session-attribution.d.ts +70 -0
- package/lib/dsh-adapter/session-attribution.d.ts.map +1 -0
- package/lib/dsh-adapter/session-attribution.js +76 -0
- package/lib/dsh-adapter/sessions.d.ts +86 -0
- package/lib/dsh-adapter/sessions.d.ts.map +1 -0
- package/lib/dsh-adapter/sessions.js +179 -0
- package/lib/dsh-adapter/tcm.d.ts +55 -0
- package/lib/dsh-adapter/tcm.d.ts.map +1 -0
- package/lib/dsh-adapter/tcm.js +94 -0
- package/lib/dsh-adapter/td-bus.d.ts +87 -0
- package/lib/dsh-adapter/td-bus.d.ts.map +1 -0
- package/lib/dsh-adapter/td-bus.js +196 -0
- package/lib/dsh-adapter/tool-runner.d.ts +185 -0
- package/lib/dsh-adapter/tool-runner.d.ts.map +1 -0
- package/lib/dsh-adapter/tool-runner.js +271 -0
- package/lib/dsh-adapter/tools.d.ts +118 -0
- package/lib/dsh-adapter/tools.d.ts.map +1 -0
- package/lib/dsh-adapter/tools.js +292 -0
- package/lib/dsh-adapter/types.d.ts +23 -0
- package/lib/dsh-adapter/types.d.ts.map +1 -0
- package/lib/dsh-adapter/types.js +16 -0
- package/lib/dsh-adapter/voice-input.d.ts +30 -0
- package/lib/dsh-adapter/voice-input.d.ts.map +1 -0
- package/lib/dsh-adapter/voice-input.js +34 -0
- package/lib/dsh-adapter/web-search-tavily-bocha.d.ts +152 -0
- package/lib/dsh-adapter/web-search-tavily-bocha.d.ts.map +1 -0
- package/lib/dsh-adapter/web-search-tavily-bocha.js +289 -0
- package/lib/http-api.d.ts +2 -0
- package/lib/http-api.d.ts.map +1 -0
- package/lib/http-api.js +2 -0
- package/lib/index.d.ts +48 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +49 -0
- package/lib/intent-router.d.ts +2 -0
- package/lib/intent-router.d.ts.map +1 -0
- package/lib/intent-router.js +2 -0
- package/lib/llm-adapters.d.ts +2 -0
- package/lib/llm-adapters.d.ts.map +1 -0
- package/lib/llm-adapters.js +2 -0
- package/lib/local-llm-adapter.d.ts +2 -0
- package/lib/local-llm-adapter.d.ts.map +1 -0
- package/lib/local-llm-adapter.js +2 -0
- package/lib/local-storage.d.ts +2 -0
- package/lib/local-storage.d.ts.map +1 -0
- package/lib/local-storage.js +2 -0
- package/lib/model-router.d.ts +2 -0
- package/lib/model-router.d.ts.map +1 -0
- package/lib/model-router.js +2 -0
- package/lib/ohttp-gateway.d.ts +2 -0
- package/lib/ohttp-gateway.d.ts.map +1 -0
- package/lib/ohttp-gateway.js +2 -0
- package/lib/plugin-host.d.ts +2 -0
- package/lib/plugin-host.d.ts.map +1 -0
- package/lib/plugin-host.js +2 -0
- package/lib/privacy-policy.d.ts +2 -0
- package/lib/privacy-policy.d.ts.map +1 -0
- package/lib/privacy-policy.js +2 -0
- package/lib/prompt-guard.d.ts +2 -0
- package/lib/prompt-guard.d.ts.map +1 -0
- package/lib/prompt-guard.js +2 -0
- package/lib/sanitizer-pipeline.d.ts +2 -0
- package/lib/sanitizer-pipeline.d.ts.map +1 -0
- package/lib/sanitizer-pipeline.js +2 -0
- package/lib/secure-bridge.d.ts +2 -0
- package/lib/secure-bridge.d.ts.map +1 -0
- package/lib/secure-bridge.js +2 -0
- package/lib/sessions.d.ts +2 -0
- package/lib/sessions.d.ts.map +1 -0
- package/lib/sessions.js +2 -0
- package/lib/tcm.d.ts +2 -0
- package/lib/tcm.d.ts.map +1 -0
- package/lib/tcm.js +2 -0
- package/lib/td-bus.d.ts +2 -0
- package/lib/td-bus.d.ts.map +1 -0
- package/lib/td-bus.js +2 -0
- package/lib/tools.d.ts +2 -0
- package/lib/tools.d.ts.map +1 -0
- package/lib/tools.js +2 -0
- package/lib/voice-input.d.ts +2 -0
- package/lib/voice-input.d.ts.map +1 -0
- package/lib/voice-input.js +2 -0
- package/package.json +253 -3
- package/presets/agent-preset-edge.json +50 -0
- package/presets/edge.desktop-safe.patch.yml +503 -0
- package/presets/edge.patch.yml +557 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sanitizer-pipeline — forward sanitization (PII → placeholders) and
|
|
3
|
+
* reverse desanitization (placeholders → PII) for SANITIZED-realm
|
|
4
|
+
* sessions. The mapping table is per-session and stored in memory.
|
|
5
|
+
*
|
|
6
|
+
* Phase 4 完整实现:
|
|
7
|
+
* - 正向脱敏(PII → 占位符)
|
|
8
|
+
* - 反向还原(占位符 → PII)
|
|
9
|
+
* - 会话级映射表(带 TTL + 容量上限,见下)
|
|
10
|
+
* - 与 privacy-policy 联动(获取脱敏规则)
|
|
11
|
+
*
|
|
12
|
+
* ## 内存中的映射表就是明文 PII
|
|
13
|
+
*
|
|
14
|
+
* `sessionMappings` 持有的是**用户原始 PII 明文**(还原时必须用到)。
|
|
15
|
+
* 因此它必须有明确的生命周期,不能只靠调用方记得调 `purge()`:
|
|
16
|
+
* 1. `http-api.dropSessionBuffer()` 在会话删除/过期/LRU 淘汰/停机时
|
|
17
|
+
* 主动调用 `purge()`(首选路径)
|
|
18
|
+
* 2. 本模块自身按 `sessionTtlMs` 惰性 + 定时清扫(兜底,防止调用方漏调)
|
|
19
|
+
* 3. `maxSessions` 上限触发 LRU 淘汰(兜底,防止会话 id 爆炸)
|
|
20
|
+
*
|
|
21
|
+
* @module dsh-edge-agent/sanitizer-pipeline
|
|
22
|
+
*/
|
|
23
|
+
import { Service } from '@deepseek-ai/cordis';
|
|
24
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
25
|
+
import type { PrivacyPolicyServiceImpl } from './privacy-policy.js';
|
|
26
|
+
export declare const name = "sanitizer-pipeline";
|
|
27
|
+
export declare const inject: readonly ["privacyPolicy", "auditLog"];
|
|
28
|
+
export interface Config {
|
|
29
|
+
/** 单会话占位符上限。超限时 sanitize 抛错(见 SanitizerLimitError)。 */
|
|
30
|
+
maxReplacementsPerSession: number;
|
|
31
|
+
/** 会话映射表存活时长(毫秒),自最后一次访问算起。0 = 不过期。 */
|
|
32
|
+
sessionTtlMs: number;
|
|
33
|
+
/** 同时保留的最大会话数,超出按最近访问时间 LRU 淘汰。0 = 不限。 */
|
|
34
|
+
maxSessions: number;
|
|
35
|
+
/** 后台清扫间隔(毫秒)。0 = 关闭定时清扫,仅惰性清扫。 */
|
|
36
|
+
sweepIntervalMs: number;
|
|
37
|
+
/** 保留字段:是否启用自动检测(当前恒为规则驱动)。 */
|
|
38
|
+
enableAutoDetection: boolean;
|
|
39
|
+
}
|
|
40
|
+
export declare const Config: Schema<Config>;
|
|
41
|
+
/**
|
|
42
|
+
* 单会话占位符数超过 `maxReplacementsPerSession` 时抛出。
|
|
43
|
+
*
|
|
44
|
+
* 不能"脱一半就放行"——剩余未替换的 PII 会随 prompt 直接上云。抛错让
|
|
45
|
+
* input-guard 走 `downgradeOnSanitizeFailure` 分支降级到 LOCAL,符合
|
|
46
|
+
* "脱不了敏就不上云"。
|
|
47
|
+
*/
|
|
48
|
+
export declare class SanitizerLimitError extends Error {
|
|
49
|
+
readonly code = "sanitizer-limit-exceeded";
|
|
50
|
+
constructor(sessionId: string, limit: number);
|
|
51
|
+
}
|
|
52
|
+
export interface Replacement {
|
|
53
|
+
original: string;
|
|
54
|
+
placeholder: string;
|
|
55
|
+
piiType: string;
|
|
56
|
+
dataClass: string;
|
|
57
|
+
/** §7.106: the prefix left literal in the outbound text (partial masking);
|
|
58
|
+
* absent when the whole value was replaced by the placeholder. */
|
|
59
|
+
keptPrefix?: string;
|
|
60
|
+
}
|
|
61
|
+
export interface SanitizationResult {
|
|
62
|
+
sanitizedText: string;
|
|
63
|
+
sessionId: string;
|
|
64
|
+
replacements: Replacement[];
|
|
65
|
+
}
|
|
66
|
+
export interface SanitizerService {
|
|
67
|
+
sanitize(text: string, context?: {
|
|
68
|
+
sessionId?: string;
|
|
69
|
+
}): Promise<SanitizationResult>;
|
|
70
|
+
desanitize(text: string, sessionId: string): Promise<string>;
|
|
71
|
+
desanitizeSync(text: string, sessionId: string): string;
|
|
72
|
+
purge(sessionId: string): Promise<void>;
|
|
73
|
+
getMappingSize(sessionId: string): number;
|
|
74
|
+
}
|
|
75
|
+
declare module '@deepseek-ai/cordis' {
|
|
76
|
+
interface Context {
|
|
77
|
+
sanitizer: SanitizerServiceImpl;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export declare class SanitizerServiceImpl extends Service implements SanitizerService {
|
|
81
|
+
private privacyPolicy;
|
|
82
|
+
private auditLog;
|
|
83
|
+
private sessionMappings;
|
|
84
|
+
private readonly maxReplacementsPerSession;
|
|
85
|
+
private readonly sessionTtlMs;
|
|
86
|
+
private readonly maxSessions;
|
|
87
|
+
private sweepTimer;
|
|
88
|
+
constructor(ctx: any, config: Config, privacyPolicy: PrivacyPolicyServiceImpl, auditLog: any);
|
|
89
|
+
/** 停止后台清扫并清空所有会话映射(明文 PII 一并释放)。 */
|
|
90
|
+
stop(): Promise<void>;
|
|
91
|
+
/**
|
|
92
|
+
* Forward sanitization: replace PII with placeholders.
|
|
93
|
+
* Returns the sanitized text and the list of replacements made.
|
|
94
|
+
*
|
|
95
|
+
* @throws {SanitizerLimitError} 当本会话占位符数将超过上限时。调用方
|
|
96
|
+
* (input-guard)应据此降级到 LOCAL,而不是发送半脱敏文本。
|
|
97
|
+
*/
|
|
98
|
+
sanitize(text: string, context?: {
|
|
99
|
+
sessionId?: string;
|
|
100
|
+
}): Promise<SanitizationResult>;
|
|
101
|
+
/**
|
|
102
|
+
* Reverse restoration: replace placeholders back to original PII.
|
|
103
|
+
*/
|
|
104
|
+
desanitize(text: string, sessionId: string): Promise<string>;
|
|
105
|
+
/**
|
|
106
|
+
* Synchronous variant of desanitize — no audit log, for use in streaming
|
|
107
|
+
* event callbacks where async is not available.
|
|
108
|
+
*
|
|
109
|
+
* 单次正则扫描 + 查表,复杂度 O(text)。此前的实现对**每一条**映射做一次
|
|
110
|
+
* 全文 split/join(O(mappings × text)),而流式路径每个 chunk 都会调一次,
|
|
111
|
+
* 长会话下是明确的热点(2026-08-30 修复)。
|
|
112
|
+
*/
|
|
113
|
+
desanitizeSync(text: string, sessionId: string): string;
|
|
114
|
+
/**
|
|
115
|
+
* Purge all mappings for a session (e.g., when session is deleted).
|
|
116
|
+
*/
|
|
117
|
+
purge(sessionId: string): Promise<void>;
|
|
118
|
+
/**
|
|
119
|
+
* Get the number of mappings for a session (for debugging/testing).
|
|
120
|
+
*/
|
|
121
|
+
getMappingSize(sessionId: string): number;
|
|
122
|
+
/** 当前活跃会话数(用于观测与测试)。 */
|
|
123
|
+
getSessionCount(): number;
|
|
124
|
+
/** 取出会话映射(不存在则新建),并刷新访问时间 + 施加容量上限。 */
|
|
125
|
+
private touchSession;
|
|
126
|
+
/** 超出 maxSessions 时淘汰最久未访问的会话。 */
|
|
127
|
+
private evictIfOverCapacity;
|
|
128
|
+
/** 清除超过 TTL 未访问的会话映射,释放其中的明文 PII。 */
|
|
129
|
+
private sweepExpired;
|
|
130
|
+
/**
|
|
131
|
+
* 把会话计数器推过输入中出现的任何占位符形状字面量的序号。
|
|
132
|
+
*
|
|
133
|
+
* 残留风险(已知且可接受):用户若在后续轮次原样回显上一轮**本会话**
|
|
134
|
+
* 铸造过的占位符,还原时会被替换回该会话自己的 PII——不构成跨会话泄漏。
|
|
135
|
+
*/
|
|
136
|
+
private bumpCounterPastLiterals;
|
|
137
|
+
private audit;
|
|
138
|
+
}
|
|
139
|
+
export declare function apply(ctx: any, config: Config): Promise<() => Promise<void>>;
|
|
140
|
+
//# sourceMappingURL=sanitizer-pipeline.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sanitizer-pipeline.d.ts","sourceRoot":"","sources":["../../src/dsh-adapter/sanitizer-pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAC7C,OAAO,MAAM,MAAM,0BAA0B,CAAA;AAC7C,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,qBAAqB,CAAA;AAInE,eAAO,MAAM,IAAI,uBAAuB,CAAA;AAExC,eAAO,MAAM,MAAM,wCAAyC,CAAA;AAE5D,MAAM,WAAW,MAAM;IACrB,uDAAuD;IACvD,yBAAyB,EAAE,MAAM,CAAA;IACjC,uCAAuC;IACvC,YAAY,EAAE,MAAM,CAAA;IACpB,0CAA0C;IAC1C,WAAW,EAAE,MAAM,CAAA;IACnB,mCAAmC;IACnC,eAAe,EAAE,MAAM,CAAA;IACvB,+BAA+B;IAC/B,mBAAmB,EAAE,OAAO,CAAA;CAC7B;AAED,eAAO,MAAM,MAAM,EAAE,MAAM,CAAC,MAAM,CAMhC,CAAA;AAQF;;;;;;GAMG;AACH,qBAAa,mBAAoB,SAAQ,KAAK;IAC5C,QAAQ,CAAC,IAAI,8BAA6B;gBAC9B,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM;CAI7C;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,WAAW,EAAE,MAAM,CAAA;IACnB,OAAO,EAAE,MAAM,CAAA;IACf,SAAS,EAAE,MAAM,CAAA;IACjB;uEACmE;IACnE,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,kBAAkB;IACjC,aAAa,EAAE,MAAM,CAAA;IACrB,SAAS,EAAE,MAAM,CAAA;IACjB,YAAY,EAAE,WAAW,EAAE,CAAA;CAC5B;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAAA;IACrF,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IAC5D,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAAA;IACvD,KAAK,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACvC,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAA;CAC1C;AAED,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,OAAO;QACf,SAAS,EAAE,oBAAoB,CAAA;KAChC;CACF;AAgBD,qBAAa,oBAAqB,SAAQ,OAAQ,YAAW,gBAAgB;IAUzE,OAAO,CAAC,aAAa;IACrB,OAAO,CAAC,QAAQ;IAVlB,OAAO,CAAC,eAAe,CAAoC;IAC3D,OAAO,CAAC,QAAQ,CAAC,yBAAyB,CAAQ;IAClD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAQ;IACrC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAQ;IACpC,OAAO,CAAC,UAAU,CAA4C;gBAG5D,GAAG,EAAE,GAAG,EACR,MAAM,EAAE,MAAM,EACN,aAAa,EAAE,wBAAwB,EACvC,QAAQ,EAAE,GAAG;IAgBvB,oCAAoC;IAC9B,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAU3B;;;;;;OAMG;IACG,QAAQ,CACZ,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,GAC/B,OAAO,CAAC,kBAAkB,CAAC;IAiL9B;;OAEG;IACG,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAWlE;;;;;;;OAOG;IACH,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM;IAUvD;;OAEG;IACG,KAAK,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAW7C;;OAEG;IACH,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM;IAIzC,wBAAwB;IACxB,eAAe,IAAI,MAAM;IAIzB,uCAAuC;IACvC,OAAO,CAAC,YAAY;IAmBpB,kCAAkC;IAClC,OAAO,CAAC,mBAAmB;IAiB3B,qCAAqC;YACvB,YAAY;IAqB1B;;;;;OAKG;IACH,OAAO,CAAC,uBAAuB;YASjB,KAAK;CAQpB;AAED,wBAAsB,KAAK,CAAC,GAAG,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CA6BlF"}
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sanitizer-pipeline — forward sanitization (PII → placeholders) and
|
|
3
|
+
* reverse desanitization (placeholders → PII) for SANITIZED-realm
|
|
4
|
+
* sessions. The mapping table is per-session and stored in memory.
|
|
5
|
+
*
|
|
6
|
+
* Phase 4 完整实现:
|
|
7
|
+
* - 正向脱敏(PII → 占位符)
|
|
8
|
+
* - 反向还原(占位符 → PII)
|
|
9
|
+
* - 会话级映射表(带 TTL + 容量上限,见下)
|
|
10
|
+
* - 与 privacy-policy 联动(获取脱敏规则)
|
|
11
|
+
*
|
|
12
|
+
* ## 内存中的映射表就是明文 PII
|
|
13
|
+
*
|
|
14
|
+
* `sessionMappings` 持有的是**用户原始 PII 明文**(还原时必须用到)。
|
|
15
|
+
* 因此它必须有明确的生命周期,不能只靠调用方记得调 `purge()`:
|
|
16
|
+
* 1. `http-api.dropSessionBuffer()` 在会话删除/过期/LRU 淘汰/停机时
|
|
17
|
+
* 主动调用 `purge()`(首选路径)
|
|
18
|
+
* 2. 本模块自身按 `sessionTtlMs` 惰性 + 定时清扫(兜底,防止调用方漏调)
|
|
19
|
+
* 3. `maxSessions` 上限触发 LRU 淘汰(兜底,防止会话 id 爆炸)
|
|
20
|
+
*
|
|
21
|
+
* @module dsh-edge-agent/sanitizer-pipeline
|
|
22
|
+
*/
|
|
23
|
+
import { Service } from '@deepseek-ai/cordis';
|
|
24
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
25
|
+
import { normalizeForPII, sliceOriginal } from './pii-utils.js';
|
|
26
|
+
import { loadEdgeConfig } from './edge-config.js';
|
|
27
|
+
export const name = 'sanitizer-pipeline';
|
|
28
|
+
export const inject = ['privacyPolicy', 'auditLog'];
|
|
29
|
+
export const Config = Schema.object({
|
|
30
|
+
maxReplacementsPerSession: Schema.natural().default(1024),
|
|
31
|
+
sessionTtlMs: Schema.natural().default(900_000), // 15 min,对齐 confidential 档
|
|
32
|
+
maxSessions: Schema.natural().default(1000),
|
|
33
|
+
sweepIntervalMs: Schema.natural().default(60_000),
|
|
34
|
+
enableAutoDetection: Schema.boolean().default(true),
|
|
35
|
+
});
|
|
36
|
+
/**
|
|
37
|
+
* 占位符字面量形态:`[<PIITYPE>_<seq>]`,PIITYPE 为大写且可含连字符
|
|
38
|
+
* (如 `[ID-CARD_1]`、`[PHONE_2]`)。反向还原与防碰撞计数都依赖它。
|
|
39
|
+
*/
|
|
40
|
+
const PLACEHOLDER_RE = /\[([A-Z][A-Z0-9-]*)_(\d+)\]/g;
|
|
41
|
+
/**
|
|
42
|
+
* 单会话占位符数超过 `maxReplacementsPerSession` 时抛出。
|
|
43
|
+
*
|
|
44
|
+
* 不能"脱一半就放行"——剩余未替换的 PII 会随 prompt 直接上云。抛错让
|
|
45
|
+
* input-guard 走 `downgradeOnSanitizeFailure` 分支降级到 LOCAL,符合
|
|
46
|
+
* "脱不了敏就不上云"。
|
|
47
|
+
*/
|
|
48
|
+
export class SanitizerLimitError extends Error {
|
|
49
|
+
code = 'sanitizer-limit-exceeded';
|
|
50
|
+
constructor(sessionId, limit) {
|
|
51
|
+
super(`sanitizer: session ${sessionId} exceeded ${limit} placeholders`);
|
|
52
|
+
this.name = 'SanitizerLimitError';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export class SanitizerServiceImpl extends Service {
|
|
56
|
+
privacyPolicy;
|
|
57
|
+
auditLog;
|
|
58
|
+
sessionMappings = new Map();
|
|
59
|
+
maxReplacementsPerSession;
|
|
60
|
+
sessionTtlMs;
|
|
61
|
+
maxSessions;
|
|
62
|
+
sweepTimer;
|
|
63
|
+
constructor(ctx, config, privacyPolicy, auditLog) {
|
|
64
|
+
super(ctx, 'sanitizer');
|
|
65
|
+
this.privacyPolicy = privacyPolicy;
|
|
66
|
+
this.auditLog = auditLog;
|
|
67
|
+
this.maxReplacementsPerSession = config.maxReplacementsPerSession ?? 1024;
|
|
68
|
+
this.sessionTtlMs = config.sessionTtlMs ?? 900_000;
|
|
69
|
+
this.maxSessions = config.maxSessions ?? 1000;
|
|
70
|
+
const interval = config.sweepIntervalMs ?? 60_000;
|
|
71
|
+
if (interval > 0 && this.sessionTtlMs > 0) {
|
|
72
|
+
this.sweepTimer = setInterval(() => {
|
|
73
|
+
void this.sweepExpired();
|
|
74
|
+
}, interval);
|
|
75
|
+
// 不要因为这个兜底定时器把进程钉住
|
|
76
|
+
if (typeof this.sweepTimer.unref === 'function')
|
|
77
|
+
this.sweepTimer.unref();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** 停止后台清扫并清空所有会话映射(明文 PII 一并释放)。 */
|
|
81
|
+
async stop() {
|
|
82
|
+
if (this.sweepTimer) {
|
|
83
|
+
clearInterval(this.sweepTimer);
|
|
84
|
+
this.sweepTimer = undefined;
|
|
85
|
+
}
|
|
86
|
+
for (const sessionId of [...this.sessionMappings.keys()]) {
|
|
87
|
+
await this.purge(sessionId);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Forward sanitization: replace PII with placeholders.
|
|
92
|
+
* Returns the sanitized text and the list of replacements made.
|
|
93
|
+
*
|
|
94
|
+
* @throws {SanitizerLimitError} 当本会话占位符数将超过上限时。调用方
|
|
95
|
+
* (input-guard)应据此降级到 LOCAL,而不是发送半脱敏文本。
|
|
96
|
+
*/
|
|
97
|
+
async sanitize(text, context) {
|
|
98
|
+
const sessionId = context?.sessionId ?? 'default';
|
|
99
|
+
await this.sweepExpired();
|
|
100
|
+
const mapping = this.touchSession(sessionId);
|
|
101
|
+
// 防碰撞:若用户输入里本就含有 `[PHONE_7]` 这种占位符形状的字面量,
|
|
102
|
+
// 把计数器推过它,确保后续新铸造的占位符永远不会与之重号——否则
|
|
103
|
+
// 反向还原会把用户的普通文本替换成别人的 PII。
|
|
104
|
+
this.bumpCounterPastLiterals(mapping, text);
|
|
105
|
+
const rules = this.privacyPolicy.getSanitizationRules();
|
|
106
|
+
let sanitizedText = text;
|
|
107
|
+
const replacements = [];
|
|
108
|
+
// §7.87.13 (2026-09-20): PII 预检测前的字符级规范化。
|
|
109
|
+
//
|
|
110
|
+
// PII 正则对"空格分隔 phone" / "中文数字 id_card"直接漏抓,LLM 也漏
|
|
111
|
+
// (§7.87.12 4 模型 trace 显示 100% 失败率)。规范化在文本上做:
|
|
112
|
+
// - 空白丢弃
|
|
113
|
+
// - 中文数字 → 阿拉伯数字(壹贰叁.../〇/两 等变体)
|
|
114
|
+
// 用 charMap 把正则在 normalized 上的命中区间翻回 text 原始子串,
|
|
115
|
+
// 让 `byOriginal` 仍以"用户原输入字符串"为 key(desanitize 后能
|
|
116
|
+
// 还原为带空格 / 中文数字的字面形态)。
|
|
117
|
+
//
|
|
118
|
+
// 性能:normalized/charMap 都是 O(n) 一次性扫描,后续规则匹配走的是
|
|
119
|
+
// 原 normalized(命中区间已被 placeholder 替换),不会累积放大。
|
|
120
|
+
const normResult = normalizeForPII(text);
|
|
121
|
+
let normalized = normResult.normalized;
|
|
122
|
+
const charMap = normResult.charMap;
|
|
123
|
+
if (normResult.changed) {
|
|
124
|
+
// 规范化实际修改了文本 → 审计,便于排查"为何 sanitize 命中了一个看似不含 PII 的输入"
|
|
125
|
+
// (例:用户输入 "1 3 8 0 0 1 3 8 0 0 0" → 被规范化后命中 phone regex)
|
|
126
|
+
await this.audit('debug', 'sanitizer.normalized', {
|
|
127
|
+
sessionId,
|
|
128
|
+
originalLen: text.length,
|
|
129
|
+
normalizedLen: normalized.length,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
for (const rule of rules) {
|
|
133
|
+
let regex;
|
|
134
|
+
try {
|
|
135
|
+
regex = new RegExp(rule.pattern, 'gi');
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
continue; // 无效正则,跳过该规则
|
|
139
|
+
}
|
|
140
|
+
// §7.87.13 (2026-09-20): regex 在 normalized 上匹配,而非 sanitizedText。
|
|
141
|
+
// 必须用 exec 拿到 m.index,才能用 charMap 翻回原始子串。
|
|
142
|
+
// dedup key 改成"用户原字符串"(`byOriginal` 已经以原字符串为 key)。
|
|
143
|
+
const matches = [];
|
|
144
|
+
regex.lastIndex = 0;
|
|
145
|
+
let m;
|
|
146
|
+
while ((m = regex.exec(normalized)) !== null) {
|
|
147
|
+
if (m[0].length === 0) {
|
|
148
|
+
// 零宽匹配防御(应不会发生,PII regex 都要求长度)
|
|
149
|
+
regex.lastIndex++;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
matches.push({ matchStr: m[0], normStart: m.index, normEnd: m.index + m[0].length });
|
|
153
|
+
}
|
|
154
|
+
if (matches.length === 0)
|
|
155
|
+
continue;
|
|
156
|
+
// §7.87.13 (2026-09-20): 反向处理。
|
|
157
|
+
//
|
|
158
|
+
// 正则在 normalized 上匹配到 N 个区间(positions [start, end))。我们对
|
|
159
|
+
// 每个区间做"matchStr → placeholder"替换,同时**更新 charMap** 以保持
|
|
160
|
+
// 它跟 normalized 同步 — charMap 的语义是"normalized 位置 → 原文字
|
|
161
|
+
// 符位置",每替换一段都必须 splice 同步调整:
|
|
162
|
+
// 删 L 个 charMap 条目(normStart..normStart+L)
|
|
163
|
+
// 插 P 个条目(normStart..normStart+P),全部指向 charMap[normStart]
|
|
164
|
+
// (placeholder 内部位置映射回"被替换段的起点",后续规则不会进
|
|
165
|
+
// 入 placeholder 区间查询)
|
|
166
|
+
//
|
|
167
|
+
// **反向处理**(从最后一个 match 开始)是为了让前面 match 的 normStart
|
|
168
|
+
// 在被替换后仍有效 — 反向处理时,左边的 match 的 normStart 还没被右
|
|
169
|
+
// 边的替换影响。
|
|
170
|
+
const seenOriginals = new Set();
|
|
171
|
+
for (let mi = matches.length - 1; mi >= 0; mi--) {
|
|
172
|
+
const { matchStr, normStart, normEnd } = matches[mi];
|
|
173
|
+
const originalSubstr = sliceOriginal(text, charMap, normStart, normEnd);
|
|
174
|
+
let placeholder = mapping.byOriginal.get(originalSubstr);
|
|
175
|
+
// §7.106 (2026-10-23): partial masking. The rule may ask to KEEP the
|
|
176
|
+
// first N characters of the match in the outbound text (phone: the
|
|
177
|
+
// 7-digit band is the attribution signal; id-card: the 6-digit
|
|
178
|
+
// region). The kept prefix stays literal; only the tail becomes the
|
|
179
|
+
// placeholder's value, so desanitize restores the whole PII for the
|
|
180
|
+
// user's own view while the cloud only ever saw the coarse part.
|
|
181
|
+
const keepCount = typeof rule.keepPrefix === 'number' && rule.keepPrefix > 0 ? rule.keepPrefix : 0;
|
|
182
|
+
// The kept prefix counts DIGITS, not characters: the original text may
|
|
183
|
+
// carry spaces or Chinese numerals ("1 3 8 0 0 1 3 8 0 0 0"), and
|
|
184
|
+
// slicing by character would keep only 3-4 digits of the band.
|
|
185
|
+
const keepNorm = keepCount > 0 && matchStr.length > keepCount ? matchStr.slice(0, keepCount) : '';
|
|
186
|
+
let keepOrig = '';
|
|
187
|
+
if (keepCount > 0) {
|
|
188
|
+
let digits = 0;
|
|
189
|
+
for (const ch of originalSubstr) {
|
|
190
|
+
keepOrig += ch;
|
|
191
|
+
if (ch >= '0' && ch <= '9') {
|
|
192
|
+
digits += 1;
|
|
193
|
+
if (digits >= keepCount)
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const tailOrig = keepOrig.length > 0 ? originalSubstr.slice(keepOrig.length) : originalSubstr;
|
|
199
|
+
if (!placeholder) {
|
|
200
|
+
// 先判上限再铸造:`>=` 后置检查会允许越界一个,且原先的 break
|
|
201
|
+
// 只跳出内层循环,外层规则照常继续铸造(限流形同虚设)。
|
|
202
|
+
if (this.maxReplacementsPerSession > 0 &&
|
|
203
|
+
mapping.placeholders.size >= this.maxReplacementsPerSession) {
|
|
204
|
+
await this.audit('warn', 'sanitizer.limit-reached', {
|
|
205
|
+
sessionId,
|
|
206
|
+
limit: this.maxReplacementsPerSession,
|
|
207
|
+
});
|
|
208
|
+
throw new SanitizerLimitError(sessionId, this.maxReplacementsPerSession);
|
|
209
|
+
}
|
|
210
|
+
mapping.counter++;
|
|
211
|
+
const piiType = rule.piiType || 'PII';
|
|
212
|
+
placeholder = `[${piiType.toUpperCase()}_${mapping.counter}]`;
|
|
213
|
+
// byOriginal / placeholders 都以"用户原输入字符串"为 value,保证
|
|
214
|
+
// desanitize 后还原为字面形态(带空格/中文数字),而非 normalized 形态。
|
|
215
|
+
// 部分脱敏时 value 只存尾段(保留的号段留在正文里,无需还原)。
|
|
216
|
+
mapping.placeholders.set(placeholder, keepOrig.length > 0 ? tailOrig : originalSubstr);
|
|
217
|
+
mapping.byOriginal.set(originalSubstr, placeholder);
|
|
218
|
+
}
|
|
219
|
+
// 在 normalized 上按 normStart 处精确替换 matchStr → (keep + placeholder)
|
|
220
|
+
const normReplacement = keepNorm + placeholder;
|
|
221
|
+
normalized =
|
|
222
|
+
normalized.slice(0, normStart) + normReplacement + normalized.slice(normEnd);
|
|
223
|
+
// 同步更新 charMap:删 L 插 P,P 个全指向 charMap[normStart](被替换段起点)
|
|
224
|
+
charMap.splice(normStart, matchStr.length, ...Array(normReplacement.length).fill(charMap[normStart]));
|
|
225
|
+
// 在 sanitizedText(原文)上按原字串替换 → 保留用户原输入字面,
|
|
226
|
+
// split/join 全量替换处理"同一 PII 多次出现"的场景
|
|
227
|
+
sanitizedText = sanitizedText.split(originalSubstr).join(keepOrig + placeholder);
|
|
228
|
+
// replacements 只记一次(对应原"counts a repeated value once"测试)
|
|
229
|
+
if (!seenOriginals.has(originalSubstr)) {
|
|
230
|
+
seenOriginals.add(originalSubstr);
|
|
231
|
+
replacements.push({
|
|
232
|
+
original: originalSubstr,
|
|
233
|
+
placeholder,
|
|
234
|
+
piiType: rule.piiType,
|
|
235
|
+
dataClass: rule.dataClass,
|
|
236
|
+
...(keepOrig.length > 0 ? { keptPrefix: keepOrig } : {}),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (replacements.length > 0) {
|
|
242
|
+
// §7.121: 原始判断数据 —— 光记「替换了几处」看不出**命中了哪条
|
|
243
|
+
// PII 规则**。`piiTypes` 是命中的规则类型(phone / id-card / …),
|
|
244
|
+
// `placeholders` 是替换后落进出网文本的占位符,两者都是**类型与
|
|
245
|
+
// 序号**,不含用户原文 —— 占位符本身是可逆映射的键,绝不是明文。
|
|
246
|
+
//
|
|
247
|
+
// `original` 绝不进 audit:它是用户 PII 明文。面板要看的「命中了
|
|
248
|
+
// 什么」由类型 + 占位符回答,不需要原文。
|
|
249
|
+
const piiTypes = [...new Set(replacements.map(r => r.piiType))];
|
|
250
|
+
await this.audit('info', 'sanitizer.sanitized', {
|
|
251
|
+
sessionId,
|
|
252
|
+
replacementCount: replacements.length,
|
|
253
|
+
totalMappings: mapping.placeholders.size,
|
|
254
|
+
piiTypes,
|
|
255
|
+
placeholders: replacements.map(r => r.placeholder),
|
|
256
|
+
dataClasses: [...new Set(replacements.map(r => r.dataClass))],
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return { sanitizedText, sessionId, replacements };
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Reverse restoration: replace placeholders back to original PII.
|
|
263
|
+
*/
|
|
264
|
+
async desanitize(text, sessionId) {
|
|
265
|
+
const restoredText = this.desanitizeSync(text, sessionId);
|
|
266
|
+
if (restoredText !== text) {
|
|
267
|
+
await this.audit('debug', 'sanitizer.desanitized', {
|
|
268
|
+
sessionId,
|
|
269
|
+
restored: true,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
return restoredText;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Synchronous variant of desanitize — no audit log, for use in streaming
|
|
276
|
+
* event callbacks where async is not available.
|
|
277
|
+
*
|
|
278
|
+
* 单次正则扫描 + 查表,复杂度 O(text)。此前的实现对**每一条**映射做一次
|
|
279
|
+
* 全文 split/join(O(mappings × text)),而流式路径每个 chunk 都会调一次,
|
|
280
|
+
* 长会话下是明确的热点(2026-08-30 修复)。
|
|
281
|
+
*/
|
|
282
|
+
desanitizeSync(text, sessionId) {
|
|
283
|
+
const mapping = this.sessionMappings.get(sessionId);
|
|
284
|
+
if (!mapping || mapping.placeholders.size === 0)
|
|
285
|
+
return text;
|
|
286
|
+
mapping.lastAccess = Date.now();
|
|
287
|
+
PLACEHOLDER_RE.lastIndex = 0;
|
|
288
|
+
return text.replace(PLACEHOLDER_RE, (whole) => mapping.placeholders.get(whole) ?? whole);
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Purge all mappings for a session (e.g., when session is deleted).
|
|
292
|
+
*/
|
|
293
|
+
async purge(sessionId) {
|
|
294
|
+
const mapping = this.sessionMappings.get(sessionId);
|
|
295
|
+
if (mapping) {
|
|
296
|
+
const size = mapping.placeholders.size;
|
|
297
|
+
mapping.placeholders.clear();
|
|
298
|
+
mapping.byOriginal.clear();
|
|
299
|
+
this.sessionMappings.delete(sessionId);
|
|
300
|
+
await this.audit('info', 'sanitizer.purged', { sessionId, purgedMappings: size });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Get the number of mappings for a session (for debugging/testing).
|
|
305
|
+
*/
|
|
306
|
+
getMappingSize(sessionId) {
|
|
307
|
+
return this.sessionMappings.get(sessionId)?.placeholders.size ?? 0;
|
|
308
|
+
}
|
|
309
|
+
/** 当前活跃会话数(用于观测与测试)。 */
|
|
310
|
+
getSessionCount() {
|
|
311
|
+
return this.sessionMappings.size;
|
|
312
|
+
}
|
|
313
|
+
/** 取出会话映射(不存在则新建),并刷新访问时间 + 施加容量上限。 */
|
|
314
|
+
touchSession(sessionId) {
|
|
315
|
+
let mapping = this.sessionMappings.get(sessionId);
|
|
316
|
+
if (!mapping) {
|
|
317
|
+
this.evictIfOverCapacity();
|
|
318
|
+
mapping = {
|
|
319
|
+
counter: 0,
|
|
320
|
+
placeholders: new Map(),
|
|
321
|
+
byOriginal: new Map(),
|
|
322
|
+
lastAccess: Date.now(),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
mapping.lastAccess = Date.now();
|
|
327
|
+
// 重新插入以维持 Map 的插入序 ≈ 最近访问序,便于 LRU 淘汰
|
|
328
|
+
this.sessionMappings.delete(sessionId);
|
|
329
|
+
}
|
|
330
|
+
this.sessionMappings.set(sessionId, mapping);
|
|
331
|
+
return mapping;
|
|
332
|
+
}
|
|
333
|
+
/** 超出 maxSessions 时淘汰最久未访问的会话。 */
|
|
334
|
+
evictIfOverCapacity() {
|
|
335
|
+
if (this.maxSessions <= 0)
|
|
336
|
+
return;
|
|
337
|
+
while (this.sessionMappings.size >= this.maxSessions) {
|
|
338
|
+
const oldest = this.sessionMappings.keys().next();
|
|
339
|
+
if (oldest.done)
|
|
340
|
+
break;
|
|
341
|
+
const victim = this.sessionMappings.get(oldest.value);
|
|
342
|
+
victim?.placeholders.clear();
|
|
343
|
+
victim?.byOriginal.clear();
|
|
344
|
+
this.sessionMappings.delete(oldest.value);
|
|
345
|
+
void this.audit('warn', 'sanitizer.session-evicted', {
|
|
346
|
+
sessionId: oldest.value,
|
|
347
|
+
reason: 'max-sessions',
|
|
348
|
+
maxSessions: this.maxSessions,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/** 清除超过 TTL 未访问的会话映射,释放其中的明文 PII。 */
|
|
353
|
+
async sweepExpired() {
|
|
354
|
+
if (this.sessionTtlMs <= 0)
|
|
355
|
+
return;
|
|
356
|
+
const cutoff = Date.now() - this.sessionTtlMs;
|
|
357
|
+
const expired = [];
|
|
358
|
+
for (const [sessionId, mapping] of this.sessionMappings) {
|
|
359
|
+
if (mapping.lastAccess < cutoff)
|
|
360
|
+
expired.push(sessionId);
|
|
361
|
+
}
|
|
362
|
+
for (const sessionId of expired) {
|
|
363
|
+
const mapping = this.sessionMappings.get(sessionId);
|
|
364
|
+
const size = mapping?.placeholders.size ?? 0;
|
|
365
|
+
mapping?.placeholders.clear();
|
|
366
|
+
mapping?.byOriginal.clear();
|
|
367
|
+
this.sessionMappings.delete(sessionId);
|
|
368
|
+
await this.audit('info', 'sanitizer.session-expired', {
|
|
369
|
+
sessionId,
|
|
370
|
+
purgedMappings: size,
|
|
371
|
+
ttlMs: this.sessionTtlMs,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* 把会话计数器推过输入中出现的任何占位符形状字面量的序号。
|
|
377
|
+
*
|
|
378
|
+
* 残留风险(已知且可接受):用户若在后续轮次原样回显上一轮**本会话**
|
|
379
|
+
* 铸造过的占位符,还原时会被替换回该会话自己的 PII——不构成跨会话泄漏。
|
|
380
|
+
*/
|
|
381
|
+
bumpCounterPastLiterals(mapping, text) {
|
|
382
|
+
PLACEHOLDER_RE.lastIndex = 0;
|
|
383
|
+
let m;
|
|
384
|
+
while ((m = PLACEHOLDER_RE.exec(text)) !== null) {
|
|
385
|
+
const seq = Number.parseInt(m[2], 10);
|
|
386
|
+
if (Number.isFinite(seq) && seq > mapping.counter)
|
|
387
|
+
mapping.counter = seq;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
async audit(level, kind, payload) {
|
|
391
|
+
if (!this.auditLog?.log)
|
|
392
|
+
return;
|
|
393
|
+
try {
|
|
394
|
+
await this.auditLog.log({ level, kind, source: 'sanitizer-pipeline', payload });
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
// Ignore audit errors
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
export async function apply(ctx, config) {
|
|
402
|
+
// §7.102: the session limits come from config/edge.json (privacy.*).
|
|
403
|
+
// The cordis patch row no longer reads process.env — the desktop GUI
|
|
404
|
+
// never had those variables.
|
|
405
|
+
const privacy = loadEdgeConfig().privacy;
|
|
406
|
+
if (privacy) {
|
|
407
|
+
if (privacy.sanTtlMs !== undefined) {
|
|
408
|
+
;
|
|
409
|
+
config.sessionTtlMs = privacy.sanTtlMs;
|
|
410
|
+
}
|
|
411
|
+
if (privacy.sanMaxSessions !== undefined) {
|
|
412
|
+
;
|
|
413
|
+
config.maxSessions = privacy.sanMaxSessions;
|
|
414
|
+
}
|
|
415
|
+
if (privacy.sanSweepMs !== undefined) {
|
|
416
|
+
;
|
|
417
|
+
config.sweepIntervalMs = privacy.sanSweepMs;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
const privacyPolicy = ctx.get('privacyPolicy');
|
|
421
|
+
if (!privacyPolicy || typeof privacyPolicy.getSanitizationRules !== 'function') {
|
|
422
|
+
// 启动期 fail-fast:缺了它 sanitize() 会在每次请求时抛错,表现为
|
|
423
|
+
// "每条请求都静默降级到 LOCAL",排查成本极高。宁可起不来。
|
|
424
|
+
throw new Error('sanitizer-pipeline: privacyPolicy service unavailable — check plugin boot order');
|
|
425
|
+
}
|
|
426
|
+
const auditLog = ctx.get('auditLog');
|
|
427
|
+
const service = new SanitizerServiceImpl(ctx, config, privacyPolicy, auditLog);
|
|
428
|
+
return async () => {
|
|
429
|
+
await service.stop();
|
|
430
|
+
};
|
|
431
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* secure-bridge — the single outbound channel for cloud-bound requests.
|
|
3
|
+
* Routes through the OHTTP gateway when reachable, falls back to direct
|
|
4
|
+
* TLS only when explicitly allowed. Cooperates with td-bus for rate
|
|
5
|
+
* limiting and quarantine.
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-edge-agent/secure-bridge
|
|
8
|
+
*/
|
|
9
|
+
import { Service } from '@deepseek-ai/cordis';
|
|
10
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
11
|
+
export declare const name = "secure-bridge";
|
|
12
|
+
export declare const inject: readonly ["tcm", "ohttp", "privacyPolicy", "auditLog"];
|
|
13
|
+
export interface Config {
|
|
14
|
+
mode: 'ohttp' | 'tls' | 'auto';
|
|
15
|
+
domainAllowList: string[];
|
|
16
|
+
domainDenyList: string[];
|
|
17
|
+
requestTimeoutMs: number;
|
|
18
|
+
}
|
|
19
|
+
export declare const Config: Schema<Config>;
|
|
20
|
+
export interface CloudRequest {
|
|
21
|
+
url: string;
|
|
22
|
+
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
23
|
+
headers: Record<string, string>;
|
|
24
|
+
body?: unknown;
|
|
25
|
+
sessionId?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface CloudResponse {
|
|
28
|
+
status: number;
|
|
29
|
+
headers: Record<string, string>;
|
|
30
|
+
body: unknown;
|
|
31
|
+
}
|
|
32
|
+
export interface SecureBridgeService {
|
|
33
|
+
sendCloudRequest(req: CloudRequest): Promise<CloudResponse>;
|
|
34
|
+
isBlocked(): boolean;
|
|
35
|
+
}
|
|
36
|
+
export declare class SecureBridgeServiceImpl extends Service {
|
|
37
|
+
constructor(ctx: any);
|
|
38
|
+
sendCloudRequest(req: CloudRequest): Promise<CloudResponse>;
|
|
39
|
+
isBlocked(): boolean;
|
|
40
|
+
}
|
|
41
|
+
declare module '@deepseek-ai/cordis' {
|
|
42
|
+
interface Context {
|
|
43
|
+
secureBridge: SecureBridgeServiceImpl;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export declare function apply(ctx: any, config: Config): Promise<() => Promise<void>>;
|
|
47
|
+
//# sourceMappingURL=secure-bridge.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"secure-bridge.d.ts","sourceRoot":"","sources":["../../src/dsh-adapter/secure-bridge.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAC7C,OAAO,MAAM,MAAM,0BAA0B,CAAA;AAE7C,eAAO,MAAM,IAAI,kBAAkB,CAAA;AAEnC,eAAO,MAAM,MAAM,wDAAyD,CAAA;AAE5E,MAAM,WAAW,MAAM;IACrB,IAAI,EAAE,OAAO,GAAG,KAAK,GAAG,MAAM,CAAA;IAC9B,eAAe,EAAE,MAAM,EAAE,CAAA;IACzB,cAAc,EAAE,MAAM,EAAE,CAAA;IACxB,gBAAgB,EAAE,MAAM,CAAA;CACzB;AAED,eAAO,MAAM,MAAM,EAAE,MAAM,CAAC,MAAM,CAKhC,CAAA;AAEF,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAA;IACX,MAAM,EAAE,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAA;IACzC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC/B,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC/B,IAAI,EAAE,OAAO,CAAA;CACd;AAED,MAAM,WAAW,mBAAmB;IAClC,gBAAgB,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,aAAa,CAAC,CAAA;IAC3D,SAAS,IAAI,OAAO,CAAA;CACrB;AAED,qBAAa,uBAAwB,SAAQ,OAAO;gBACtC,GAAG,EAAE,GAAG;IAGd,gBAAgB,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,aAAa,CAAC;IAIjE,SAAS,IAAI,OAAO;CAGrB;AAED,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,OAAO;QACf,YAAY,EAAE,uBAAuB,CAAA;KACtC;CACF;AAED,wBAAsB,KAAK,CAAC,GAAG,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CAIlF"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* secure-bridge — the single outbound channel for cloud-bound requests.
|
|
3
|
+
* Routes through the OHTTP gateway when reachable, falls back to direct
|
|
4
|
+
* TLS only when explicitly allowed. Cooperates with td-bus for rate
|
|
5
|
+
* limiting and quarantine.
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-edge-agent/secure-bridge
|
|
8
|
+
*/
|
|
9
|
+
import { Service } from '@deepseek-ai/cordis';
|
|
10
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
11
|
+
export const name = 'secure-bridge';
|
|
12
|
+
export const inject = ['tcm', 'ohttp', 'privacyPolicy', 'auditLog'];
|
|
13
|
+
export const Config = Schema.object({
|
|
14
|
+
mode: Schema.union(['ohttp', 'tls', 'auto']).default('ohttp'),
|
|
15
|
+
domainAllowList: Schema.array(Schema.string()).default([]),
|
|
16
|
+
domainDenyList: Schema.array(Schema.string()).default([]),
|
|
17
|
+
requestTimeoutMs: Schema.natural().default(30000),
|
|
18
|
+
});
|
|
19
|
+
export class SecureBridgeServiceImpl extends Service {
|
|
20
|
+
constructor(ctx) {
|
|
21
|
+
super(ctx, 'secureBridge');
|
|
22
|
+
}
|
|
23
|
+
async sendCloudRequest(req) {
|
|
24
|
+
void req;
|
|
25
|
+
return { status: 200, headers: {}, body: null };
|
|
26
|
+
}
|
|
27
|
+
isBlocked() {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export async function apply(ctx, config) {
|
|
32
|
+
ctx.plugin(SecureBridgeServiceImpl);
|
|
33
|
+
void config;
|
|
34
|
+
return async () => { };
|
|
35
|
+
}
|