deepccc 0.1.1 → 0.1.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.
@@ -6,6 +6,7 @@ import { createRequire } from "node:module";
6
6
  import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
7
7
  import { createInterface } from "node:readline";
8
8
  import { jsonSchema, tool } from "ai";
9
+ import { isDangerousCommand } from "./permissions.js";
9
10
  import { killProcessTree } from "./proc-tree-kill.js";
10
11
  const MAX_READ_BYTES = 1024 * 1024;
11
12
  const MAX_LIST_ENTRIES = 200;
@@ -919,7 +920,29 @@ export async function applyPatchForTool(cwd, input) {
919
920
  }
920
921
  return { changedFiles };
921
922
  }
922
- export function createBuiltinFileTools(cwd) {
923
+ export function createBuiltinFileTools(cwd, options = {}) {
924
+ const gate = options.permissionGate;
925
+ /** 文件路径的候选匹配键:原始路径 + 绝对路径 + 相对 cwd 路径(正/反斜杠双版本,规则可任选其一) */
926
+ const pathKeys = (p) => {
927
+ const abs = resolveToolPath(cwd, p);
928
+ const rel = relative(cwd, abs);
929
+ return [
930
+ p,
931
+ abs,
932
+ rel,
933
+ rel.split(sep).join("/"),
934
+ abs.split(sep).join("/"),
935
+ ];
936
+ };
937
+ /** 副作用工具执行前的权限守卫:gate 拒绝时抛错,工具不会真正执行 */
938
+ const guard = async (request) => {
939
+ if (!gate)
940
+ return;
941
+ const decision = await gate.check(request);
942
+ if (decision === "deny") {
943
+ throw new Error(`权限拒绝:${request.detail}(已按规则或用户选择拦截;如需放行请调整 ~/.deepccc/allow.json,或使用 --dangerously-bypass-permissions)`);
944
+ }
945
+ };
923
946
  return {
924
947
  read_file: tool({
925
948
  description: "Read a UTF-8 text file from the local filesystem. Use line ranges for large files.",
@@ -973,7 +996,15 @@ export function createBuiltinFileTools(cwd) {
973
996
  },
974
997
  required: ["command"],
975
998
  }),
976
- execute: (input, options) => runCommandForTool(cwd, input, options.abortSignal),
999
+ execute: async (input, options) => {
1000
+ await guard({
1001
+ tool: "run_command",
1002
+ action: input.command,
1003
+ reason: isDangerousCommand(input.command) ? "high-risk" : "rule",
1004
+ detail: `运行命令: ${input.command}`,
1005
+ });
1006
+ return runCommandForTool(cwd, input, options.abortSignal);
1007
+ },
977
1008
  }),
978
1009
  edit_file: tool({
979
1010
  description: "Edit an existing UTF-8 text file by applying exact oldText -> newText replacements. Uses optional SHA-256 precondition to avoid overwriting concurrent edits.",
@@ -1000,7 +1031,16 @@ export function createBuiltinFileTools(cwd) {
1000
1031
  },
1001
1032
  required: ["path", "edits"],
1002
1033
  }),
1003
- execute: (input) => editFileForTool(cwd, input),
1034
+ execute: async (input) => {
1035
+ await guard({
1036
+ tool: "edit_file",
1037
+ action: input.path,
1038
+ altKeys: pathKeys(input.path),
1039
+ reason: "rule",
1040
+ detail: `编辑文件: ${input.path}`,
1041
+ });
1042
+ return editFileForTool(cwd, input);
1043
+ },
1004
1044
  }),
1005
1045
  create_file: tool({
1006
1046
  description: "Create a UTF-8 text file, or overwrite an existing one when overwrite=true.",
@@ -1015,7 +1055,16 @@ export function createBuiltinFileTools(cwd) {
1015
1055
  },
1016
1056
  required: ["path", "content"],
1017
1057
  }),
1018
- execute: (input) => createFileForTool(cwd, input),
1058
+ execute: async (input) => {
1059
+ await guard({
1060
+ tool: "create_file",
1061
+ action: input.path,
1062
+ altKeys: pathKeys(input.path),
1063
+ reason: "rule",
1064
+ detail: `创建/覆盖文件: ${input.path}`,
1065
+ });
1066
+ return createFileForTool(cwd, input);
1067
+ },
1019
1068
  }),
1020
1069
  delete_file: tool({
1021
1070
  description: "Delete an existing text file. Use expectedSha256 to avoid deleting a file that changed after reading.",
@@ -1028,7 +1077,16 @@ export function createBuiltinFileTools(cwd) {
1028
1077
  },
1029
1078
  required: ["path"],
1030
1079
  }),
1031
- execute: (input) => deleteFileForTool(cwd, input),
1080
+ execute: async (input) => {
1081
+ await guard({
1082
+ tool: "delete_file",
1083
+ action: input.path,
1084
+ altKeys: pathKeys(input.path),
1085
+ reason: "rule",
1086
+ detail: `删除文件: ${input.path}`,
1087
+ });
1088
+ return deleteFileForTool(cwd, input);
1089
+ },
1032
1090
  }),
1033
1091
  move_file: tool({
1034
1092
  description: "Move or rename an existing text file. Can overwrite an existing destination only when overwrite=true.",
@@ -1044,7 +1102,16 @@ export function createBuiltinFileTools(cwd) {
1044
1102
  },
1045
1103
  required: ["sourcePath", "destinationPath"],
1046
1104
  }),
1047
- execute: (input) => moveFileForTool(cwd, input),
1105
+ execute: async (input) => {
1106
+ await guard({
1107
+ tool: "move_file",
1108
+ action: input.sourcePath,
1109
+ altKeys: pathKeys(input.sourcePath),
1110
+ reason: "rule",
1111
+ detail: `移动/重命名文件: ${input.sourcePath} → ${input.destinationPath}`,
1112
+ });
1113
+ return moveFileForTool(cwd, input);
1114
+ },
1048
1115
  }),
1049
1116
  apply_patch: tool({
1050
1117
  description: "Apply a unified diff patch to one or more UTF-8 text files. Prefer edit_file for small targeted edits.",
@@ -1061,7 +1128,15 @@ export function createBuiltinFileTools(cwd) {
1061
1128
  },
1062
1129
  required: ["patch"],
1063
1130
  }),
1064
- execute: (input) => applyPatchForTool(cwd, input),
1131
+ execute: async (input) => {
1132
+ await guard({
1133
+ tool: "apply_patch",
1134
+ action: "patch",
1135
+ reason: "rule",
1136
+ detail: "应用 unified diff patch",
1137
+ });
1138
+ return applyPatchForTool(cwd, input);
1139
+ },
1065
1140
  }),
1066
1141
  };
1067
1142
  }
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * ChatSession 是程序化入口,既可以被 CLI 调用,也可以被其他模块调用。
5
5
  */
6
+ import { type PermissionMode, type PermissionResolver } from "./permissions.js";
6
7
  export interface ChatSessionConfig {
7
8
  /** OpenAI-compatible service base URL. Defaults to DEEPCCC_BASE_URL/config. */
8
9
  baseURL?: string;
@@ -38,6 +39,16 @@ export interface ChatSessionOptions {
38
39
  * Defaults to ~/.codex/skills, ~/.agents/skills, <cwd>/.codex/skills.
39
40
  */
40
41
  skillsDirs?: string[];
42
+ /**
43
+ * 权限模式:ask(默认,高危命令询问)/ bypass(全部放行,等价
44
+ * --dangerously-bypass-permissions;chatccc 等无终端环境集成时使用)。
45
+ */
46
+ permissionMode?: PermissionMode;
47
+ /**
48
+ * ask 模式下高危操作的交互确认回调;缺省时非交互环境(JSONL / 程序化
49
+ * 调用)自动拒绝高危命令,常规文件操作与低危命令不受影响。
50
+ */
51
+ permissionResolver?: PermissionResolver;
41
52
  }
42
53
  /**
43
54
  * 流式响应事件
@@ -81,6 +92,7 @@ export declare class ChatSession {
81
92
  private context;
82
93
  private maxSteps?;
83
94
  private effort;
95
+ private permissionGate;
84
96
  constructor(overrides?: ChatSessionConfig, options?: ChatSessionOptions);
85
97
  chat(userMessage: string, signal?: AbortSignal): AsyncIterable<ChatEvent>;
86
98
  /** 返回当前的会话历史(只读) */
package/dist/index.js CHANGED
@@ -11,7 +11,9 @@ import { config as appConfig, RAW_STREAM_LOGS_DIR } from "./config.js";
11
11
  import { createRawStreamLog, } from "./raw-stream-log.js";
12
12
  import { BuiltinContextManager, buildSummaryPrompt, defaultBuiltinSessionId, } from "./context.js";
13
13
  import { createBuiltinFileTools } from "./file-tools.js";
14
+ import { PermissionGate } from "./permissions.js";
14
15
  import { buildDefaultSkillDirs, buildSkillsIndexPrompt, scanSkillsDirs } from "./skills.js";
16
+ import { applyPrivacy, applyPrivacyToJson } from "./privacy.js";
15
17
  // ---------------------------------------------------------------------------
16
18
  // 系统提示词 — 编译期冻结常量(DeepCCC 英文品牌)
17
19
  // ---------------------------------------------------------------------------
@@ -85,6 +87,7 @@ export class ChatSession {
85
87
  context;
86
88
  maxSteps;
87
89
  effort;
90
+ permissionGate;
88
91
  constructor(overrides = {}, options = {}) {
89
92
  const apiKey = overrides.apiKey ?? appConfig.apiKey;
90
93
  if (!apiKey) {
@@ -126,10 +129,12 @@ export class ChatSession {
126
129
  compactAtTokens: options.compactAtTokens,
127
130
  keepRecentMessages: options.keepRecentMessages,
128
131
  });
132
+ this.permissionGate = new PermissionGate(options.permissionMode ?? "ask", options.permissionResolver);
129
133
  }
130
134
  async *chat(userMessage, signal) {
131
135
  this.context.appendMessage({ role: "user", content: userMessage });
132
136
  let fullText = "";
137
+ let safeAccumulated = "";
133
138
  let rawLog = null;
134
139
  let completed = false;
135
140
  try {
@@ -158,7 +163,7 @@ export class ChatSession {
158
163
  model: this.model,
159
164
  system: this.systemPrompt,
160
165
  messages: this.context.buildModelMessages(),
161
- tools: createBuiltinFileTools(this.cwd),
166
+ tools: createBuiltinFileTools(this.cwd, { permissionGate: this.permissionGate }),
162
167
  stopWhen: maxSteps !== undefined ? stepCountIs(maxSteps) : isLoopFinished(),
163
168
  abortSignal: signal,
164
169
  // DeepSeek OpenAI 兼容接口:providerOptions.deepseek.reasoningEffort
@@ -170,7 +175,11 @@ export class ChatSession {
170
175
  rawLog?.writeLine(safeRawStreamJson(part));
171
176
  if (part.type === "text-delta") {
172
177
  fullText += part.text;
173
- yield { type: "text", text: part.text, accumulated: fullText };
178
+ // 隐私替换只在展示层:safeAccumulated 供事件消费者(终端/JSONL)使用,
179
+ // fullText 原文用于持久化上下文,避免替换结果回流污染上下文。
180
+ const safeText = applyPrivacy(part.text);
181
+ safeAccumulated += safeText;
182
+ yield { type: "text", text: safeText, accumulated: safeAccumulated };
174
183
  }
175
184
  else if (part.type === "tool-call") {
176
185
  toolContext.push(`tool_call ${part.toolName}: ${safeJson(part.input)}`);
@@ -178,7 +187,7 @@ export class ChatSession {
178
187
  type: "tool_use",
179
188
  id: part.toolCallId,
180
189
  name: part.toolName,
181
- input: part.input,
190
+ input: applyPrivacyToJson(part.input),
182
191
  };
183
192
  }
184
193
  else if (part.type === "tool-result") {
@@ -187,7 +196,7 @@ export class ChatSession {
187
196
  type: "tool_result",
188
197
  tool_use_id: part.toolCallId,
189
198
  name: part.toolName,
190
- content: part.output,
199
+ content: applyPrivacyToJson(part.output),
191
200
  is_error: false,
192
201
  };
193
202
  }
@@ -198,13 +207,13 @@ export class ChatSession {
198
207
  type: "tool_result",
199
208
  tool_use_id: part.toolCallId,
200
209
  name: part.toolName,
201
- content: message,
210
+ content: applyPrivacy(message),
202
211
  is_error: true,
203
212
  };
204
213
  }
205
214
  else if (part.type === "error") {
206
215
  const message = errorMessage(part.error);
207
- yield { type: "error", message };
216
+ yield { type: "error", message: applyPrivacy(message) };
208
217
  throw new Error(message);
209
218
  }
210
219
  }
@@ -213,7 +222,7 @@ export class ChatSession {
213
222
  ? `${fullText}\n\n[Tool transcript]\n${toolContext.join("\n")}`
214
223
  : fullText;
215
224
  this.context.appendMessage({ role: "assistant", content: persistedText });
216
- yield { type: "done", text: fullText };
225
+ yield { type: "done", text: safeAccumulated };
217
226
  }
218
227
  catch (err) {
219
228
  const message = err instanceof Error ? err.message : String(err);
@@ -222,10 +231,10 @@ export class ChatSession {
222
231
  if (fullText) {
223
232
  this.context.appendMessage({ role: "assistant", content: `${fullText}\n[interrupted]` });
224
233
  }
225
- yield { type: "done", text: fullText };
234
+ yield { type: "done", text: safeAccumulated };
226
235
  return;
227
236
  }
228
- yield { type: "error", message };
237
+ yield { type: "error", message: applyPrivacy(message) };
229
238
  throw err;
230
239
  }
231
240
  finally {
@@ -0,0 +1,42 @@
1
+ export type PermissionMode = "ask" | "bypass";
2
+ export type PermissionReason = "high-risk" | "rule";
3
+ export type PermissionAnswer = "allow" | "allow-always" | "allow-session" | "deny";
4
+ export type PermissionDecision = "allow" | "deny";
5
+ export interface PermissionRequest {
6
+ /** 工具名:run_command / edit_file / create_file / delete_file / move_file / apply_patch */
7
+ tool: string;
8
+ /** 具体操作:命令全文或文件路径 */
9
+ action: string;
10
+ /** high-risk = 命中内置危险命令库,需要询问;rule = 仅受 allow/deny 规则管控 */
11
+ reason: PermissionReason;
12
+ /** 展示给用户的说明 */
13
+ detail: string;
14
+ /** 额外匹配键(如文件相对路径),allow/deny 规则命中任一即生效 */
15
+ altKeys?: string[];
16
+ }
17
+ /** ask 模式下高危操作的交互确认回调(由 CLI 注入 readline 实现) */
18
+ export type PermissionResolver = (request: PermissionRequest) => Promise<PermissionAnswer>;
19
+ export interface AllowRules {
20
+ /** 永远允许的规则,格式 "<tool>:<pattern>" 或 "*:<pattern>",pattern 中 * 为通配符 */
21
+ allow: string[];
22
+ /** 永远拒绝的规则,格式同上 */
23
+ deny: string[];
24
+ }
25
+ export declare function isDangerousCommand(command: string): boolean;
26
+ export declare function getAllowRules(): AllowRules;
27
+ export declare function reloadAllowRules(): void;
28
+ /** 把"永远允许"的规则追加写入 allow.json 并热加载 */
29
+ export declare function appendAllowRule(rule: string): void;
30
+ /**
31
+ * 规则匹配。规则格式 "<tool>:<pattern>" 或 "*:<pattern>";
32
+ * pattern 中的 * 为通配符,其余字符按字面量匹配。
33
+ */
34
+ export declare function matchRule(rule: string, key: string): boolean;
35
+ export declare class PermissionGate {
36
+ private readonly mode;
37
+ private readonly resolver?;
38
+ private sessionAllowAll;
39
+ constructor(mode?: PermissionMode, resolver?: PermissionResolver);
40
+ getMode(): PermissionMode;
41
+ check(request: PermissionRequest): Promise<PermissionDecision>;
42
+ }
@@ -0,0 +1,170 @@
1
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { DEEPCCC_HOME } from "./config.js";
4
+ const RULES_FILE = join(DEEPCCC_HOME, "allow.json");
5
+ const EMPTY_RULES = { allow: [], deny: [] };
6
+ /**
7
+ * 内置危险命令库(命中即进入询问流程;allow 规则可覆盖)。
8
+ * 覆盖 Windows 与 Unix 常见破坏性操作:强制删除、强制推送、磁盘/分区、
9
+ * 关机重启、设备覆写、数据库 DROP、fork bomb、全局卸载、发布等。
10
+ */
11
+ const DANGEROUS_COMMAND_PATTERNS = [
12
+ /^\s*rm\s+-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*\b/i, // rm -rf / rm -fr
13
+ /^\s*rm\s+-[a-zA-Z]*f[a-zA-Z]*r[a-zA-Z]*\b/i,
14
+ /^\s*rmdir\s+\/s/i, // Windows rmdir /s
15
+ /^\s*(del|erase)\s+\/s/i, // Windows del /s
16
+ /^\s*git\s+(push|fetch|pull)\s+.*(--force|-f\b)/i, // git push --force
17
+ /^\s*git\s+reset\s+--hard/i,
18
+ /^\s*git\s+clean\s+(-[a-z]*f)/i,
19
+ /^\s*format\s+[a-z]:/i,
20
+ /^\s*diskpart\b/i,
21
+ /^\s*(shutdown|reboot)\b/i,
22
+ /^\s*mkfs\b/i,
23
+ /^\s*fdisk\b/i,
24
+ /^\s*dd\s+.*\bof=/i,
25
+ /^\s*sudo\s+(rm|shutdown|mkfs|dd|fdisk|diskpart)/i,
26
+ /^\s*Remove-Item\s+-Recurse/i,
27
+ /^\s*:\(\)\s*\{.*\}\s*;/i, // fork bomb
28
+ /^\s*(drop|truncate)\s+table/i,
29
+ /^\s*npm\s+(publish|unpublish)\b/i,
30
+ /^\s*npm\s+uninstall\s+-g/i,
31
+ /^\s*pip\s+uninstall\b/i,
32
+ ];
33
+ export function isDangerousCommand(command) {
34
+ return DANGEROUS_COMMAND_PATTERNS.some((re) => re.test(command));
35
+ }
36
+ // ---------------------------------------------------------------------------
37
+ // 规则加载(~/.deepccc/allow.json,热加载)
38
+ // ---------------------------------------------------------------------------
39
+ let rulesCache = null;
40
+ let rulesStamp;
41
+ function rulesFileStamp() {
42
+ if (!existsSync(RULES_FILE))
43
+ return null;
44
+ try {
45
+ const s = statSync(RULES_FILE);
46
+ return `${s.mtimeMs}:${s.size}`;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ function sanitizeRuleList(raw) {
53
+ if (!Array.isArray(raw))
54
+ return [];
55
+ return raw.filter((item) => typeof item === "string" && item.trim().length > 0);
56
+ }
57
+ function loadRules() {
58
+ if (!existsSync(RULES_FILE))
59
+ return EMPTY_RULES;
60
+ try {
61
+ const raw = readFileSync(RULES_FILE, "utf-8").replace(/^\uFEFF/, "");
62
+ const parsed = JSON.parse(raw);
63
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
64
+ return EMPTY_RULES;
65
+ return {
66
+ allow: sanitizeRuleList(parsed.allow),
67
+ deny: sanitizeRuleList(parsed.deny),
68
+ };
69
+ }
70
+ catch (err) {
71
+ console.error(`[PERMISSIONS] failed to read allow.json: ${err.message}`);
72
+ return EMPTY_RULES;
73
+ }
74
+ }
75
+ export function getAllowRules() {
76
+ const stamp = rulesFileStamp();
77
+ if (!rulesCache || stamp !== rulesStamp) {
78
+ rulesCache = loadRules();
79
+ rulesStamp = stamp;
80
+ }
81
+ return rulesCache;
82
+ }
83
+ export function reloadAllowRules() {
84
+ rulesCache = null;
85
+ rulesStamp = undefined;
86
+ }
87
+ /** 把"永远允许"的规则追加写入 allow.json 并热加载 */
88
+ export function appendAllowRule(rule) {
89
+ const rules = getAllowRules();
90
+ if (rules.allow.includes(rule))
91
+ return;
92
+ const next = { allow: [...rules.allow, rule], deny: rules.deny };
93
+ try {
94
+ mkdirSync(dirname(RULES_FILE), { recursive: true });
95
+ writeFileSync(RULES_FILE, `${JSON.stringify(next, null, 2)}\n`, "utf-8");
96
+ reloadAllowRules();
97
+ }
98
+ catch (err) {
99
+ console.error(`[PERMISSIONS] failed to write allow.json: ${err.message}`);
100
+ }
101
+ }
102
+ function escapeRegex(s) {
103
+ return s.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
104
+ }
105
+ /**
106
+ * 规则匹配。规则格式 "<tool>:<pattern>" 或 "*:<pattern>";
107
+ * pattern 中的 * 为通配符,其余字符按字面量匹配。
108
+ */
109
+ export function matchRule(rule, key) {
110
+ const ruleColon = rule.indexOf(":");
111
+ if (ruleColon === -1)
112
+ return false;
113
+ const toolPart = rule.slice(0, ruleColon);
114
+ const pattern = rule.slice(ruleColon + 1);
115
+ const keyColon = key.indexOf(":");
116
+ if (keyColon === -1)
117
+ return false;
118
+ const keyTool = key.slice(0, keyColon);
119
+ const keyAction = key.slice(keyColon + 1);
120
+ if (toolPart !== "*" && toolPart !== keyTool)
121
+ return false;
122
+ const re = new RegExp(`^${pattern.split("*").map(escapeRegex).join(".*")}$`);
123
+ return re.test(keyAction);
124
+ }
125
+ // ---------------------------------------------------------------------------
126
+ // PermissionGate
127
+ // ---------------------------------------------------------------------------
128
+ export class PermissionGate {
129
+ mode;
130
+ resolver;
131
+ sessionAllowAll = false;
132
+ constructor(mode = "ask", resolver) {
133
+ this.mode = mode;
134
+ this.resolver = resolver;
135
+ }
136
+ getMode() {
137
+ return this.mode;
138
+ }
139
+ async check(request) {
140
+ if (this.mode === "bypass" || this.sessionAllowAll)
141
+ return "allow";
142
+ const key = `${request.tool}:${request.action}`;
143
+ const keys = [key, ...(request.altKeys ?? []).map((k) => `${request.tool}:${k}`)];
144
+ const rules = getAllowRules();
145
+ if (rules.deny.some((r) => keys.some((k) => matchRule(r, k))))
146
+ return "deny";
147
+ if (rules.allow.some((r) => keys.some((k) => matchRule(r, k))))
148
+ return "allow";
149
+ // 只有命中内置危险命令库的 run_command 才询问;文件编辑等常规操作默认放行
150
+ if (request.reason !== "high-risk")
151
+ return "allow";
152
+ // 高危且无交互能力(非 TTY / JSONL / 程序化调用)→ 安全默认拒绝
153
+ if (!this.resolver)
154
+ return "deny";
155
+ const answer = await this.resolver(request);
156
+ switch (answer) {
157
+ case "allow":
158
+ return "allow";
159
+ case "allow-session":
160
+ this.sessionAllowAll = true;
161
+ return "allow";
162
+ case "allow-always":
163
+ appendAllowRule(key);
164
+ return "allow";
165
+ case "deny":
166
+ default:
167
+ return "deny";
168
+ }
169
+ }
170
+ }
@@ -0,0 +1,21 @@
1
+ interface PrivacyRules {
2
+ [key: string]: string;
3
+ }
4
+ interface PrivacyConfig {
5
+ enabled: boolean;
6
+ rules: PrivacyRules;
7
+ }
8
+ export declare function getPrivacyConfig(): PrivacyConfig;
9
+ export declare function getPrivacyRules(): PrivacyRules;
10
+ export declare function reloadPrivacyRules(): void;
11
+ /**
12
+ * 把文本中的隐私规则字面量替换为掩码值(split+join,规则键保持字面量)。
13
+ * 仅作用于展示层:调用方应保持内部数据(如持久化上下文)为原文。
14
+ */
15
+ export declare function applyPrivacy(text: string): string;
16
+ /**
17
+ * 递归替换结构化数据(工具调用参数/结果)中的字符串字段。
18
+ * 数组逐项、对象逐值、字符串走 applyPrivacy,其余类型原样返回。
19
+ */
20
+ export declare function applyPrivacyToJson(value: unknown): unknown;
21
+ export {};
@@ -0,0 +1,124 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { DEEPCCC_HOME } from "./config.js";
4
+ let config = null;
5
+ let loadedStamp;
6
+ function privacyFilePath() {
7
+ return join(DEEPCCC_HOME, "privacy.json");
8
+ }
9
+ function privacyFileStamp() {
10
+ const filePath = privacyFilePath();
11
+ if (!existsSync(filePath))
12
+ return null;
13
+ try {
14
+ const s = statSync(filePath);
15
+ return `${s.mtimeMs}:${s.size}`;
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ function sanitizeRules(raw) {
22
+ const result = {};
23
+ for (const [from, to] of Object.entries(raw)) {
24
+ if (!from) {
25
+ console.error("[PRIVACY] privacy.json rule key cannot be empty, skipped");
26
+ continue;
27
+ }
28
+ if (typeof to !== "string") {
29
+ console.error(`[PRIVACY] privacy.json value must be a string, skipped "${from}"`);
30
+ continue;
31
+ }
32
+ result[from] = to;
33
+ }
34
+ return result;
35
+ }
36
+ function normalizeConfig(parsed) {
37
+ const hasNewSchema = Object.prototype.hasOwnProperty.call(parsed, "enabled") ||
38
+ Object.prototype.hasOwnProperty.call(parsed, "rules");
39
+ if (!hasNewSchema) {
40
+ return { enabled: true, rules: sanitizeRules(parsed) };
41
+ }
42
+ const enabledRaw = parsed.enabled;
43
+ const enabled = enabledRaw === undefined ? true : enabledRaw !== false;
44
+ if (enabledRaw !== undefined && typeof enabledRaw !== "boolean") {
45
+ console.error("[PRIVACY] privacy.json enabled must be a boolean, using true");
46
+ }
47
+ const rulesRaw = parsed.rules;
48
+ if (rulesRaw === undefined)
49
+ return { enabled, rules: {} };
50
+ if (typeof rulesRaw !== "object" || rulesRaw === null || Array.isArray(rulesRaw)) {
51
+ console.error("[PRIVACY] privacy.json rules must be an object");
52
+ return { enabled, rules: {} };
53
+ }
54
+ return { enabled, rules: sanitizeRules(rulesRaw) };
55
+ }
56
+ function loadConfig() {
57
+ const filePath = privacyFilePath();
58
+ if (!existsSync(filePath)) {
59
+ return { enabled: true, rules: {} };
60
+ }
61
+ try {
62
+ const raw = readFileSync(filePath, "utf-8").replace(/^\uFEFF/, "");
63
+ const parsed = JSON.parse(raw);
64
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
65
+ console.error("[PRIVACY] privacy.json must be an object");
66
+ return { enabled: true, rules: {} };
67
+ }
68
+ return normalizeConfig(parsed);
69
+ }
70
+ catch (err) {
71
+ console.error(`[PRIVACY] failed to read privacy.json: ${err.message}`);
72
+ return { enabled: true, rules: {} };
73
+ }
74
+ }
75
+ export function getPrivacyConfig() {
76
+ const stamp = privacyFileStamp();
77
+ if (!config || stamp !== loadedStamp) {
78
+ config = loadConfig();
79
+ loadedStamp = stamp;
80
+ }
81
+ return config;
82
+ }
83
+ export function getPrivacyRules() {
84
+ return getPrivacyConfig().rules;
85
+ }
86
+ export function reloadPrivacyRules() {
87
+ config = null;
88
+ loadedStamp = undefined;
89
+ }
90
+ /**
91
+ * 把文本中的隐私规则字面量替换为掩码值(split+join,规则键保持字面量)。
92
+ * 仅作用于展示层:调用方应保持内部数据(如持久化上下文)为原文。
93
+ */
94
+ export function applyPrivacy(text) {
95
+ const { enabled, rules } = getPrivacyConfig();
96
+ if (!enabled || Object.keys(rules).length === 0 || !text)
97
+ return text;
98
+ let result = text;
99
+ for (const [from, to] of Object.entries(rules)) {
100
+ result = result.split(from).join(to);
101
+ }
102
+ return result;
103
+ }
104
+ /**
105
+ * 递归替换结构化数据(工具调用参数/结果)中的字符串字段。
106
+ * 数组逐项、对象逐值、字符串走 applyPrivacy,其余类型原样返回。
107
+ */
108
+ export function applyPrivacyToJson(value) {
109
+ const { enabled, rules } = getPrivacyConfig();
110
+ if (!enabled || Object.keys(rules).length === 0)
111
+ return value;
112
+ if (typeof value === "string")
113
+ return applyPrivacy(value);
114
+ if (Array.isArray(value))
115
+ return value.map((item) => applyPrivacyToJson(item));
116
+ if (value !== null && typeof value === "object") {
117
+ const out = {};
118
+ for (const [key, item] of Object.entries(value)) {
119
+ out[key] = applyPrivacyToJson(item);
120
+ }
121
+ return out;
122
+ }
123
+ return value;
124
+ }