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.
- package/README.md +277 -200
- package/dist/cli.js +48 -5
- package/dist/file-tools.d.ts +6 -1
- package/dist/file-tools.js +82 -7
- package/dist/index.d.ts +12 -0
- package/dist/index.js +18 -9
- package/dist/permissions.d.ts +42 -0
- package/dist/permissions.js +170 -0
- package/dist/privacy.d.ts +21 -0
- package/dist/privacy.js +124 -0
- package/package.json +61 -61
package/dist/file-tools.js
CHANGED
|
@@ -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) =>
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 {};
|
package/dist/privacy.js
ADDED
|
@@ -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
|
+
}
|