memory-privacy 1.1.0

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/filters.ts ADDED
@@ -0,0 +1,204 @@
1
+ import type { SessionIdentity } from "./identity.js";
2
+ import {
3
+ extractGroupIdFromPath,
4
+ extractDateFromPath,
5
+ extractKnowledgeBaseId,
6
+ isWithinMembershipPeriod,
7
+ loadKnowledgeACL,
8
+ toRelativeMemoryPath,
9
+ } from "./utils.js";
10
+
11
+ /**
12
+ * 过滤 memory_search 结果,按身份隔离
13
+ */
14
+ export function filterResultsByIdentity(
15
+ results: any,
16
+ identity: SessionIdentity,
17
+ workspaceDir: string
18
+ ): any {
19
+ // results 是 AgentToolResult: { content, details }
20
+ // details 中通常包含 results 数组,每项有 path 字段
21
+ if (!results || !results.details) return results;
22
+
23
+ const details = results.details;
24
+
25
+ // memory_search 返回的 details 可能有 results 数组
26
+ if (Array.isArray(details.results)) {
27
+ const filtered = details.results.filter((r: any) => {
28
+ const rawPath: string = r.path || r.filePath || "";
29
+ const relPath = toRelativeMemoryPath(rawPath, workspaceDir);
30
+ return isPathAllowedRelative(relPath, identity, workspaceDir);
31
+ });
32
+
33
+ return {
34
+ ...results,
35
+ content: results.content.map((c: any) => {
36
+ if (c.type === "text" && typeof c.text === "string") {
37
+ // Rewrite the text content to reflect filtered results
38
+ return {
39
+ ...c,
40
+ text: filtered.length > 0
41
+ ? `Found ${filtered.length} result(s).`
42
+ : "No results found.",
43
+ };
44
+ }
45
+ return c;
46
+ }),
47
+ details: { ...details, results: filtered },
48
+ };
49
+ }
50
+
51
+ // If results is an array directly
52
+ if (Array.isArray(details)) {
53
+ const filtered = details.filter((r: any) => {
54
+ const rawPath: string = r.path || r.filePath || "";
55
+ const relPath = toRelativeMemoryPath(rawPath, workspaceDir);
56
+ return isPathAllowedRelative(relPath, identity, workspaceDir);
57
+ });
58
+
59
+ return {
60
+ ...results,
61
+ details: filtered,
62
+ };
63
+ }
64
+
65
+ return results;
66
+ }
67
+
68
+ /**
69
+ * 检查相对路径是否允许访问(用于 memory_search 过滤和 memory_get 检查)
70
+ */
71
+ function isPathAllowedRelative(
72
+ relPath: string,
73
+ identity: SessionIdentity,
74
+ workspaceDir: string
75
+ ): boolean {
76
+ // 元数据文件永远不返回(包括 owner)
77
+ if (relPath.endsWith("/_members.json") || relPath.endsWith("/_acl.json")) {
78
+ return false;
79
+ }
80
+
81
+ // owner 可以访问 memory/ 下的所有文件(元数据除外,已在上面拦截)
82
+ if (identity.type === "owner") {
83
+ return relPath.startsWith("memory/");
84
+ }
85
+
86
+ // 以下为非 owner(peer / group)的权限判定
87
+
88
+ // owner/ 目录 — 非 owner 一律拒绝
89
+ if (relPath.startsWith("memory/owner/")) {
90
+ return false;
91
+ }
92
+
93
+ // memory/ 根目录下的散落文件(如 memory/2026-03-19.md)— 非 owner 拒绝
94
+ // 合法文件应在 peers/groups/knowledge 子目录中
95
+ const thirdSegment = relPath.split("/")[1] || "";
96
+ if (thirdSegment && !["owner", "peers", "groups", "knowledge"].includes(thirdSegment)) {
97
+ return false;
98
+ }
99
+
100
+ // peers/ 目录
101
+ if (relPath.startsWith("memory/peers/")) {
102
+ if (identity.type === "peer") {
103
+ // OpenClaw 会把 sessionKey 转小写,但目录名保留原始大小写,需忽略大小写
104
+ const lowerPath = relPath.toLowerCase();
105
+ return lowerPath.startsWith(`memory/peers/${identity.peerId.toLowerCase()}/`);
106
+ }
107
+ return false; // group session 不能访问 peers/
108
+ }
109
+
110
+ // groups/ 目录
111
+ if (relPath.startsWith("memory/groups/")) {
112
+ const groupId = extractGroupIdFromPath(relPath);
113
+
114
+ if (identity.type === "group") {
115
+ return groupId.toLowerCase() === identity.groupId.toLowerCase();
116
+ }
117
+
118
+ if (identity.type === "peer") {
119
+ // 私聊中访问群记忆:按 _members.json 时间线过滤
120
+ const fileDate = extractDateFromPath(relPath);
121
+ if (!fileDate) return false;
122
+ return isWithinMembershipPeriod(workspaceDir, groupId, identity.peerId, fileDate);
123
+ }
124
+
125
+ return false;
126
+ }
127
+
128
+ // knowledge/ 目录
129
+ if (relPath.startsWith("memory/knowledge/")) {
130
+ return isKnowledgeAccessAllowed(relPath, identity, workspaceDir);
131
+ }
132
+
133
+ // 未知路径,默认拒绝
134
+ return false;
135
+ }
136
+
137
+ /**
138
+ * 检查路径是否允许访问(支持绝对路径和相对路径)
139
+ */
140
+ export function isPathAllowed(
141
+ filePath: string,
142
+ identity: SessionIdentity,
143
+ workspaceDir: string
144
+ ): boolean {
145
+ const relPath = toRelativeMemoryPath(filePath, workspaceDir);
146
+ return isPathAllowedRelative(relPath, identity, workspaceDir);
147
+ }
148
+
149
+ /**
150
+ * 检查知识库是否允许访问
151
+ */
152
+ function isKnowledgeAccessAllowed(
153
+ relPath: string,
154
+ identity: SessionIdentity,
155
+ workspaceDir: string
156
+ ): boolean {
157
+ const acl = loadKnowledgeACL(workspaceDir);
158
+ const kbId = extractKnowledgeBaseId(relPath);
159
+ const kbAcl = acl[kbId];
160
+
161
+ if (!kbAcl) return false;
162
+ if (kbAcl.rules.public) return true;
163
+
164
+ switch (identity.type) {
165
+ case "owner": return true;
166
+ case "peer": {
167
+ const lp = identity.peerId.toLowerCase();
168
+ return (
169
+ kbAcl.rules.userids?.some((u: string) => u.toLowerCase() === lp) ||
170
+ kbAcl.rules.allowUsers?.some((u: string) => u.toLowerCase() === lp)
171
+ ) ?? false;
172
+ }
173
+ case "group": return kbAcl.rules.allowGroups?.some((g: string) => g.toLowerCase() === identity.groupId.toLowerCase()) ?? false;
174
+ default: return false;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * 根据身份获取写入目标目录(相对路径)
180
+ */
181
+ export function getTargetDir(identity: SessionIdentity): string {
182
+ switch (identity.type) {
183
+ case "owner": return "memory/owner/";
184
+ case "peer": return `memory/peers/${identity.peerId}/`;
185
+ case "group": return `memory/groups/${identity.groupId}/`;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * 检查写入路径是否已经在正确目录中(支持绝对路径和相对路径)
191
+ */
192
+ export function isWritePathCorrect(filePath: string, identity: SessionIdentity): boolean {
193
+ const lowerPath = filePath.toLowerCase();
194
+ switch (identity.type) {
195
+ case "owner":
196
+ return lowerPath.includes("/memory/owner/") || lowerPath.startsWith("memory/owner/");
197
+ case "peer":
198
+ return lowerPath.includes(`/memory/peers/${identity.peerId.toLowerCase()}/`) ||
199
+ lowerPath.startsWith(`memory/peers/${identity.peerId.toLowerCase()}/`);
200
+ case "group":
201
+ return lowerPath.includes(`/memory/groups/${identity.groupId.toLowerCase()}/`) ||
202
+ lowerPath.startsWith(`memory/groups/${identity.groupId.toLowerCase()}/`);
203
+ }
204
+ }
package/identity.ts ADDED
@@ -0,0 +1,46 @@
1
+ export type SessionIdentity =
2
+ | { type: "owner"; userId: string }
3
+ | { type: "peer"; peerId: string }
4
+ | { type: "group"; groupId: string };
5
+
6
+ /**
7
+ * 从 sessionKey 解析会话身份
8
+ *
9
+ * sessionKey 格式(最后一段用 : 分隔的是会话ID):
10
+ * 单聊: agent:{agentId}:palz:direct:{userId}:user_{userID}_lobster_{lobsterID}_release_{releaseName}
11
+ * 群聊: agent:{agentId}:palz:direct:{userId}:user_{userID}_lobster_{lobsterID}_group_{groupID}_release_{releaseName}
12
+ */
13
+ export function resolveSessionIdentity(sessionKey: string | undefined, config: any): SessionIdentity {
14
+ const sk = sessionKey ?? "";
15
+ // 取最后一段(会话ID)
16
+ const sessionPart = sk.split(":").pop() || "";
17
+
18
+ // 1. 群聊:包含 _group_
19
+ const groupMatch = sessionPart.match(/user_(.+?)_lobster_(.+?)_group_(.+?)_release_(.+)$/);
20
+ if (groupMatch) {
21
+ return { type: "group", groupId: groupMatch[3] };
22
+ }
23
+
24
+ // 2. 私聊
25
+ const peerMatch = sessionPart.match(/user_(.+?)_lobster_(.+?)_release_(.+)$/);
26
+ if (peerMatch) {
27
+ const userID = peerMatch[1];
28
+ if (isOwner(userID, config)) return { type: "owner", userId: userID };
29
+ return { type: "peer", peerId: userID };
30
+ }
31
+
32
+ // 3. 兜底:未知 session 按 peer 处理(安全优先)
33
+ return { type: "peer", peerId: "unknown" };
34
+ }
35
+
36
+ /**
37
+ * 判断 userId 是否是主人
38
+ * 对比 openclaw.json 中的 commands.ownerAllowFrom 列表
39
+ */
40
+ export function isOwner(userId: string, config: any): boolean {
41
+ const ownerList: (string | number)[] = config?.commands?.ownerAllowFrom ?? [];
42
+ return ownerList.some(entry => {
43
+ const entryStr = String(entry);
44
+ return entryStr === userId || entryStr === "*";
45
+ });
46
+ }
package/index.ts ADDED
@@ -0,0 +1,504 @@
1
+ import path from "path";
2
+ import fs from "fs";
3
+ import { resolveSessionIdentity } from "./identity.js";
4
+ import { filterResultsByIdentity, isPathAllowed, getTargetDir, isWritePathCorrect } from "./filters.js";
5
+ import { getAllowedDirs, loadAllowedMemoryContent } from "./utils.js";
6
+
7
+ export default {
8
+ id: "memory-privacy",
9
+ name: "Memory Privacy Isolation",
10
+ description: "Multi-user memory isolation for IM + OpenClaw",
11
+
12
+ register(api: any) {
13
+ const config = api.config;
14
+ const workspaceDir: string =
15
+ api.runtime?.config?.agents?.defaults?.workspace ??
16
+ api.config?.agents?.defaults?.workspace ??
17
+ process.env.OPENCLAW_WORKSPACE ??
18
+ process.cwd();
19
+
20
+ api.logger.info(`[memory-privacy] workspaceDir: ${workspaceDir}`);
21
+
22
+ // ====================================================================
23
+ // 钩子 1: before_prompt_build — 用 memory_search 检索相关记忆注入上下文
24
+ // ====================================================================
25
+ api.on("before_prompt_build", async (event: any, ctx: any) => {
26
+ const identity = resolveSessionIdentity(ctx.sessionKey, config);
27
+ const wsDir = ctx.workspaceDir || workspaceDir;
28
+
29
+ // 从 event.messages 提取用户最新消息作为搜索 query
30
+ const messages = event.messages || [];
31
+ const lastUserMsg = [...messages].reverse().find((m: any) => m.role === "user");
32
+ let query = "";
33
+ if (lastUserMsg) {
34
+ const content = lastUserMsg.content;
35
+ if (typeof content === "string") query = content;
36
+ else if (Array.isArray(content)) {
37
+ query = content.filter((c: any) => c.type === "text").map((c: any) => c.text || "").join(" ");
38
+ }
39
+ }
40
+
41
+ let memoryBlock = "";
42
+
43
+ if (query) {
44
+ try {
45
+ // 用内置 memory_search 搜索相关记忆
46
+ const searchTool = api.runtime.tools.createMemorySearchTool({
47
+ config: ctx.config || config,
48
+ agentSessionKey: ctx.sessionKey,
49
+ });
50
+
51
+ if (searchTool) {
52
+ const rawResults = await searchTool.execute("prompt-build-search", { query });
53
+ // 按身份权限过滤结果
54
+ const filtered = filterResultsByIdentity(rawResults, identity, wsDir);
55
+ const details = filtered?.details;
56
+ const results = Array.isArray(details?.results) ? details.results : [];
57
+ const snippets = results
58
+ .map((r: any) => r.snippet || "")
59
+ .filter((s: string) => s.length > 0);
60
+
61
+ if (snippets.length > 0) {
62
+ memoryBlock = `\n\n${snippets.join("\n\n")}`;
63
+ }
64
+ } else {
65
+ // memory_search 不可用,回退到全量加载
66
+ const fallback = loadAllowedMemoryContent(wsDir, identity);
67
+ if (fallback) memoryBlock = `\n\n${fallback}`;
68
+ }
69
+ } catch {
70
+ // 搜索失败,回退到全量加载
71
+ const fallback = loadAllowedMemoryContent(wsDir, identity);
72
+ if (fallback) memoryBlock = `\n\n${fallback}`;
73
+ }
74
+ }
75
+
76
+ switch (identity.type) {
77
+ case "owner":
78
+ return {
79
+ prependContext:
80
+ `[SYSTEM] 你是主人的私人助理,像正常对话一样自然回复。` +
81
+ memoryBlock,
82
+ };
83
+
84
+ case "peer":
85
+ return {
86
+ prependContext:
87
+ `[SYSTEM] 你是一个私人助理,像正常对话一样自然回复。\n` +
88
+ memoryBlock,
89
+ };
90
+
91
+ case "group":
92
+ return {
93
+ prependContext:
94
+ `[SYSTEM] 你是群助理,像正常对话一样自然回复。\n` +
95
+ memoryBlock,
96
+ };
97
+ }
98
+ });
99
+
100
+ // ====================================================================
101
+ // 钩子 2: registerTool — 包装 memory_search / memory_get
102
+ // ====================================================================
103
+ api.registerTool(
104
+ (ctx: any) => {
105
+ const identity = resolveSessionIdentity(ctx.sessionKey, config);
106
+ const wsDir = ctx.workspaceDir || workspaceDir;
107
+
108
+ const originalSearchTool = api.runtime.tools.createMemorySearchTool({
109
+ config: ctx.config || config,
110
+ agentSessionKey: ctx.sessionKey,
111
+ });
112
+
113
+ const originalGetTool = api.runtime.tools.createMemoryGetTool({
114
+ config: ctx.config || config,
115
+ agentSessionKey: ctx.sessionKey,
116
+ });
117
+
118
+ const tools: any[] = [];
119
+
120
+ if (originalSearchTool) {
121
+ const wrappedSearchTool = {
122
+ ...originalSearchTool,
123
+ execute: async (
124
+ toolCallId: string,
125
+ params: Record<string, unknown>,
126
+ signal?: AbortSignal,
127
+ onUpdate?: any
128
+ ) => {
129
+ const results = await originalSearchTool.execute(toolCallId, params, signal, onUpdate);
130
+ return filterResultsByIdentity(results, identity, wsDir);
131
+ },
132
+ };
133
+ tools.push(wrappedSearchTool);
134
+ }
135
+
136
+ if (originalGetTool) {
137
+ const wrappedGetTool = {
138
+ ...originalGetTool,
139
+ execute: async (
140
+ toolCallId: string,
141
+ params: Record<string, unknown>,
142
+ signal?: AbortSignal,
143
+ onUpdate?: any
144
+ ) => {
145
+ const filePath = (params.path || params.filePath || "") as string;
146
+ if (filePath && !isPathAllowed(filePath, identity, wsDir)) {
147
+ return {
148
+ content: [{ type: "text" as const, text: "No results found." }],
149
+ details: { error: "access_denied" },
150
+ };
151
+ }
152
+ return originalGetTool.execute(toolCallId, params, signal, onUpdate);
153
+ },
154
+ };
155
+ tools.push(wrappedGetTool);
156
+ }
157
+
158
+ return tools.length > 0 ? tools : null;
159
+ },
160
+ { names: ["memory_search", "memory_get"] }
161
+ );
162
+
163
+ // ====================================================================
164
+ // 钩子 3: before_tool_call — 写入拦截 + 重定向
165
+ // ====================================================================
166
+ api.on("before_tool_call", (event: any, ctx: any) => {
167
+ const toolName = event.toolName;
168
+
169
+ // 拦截所有写入类工具(file_write / write / file_edit / edit / memory_add)
170
+ const isWriteTool =
171
+ toolName === "file_write" || toolName === "write" ||
172
+ toolName === "file_edit" || toolName === "edit" ||
173
+ toolName === "memory_add";
174
+ if (!isWriteTool) return;
175
+
176
+ const filePath = (event.params?.path || event.params?.filePath || event.params?.file_path || "") as string;
177
+ if (!filePath) return;
178
+
179
+ const identity = resolveSessionIdentity(ctx.sessionKey, config);
180
+ const lowerFilePath = filePath.toLowerCase();
181
+
182
+ // --- MEMORY.md 写入保护(非 owner 阻止)---
183
+ if (lowerFilePath.endsWith("/memory.md") || lowerFilePath.endsWith("\\memory.md") || lowerFilePath === "memory.md") {
184
+ if (identity.type !== "owner") {
185
+ return { block: true, blockReason: "Access denied." };
186
+ }
187
+ return; // owner 可以正常写 MEMORY.md
188
+ }
189
+
190
+ // --- USER.md 写入保护(group session 阻止)---
191
+ if (lowerFilePath.endsWith("/user.md") || lowerFilePath.endsWith("\\user.md") || lowerFilePath === "user.md") {
192
+ if (identity.type === "group") {
193
+ return { block: true, blockReason: "Access denied." };
194
+ }
195
+ return; // owner / peer 可以写 USER.md
196
+ }
197
+
198
+ // --- 以下只处理 memory/ 目录下的写入 ---
199
+ // 检查路径是否涉及 memory/ 目录(绝对路径或相对路径)
200
+ const touchesMemoryDir =
201
+ lowerFilePath.includes("/memory/") ||
202
+ lowerFilePath.startsWith("memory/") ||
203
+ lowerFilePath.startsWith("memory\\");
204
+ if (!touchesMemoryDir) return;
205
+
206
+ // 阻止写入元数据文件
207
+ if (lowerFilePath.endsWith("/_members.json") || lowerFilePath.endsWith("/_acl.json")) {
208
+ return { block: true, blockReason: "Access denied." };
209
+ }
210
+
211
+ // 阻止直接写入 knowledge/
212
+ if (lowerFilePath.includes("/memory/knowledge/") || lowerFilePath.includes("memory/knowledge/")) {
213
+ return { block: true, blockReason: "Access denied." };
214
+ }
215
+
216
+ // 已经在正确目录中的写入不需要重定向
217
+ if (isWritePathCorrect(filePath, identity)) return;
218
+
219
+ // 重定向到正确目录(与 im-sync 的结构一致:memory/peers/{peerId}/xxx)
220
+ const filename = filePath.split("/").pop() || filePath.split("\\").pop() || "";
221
+ const wsDir = ctx.workspaceDir || workspaceDir;
222
+ const targetDir = getTargetDir(identity);
223
+ const correctedPath = path.join(wsDir, targetDir, filename);
224
+
225
+ api.logger.info(`[memory-privacy] Redirecting write: ${filePath} → ${correctedPath}`);
226
+
227
+ // 同时设置所有可能的路径参数名,确保不同工具都能接收
228
+ const correctedParams = { ...event.params };
229
+ if (event.params?.path !== undefined) correctedParams.path = correctedPath;
230
+ if (event.params?.filePath !== undefined) correctedParams.filePath = correctedPath;
231
+ if (event.params?.file_path !== undefined) correctedParams.file_path = correctedPath;
232
+ // 如果原始参数只用了一个名称,也要确保最常用的 path 被设置
233
+ if (!correctedParams.path && !correctedParams.filePath && !correctedParams.file_path) {
234
+ correctedParams.path = correctedPath;
235
+ }
236
+
237
+ return { params: correctedParams };
238
+ }, { priority: 10 });
239
+
240
+ // ====================================================================
241
+ // 钩子 4: before_tool_call — 读取拦截(file_read / read)
242
+ // ====================================================================
243
+ api.on("before_tool_call", (event: any, ctx: any) => {
244
+ if (event.toolName !== "file_read" && event.toolName !== "read") return;
245
+
246
+ const filePath = (event.params?.path || event.params?.filePath || event.params?.file_path || "") as string;
247
+ if (!filePath) return;
248
+
249
+ const wsDir = ctx.workspaceDir || workspaceDir;
250
+ const identity = resolveSessionIdentity(ctx.sessionKey, config);
251
+
252
+ // 拦截 memory/ 路径的读取
253
+ if (filePath.includes("/memory/") || filePath.includes("memory/")) {
254
+ if (!isPathAllowed(filePath, identity, wsDir)) {
255
+ return { block: true, blockReason: "Access denied." };
256
+ }
257
+ }
258
+
259
+ // 拦截 MEMORY.md 的读取(peer/group session 不可读)
260
+ const lowerFilePath = filePath.toLowerCase();
261
+ if (lowerFilePath.endsWith("/memory.md") || lowerFilePath.endsWith("\\memory.md") || lowerFilePath === "memory.md") {
262
+ if (identity.type !== "owner") {
263
+ return { block: true, blockReason: "Access denied." };
264
+ }
265
+ }
266
+
267
+ // 拦截 USER.md 的读取(group session 不可读)
268
+ if (lowerFilePath.endsWith("/user.md") || lowerFilePath.endsWith("\\user.md") || lowerFilePath === "user.md") {
269
+ if (identity.type === "group") {
270
+ return { block: true, blockReason: "Access denied." };
271
+ }
272
+ }
273
+ }, { priority: 10 });
274
+
275
+ // ====================================================================
276
+ // 钩子 5: before_tool_call — exec/process 拦截
277
+ // 思路:不做字符串替换(太容易绕过),而是直接将涉及 memory/workspace 的
278
+ // 命令中的搜索路径替换为 getAllowedDirs 返回的所有有权限目录
279
+ // ====================================================================
280
+ api.on("before_tool_call", (event: any, ctx: any) => {
281
+ if (event.toolName !== "exec" && event.toolName !== "process") return;
282
+
283
+ const identity = resolveSessionIdentity(ctx.sessionKey, config);
284
+ if (identity.type === "owner") return;
285
+
286
+ const command = (event.params?.command || event.params?.cmd || "") as string;
287
+ if (!command) return;
288
+
289
+ // MEMORY.md / USER.md 始终拦截非 owner(大小写不敏感)
290
+ const lowerCommand = command.toLowerCase();
291
+ if (lowerCommand.includes("memory.md") || lowerCommand.includes("user.md")) {
292
+ return { block: true, blockReason: "Access denied." };
293
+ }
294
+
295
+ const memoryDir = path.join(workspaceDir, "memory");
296
+
297
+ // 检测命令是否涉及 memory 目录(绝对路径 + 相对路径)
298
+ const touchesMemory =
299
+ command.includes(memoryDir) ||
300
+ command.includes("/memory/") ||
301
+ command.includes("/memory ") ||
302
+ /(?:^|[\s;|&"'(])memory(?:[/\s]|$)/.test(command);
303
+
304
+ // 检测命令是否涉及 workspace 根目录或当前目录(会递归扫描到 memory)
305
+ // 包括: find /workspace ..., ls ., 以及隐式使用 cwd 的命令(如 bare ls, bare find .)
306
+ const touchesWorkspaceRoot =
307
+ command.includes(workspaceDir + " ") ||
308
+ command.includes(workspaceDir + "/") ||
309
+ command.includes(workspaceDir + "\"") ||
310
+ command.includes(workspaceDir + "'") ||
311
+ command.endsWith(workspaceDir) ||
312
+ /(?:^|[\s;|&"'(])\.(?:[\s/;|&"')]|$)/.test(command) ||
313
+ /^(ls|find|tree|du)(\s+-[^\s]*)*\s*$/.test(command.trim()); // bare ls/find/tree/du (no path arg)
314
+
315
+ if (!touchesMemory && !touchesWorkspaceRoot) return;
316
+
317
+ // 获取用户有权限的所有目录
318
+ const wsDir = ctx.workspaceDir || workspaceDir;
319
+ const allowedDirs = getAllowedDirs(wsDir, identity);
320
+
321
+ if (allowedDirs.length === 0) {
322
+ return { block: true, blockReason: "Access denied." };
323
+ }
324
+
325
+ // 用空格拼接所有允许的目录路径,作为搜索目标
326
+ const allowedPathsStr = allowedDirs.map(d => `"${d}"`).join(" ");
327
+
328
+ // 只执行一种替换,避免多次替换导致路径重复(如 memory/peers/user_b/memory/peers/user_b/)
329
+ let rewritten = command;
330
+ let replaced = false;
331
+
332
+ // 优先替换绝对路径(最精确)
333
+ if (!replaced && command.includes(memoryDir)) {
334
+ // 匹配 memoryDir 及其后面可能跟随的 / 和子路径,整体替换
335
+ const memoryDirEscaped = memoryDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
336
+ rewritten = rewritten.replace(
337
+ new RegExp(memoryDirEscaped + "[^\\s;|&\"']*", "g"),
338
+ allowedPathsStr
339
+ );
340
+ replaced = true;
341
+ }
342
+
343
+ // 替换相对路径引用(memory/ 或 memory 作为独立词)
344
+ if (!replaced && touchesMemory) {
345
+ rewritten = rewritten.replace(
346
+ /(?<=^|[\s;|&"'(])memory[^\s;|&"']*/g,
347
+ allowedPathsStr
348
+ );
349
+ replaced = true;
350
+ }
351
+
352
+ // 替换 workspace 根目录引用 或 隐式 cwd 命令
353
+ if (!replaced && touchesWorkspaceRoot) {
354
+ if (command.includes(workspaceDir)) {
355
+ // 替换绝对 workspace 路径及其后面可能的子路径
356
+ const wsDirEscaped = workspaceDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
357
+ rewritten = rewritten.replace(
358
+ new RegExp(wsDirEscaped + "[^\\s;|&\"']*", "g"),
359
+ allowedPathsStr
360
+ );
361
+ } else if (/(?:^|[\s;|&"'(])\.(?:[\s/;|&"')]|$)/.test(command)) {
362
+ // 替换 "." 当前目录
363
+ rewritten = rewritten.replace(
364
+ /(?<=^|[\s;|&"'(])\.(?=[\s/;|&"')]|$)/g,
365
+ allowedPathsStr
366
+ );
367
+ } else {
368
+ // bare 命令(如 ls -la),追加允许的目录到命令末尾
369
+ rewritten = command.trimEnd() + " " + allowedPathsStr;
370
+ }
371
+ }
372
+
373
+ api.logger.info(`[memory-privacy] Rewriting exec: ${command} → ${rewritten}`);
374
+
375
+ return { params: { ...event.params, command: rewritten, cmd: rewritten } };
376
+ }, { priority: 10 });
377
+
378
+ // ====================================================================
379
+ // 钩子 6: before_tool_call — memory_get 读取拦截
380
+ // registerTool 包装未生效(添加新工具而非替换),用 before_tool_call 可靠拦截
381
+ // ====================================================================
382
+ api.on("before_tool_call", (event: any, ctx: any) => {
383
+ if (event.toolName !== "memory_get") return;
384
+
385
+ const filePath = (event.params?.path || event.params?.filePath || event.params?.file_path || "") as string;
386
+ if (!filePath) return;
387
+
388
+ const identity = resolveSessionIdentity(ctx.sessionKey, config);
389
+ if (identity.type === "owner") return;
390
+
391
+ // 拦截 MEMORY.md(非 owner 不可读)
392
+ const lowerFilePath = filePath.toLowerCase();
393
+ if (lowerFilePath.endsWith("/memory.md") || lowerFilePath.endsWith("\\memory.md") || lowerFilePath === "memory.md") {
394
+ return { block: true, blockReason: "No results found." };
395
+ }
396
+
397
+ // 拦截 USER.md(group session 不可读)
398
+ if (lowerFilePath.endsWith("/user.md") || lowerFilePath.endsWith("\\user.md") || lowerFilePath === "user.md") {
399
+ if (identity.type === "group") {
400
+ return { block: true, blockReason: "No results found." };
401
+ }
402
+ }
403
+
404
+ const wsDir = ctx.workspaceDir || workspaceDir;
405
+ if (!isPathAllowed(filePath, identity, wsDir)) {
406
+ return { block: true, blockReason: "No results found." };
407
+ }
408
+ }, { priority: 10 });
409
+
410
+ // ====================================================================
411
+ // 钩子 7: agent_end — 将 agent 对话同步到 memory 文件
412
+ // ====================================================================
413
+ api.on("agent_end", (event: any, ctx: any) => {
414
+ const identity = resolveSessionIdentity(ctx.sessionKey, config);
415
+ const wsDir = ctx.workspaceDir || workspaceDir;
416
+ const messages = event.messages || [];
417
+
418
+ // 提取用户最新消息
419
+ const lastUserMsg = [...messages].reverse().find((m: any) => m.role === "user");
420
+ // 提取虾的最新回复(只取纯文本,过滤 toolCall)
421
+ const lastAssistantMsg = [...messages].reverse().find((m: any) => m.role === "assistant");
422
+
423
+ const userText = extractText(lastUserMsg);
424
+ const assistantText = extractText(lastAssistantMsg);
425
+ if (!userText && !assistantText) return;
426
+
427
+ // 确定写入路径
428
+ const now = new Date();
429
+ const date = dateStr(now);
430
+ const time = timeStr(now);
431
+ let filePath: string;
432
+ let header: string;
433
+
434
+ switch (identity.type) {
435
+ case "owner":
436
+ filePath = path.join(wsDir, "memory/owner", `${date}.md`);
437
+ header = `# 主人对话\n\n`;
438
+ break;
439
+ case "peer":
440
+ filePath = path.join(wsDir, `memory/peers/${identity.peerId}`, `${date}.md`);
441
+ header = `# 与 ${identity.peerId} 的对话\n\n`;
442
+ break;
443
+ case "group":
444
+ filePath = path.join(wsDir, `memory/groups/${identity.groupId}`, `${date}.md`);
445
+ header = `# 群聊: ${identity.groupId}\n\n`;
446
+ break;
447
+ }
448
+
449
+ // 文件不存在则创建(含目录)
450
+ ensureFileWithHeader(filePath!, header!);
451
+ // 追加写入(格式与 im-sync 一致)
452
+ if (userText) fs.appendFileSync(filePath!, `[${time}] ${getUserId(identity)}: ${userText}\n`);
453
+ if (assistantText) fs.appendFileSync(filePath!, `[${time}] 某只虾: ${assistantText}\n`);
454
+ });
455
+
456
+ api.logger.info("[memory-privacy] Plugin registered successfully.");
457
+ },
458
+ };
459
+
460
+ /** 从消息中提取纯文本(过滤 toolCall) */
461
+ function extractText(msg: any): string {
462
+ if (!msg) return "";
463
+ const content = msg.content;
464
+ if (typeof content === "string") return content.trim();
465
+ if (Array.isArray(content)) {
466
+ return content
467
+ .filter((c: any) => c.type === "text")
468
+ .map((c: any) => c.text || "")
469
+ .join(" ")
470
+ .trim();
471
+ }
472
+ return "";
473
+ }
474
+
475
+ /** 日期字符串 YYYY-MM-DD */
476
+ function dateStr(d: Date): string {
477
+ return d.toISOString().slice(0, 10);
478
+ }
479
+
480
+ /** 时间字符串 HH:MM */
481
+ function timeStr(d: Date): string {
482
+ return d.toTimeString().slice(0, 5);
483
+ }
484
+
485
+ /** 确保文件存在,不存在则创建目录和文件(含 header) */
486
+ function ensureFileWithHeader(filePath: string, header: string): void {
487
+ const dir = path.dirname(filePath);
488
+ if (!fs.existsSync(dir)) {
489
+ fs.mkdirSync(dir, { recursive: true });
490
+ }
491
+ if (!fs.existsSync(filePath)) {
492
+ fs.writeFileSync(filePath, header);
493
+ }
494
+ }
495
+
496
+ /** 根据身份获取用户标识 */
497
+ function getUserId(identity: { type: string; userId?: string; peerId?: string; groupId?: string }): string {
498
+ switch (identity.type) {
499
+ case "owner": return identity.userId || "owner";
500
+ case "peer": return identity.peerId || "unknown";
501
+ case "group": return "user";
502
+ default: return "unknown";
503
+ }
504
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "id": "memory-privacy",
3
+ "name": "Memory Privacy Isolation",
4
+ "description": "Multi-user memory isolation for IM + OpenClaw",
5
+ "configSchema": {
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {}
9
+ }
10
+ }
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "memory-privacy",
3
+ "version": "1.1.0",
4
+ "type": "module",
5
+ "main": "index.ts",
6
+ "files": [
7
+ "*.ts",
8
+ "openclaw.plugin.json",
9
+ "!*.test.ts"
10
+ ],
11
+ "publishConfig": {
12
+ "access": "public",
13
+ "registry": "https://registry.npmjs.org/"
14
+ },
15
+ "openclaw": {
16
+ "extensions": [
17
+ "./index.ts"
18
+ ]
19
+ },
20
+ "devDependencies": {
21
+ "vitest": "^4.1.0"
22
+ }
23
+ }
package/utils.ts ADDED
@@ -0,0 +1,321 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import type { SessionIdentity } from "./identity.js";
4
+
5
+ // ========== 类型定义 ==========
6
+
7
+ export interface MemberPeriod {
8
+ joined: number; // Unix 毫秒时间戳
9
+ left: number | null;
10
+ reason?: string;
11
+ }
12
+
13
+ export interface MemberInfo {
14
+ displayName: string;
15
+ periods: MemberPeriod[];
16
+ }
17
+
18
+ export interface MembersJson {
19
+ groupName?: string;
20
+ members: Record<string, MemberInfo>;
21
+ }
22
+
23
+ export interface KnowledgeBaseRule {
24
+ userids?: string[];
25
+ allowUsers?: string[]; // 兼容旧格式
26
+ allowGroups?: string[];
27
+ public?: boolean;
28
+ }
29
+
30
+ export interface KnowledgeBaseEntry {
31
+ name: string;
32
+ rules: KnowledgeBaseRule;
33
+ }
34
+
35
+ export type KnowledgeACL = Record<string, KnowledgeBaseEntry>;
36
+
37
+ // ========== 路径解析 ==========
38
+
39
+ /**
40
+ * 从路径中提取 groupId
41
+ * "memory/groups/grp_12345/2026-03-17.md" → "grp_12345"
42
+ */
43
+ export function extractGroupIdFromPath(filePath: string): string {
44
+ const parts = filePath.split("/");
45
+ const groupsIndex = parts.indexOf("groups");
46
+ return groupsIndex >= 0 && parts[groupsIndex + 1] ? parts[groupsIndex + 1] : "unknown-group";
47
+ }
48
+
49
+ /**
50
+ * 从路径中提取日期
51
+ * "memory/groups/grp_12345/2026-03-17.md" → "2026-03-17"
52
+ * "memory/knowledge/engineering/api-design.md" → null
53
+ */
54
+ export function extractDateFromPath(filePath: string): string | null {
55
+ const filename = filePath.split("/").pop() || "";
56
+ const match = filename.match(/^(\d{4}-\d{2}-\d{2})\.md$/);
57
+ return match ? match[1] : null;
58
+ }
59
+
60
+ /**
61
+ * 从路径中提取知识库 ID
62
+ * "memory/knowledge/engineering/api-design.md" → "engineering"
63
+ */
64
+ export function extractKnowledgeBaseId(filePath: string): string {
65
+ const parts = filePath.split("/");
66
+ const knowledgeIndex = parts.indexOf("knowledge");
67
+ return knowledgeIndex >= 0 && parts[knowledgeIndex + 1] ? parts[knowledgeIndex + 1] : "unknown-kb";
68
+ }
69
+
70
+ // ========== 文件加载 ==========
71
+
72
+ /**
73
+ * 加载 _members.json
74
+ */
75
+ export function loadMembersRegistry(filePath: string): MembersJson | null {
76
+ try {
77
+ if (fs.existsSync(filePath)) {
78
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
79
+ }
80
+ } catch {
81
+ // ignore parse errors
82
+ }
83
+ return null;
84
+ }
85
+
86
+ /**
87
+ * 加载知识库 ACL(带 mtime 缓存)
88
+ */
89
+ let aclCache: { data: KnowledgeACL; mtime: number } | null = null;
90
+
91
+ export function loadKnowledgeACL(workspaceDir: string): KnowledgeACL {
92
+ const aclPath = path.join(workspaceDir, "memory", "knowledge", "_acl.json");
93
+
94
+ try {
95
+ const stat = fs.statSync(aclPath);
96
+ if (aclCache && aclCache.mtime === stat.mtimeMs) {
97
+ return aclCache.data;
98
+ }
99
+
100
+ const content = fs.readFileSync(aclPath, "utf-8");
101
+ const data = JSON.parse(content) as KnowledgeACL;
102
+ aclCache = { data, mtime: stat.mtimeMs };
103
+ return data;
104
+ } catch {
105
+ return {};
106
+ }
107
+ }
108
+
109
+ // ========== 群成员时间线 ==========
110
+
111
+ /**
112
+ * 检查某个用户在某个日期是否处于群成员期内
113
+ *
114
+ * 规则:fileTimestamp >= joined && (left === null || fileTimestamp < left)
115
+ * fileTimestamp = 文件日期 00:00:00 UTC
116
+ */
117
+ export function isWithinMembershipPeriod(
118
+ workspaceDir: string,
119
+ groupId: string,
120
+ peerId: string,
121
+ fileDate: string // "2026-03-17"
122
+ ): boolean {
123
+ const registryPath = path.join(workspaceDir, "memory", "groups", groupId, "_members.json");
124
+ const registry = loadMembersRegistry(registryPath);
125
+
126
+ if (!registry) return false;
127
+
128
+ // OpenClaw 把 sessionKey 转小写,但 _members.json 里的 key 保留原始大小写
129
+ const lowerPeerId = peerId.toLowerCase();
130
+ const memberKey = Object.keys(registry.members).find(k => k.toLowerCase() === lowerPeerId);
131
+ const member = memberKey ? registry.members[memberKey] : undefined;
132
+ if (!member || !member.periods || member.periods.length === 0) return false;
133
+
134
+ const fileTimestamp = new Date(`${fileDate}T00:00:00Z`).getTime();
135
+
136
+ return member.periods.some((period) => {
137
+ const afterJoin = fileTimestamp >= period.joined;
138
+ const beforeLeft = period.left === null || fileTimestamp < period.left;
139
+ return afterJoin && beforeLeft;
140
+ });
141
+ }
142
+
143
+ /**
144
+ * 将绝对路径转为相对于 workspace 的 memory 路径
145
+ * "/path/to/workspace/memory/owner/2026-03-17.md" → "memory/owner/2026-03-17.md"
146
+ */
147
+ export function toRelativeMemoryPath(absolutePath: string, workspaceDir: string): string {
148
+ const wsPrefix = workspaceDir.endsWith("/") ? workspaceDir : workspaceDir + "/";
149
+ if (absolutePath.startsWith(wsPrefix)) {
150
+ return absolutePath.slice(wsPrefix.length);
151
+ }
152
+ // Try to find memory/ in the path
153
+ const memIdx = absolutePath.indexOf("memory/");
154
+ if (memIdx >= 0) {
155
+ return absolutePath.slice(memIdx);
156
+ }
157
+ return absolutePath;
158
+ }
159
+
160
+ /**
161
+ * 获取某个身份有权限访问的所有 memory 子目录(绝对路径列表)
162
+ *
163
+ * peer: peers/{peerId}/ + _acl.json 中 userids 包含 peerId 的知识库 + _members.json 中当前在群的群组
164
+ * group: groups/{groupId}/ + _acl.json 中 allowGroups 包含 groupId 的知识库
165
+ * owner: memory/ 整个目录
166
+ */
167
+ export function getAllowedDirs(wsDir: string, identity: SessionIdentity): string[] {
168
+ const memDir = path.join(wsDir, "memory");
169
+
170
+ if (identity.type === "owner") {
171
+ return [memDir];
172
+ }
173
+
174
+ const dirs: string[] = [];
175
+
176
+ if (identity.type === "peer") {
177
+ // 1. 自己的 peer 目录
178
+ dirs.push(path.join(memDir, "peers", identity.peerId));
179
+
180
+ // 2. 知识库:_acl.json 中 userids 包含该用户
181
+ const acl = loadKnowledgeACL(wsDir);
182
+ const lowerPeerId = identity.peerId.toLowerCase();
183
+ for (const [kbId, kbEntry] of Object.entries(acl)) {
184
+ const r = kbEntry.rules;
185
+ if (
186
+ r.public ||
187
+ r.userids?.some(u => u.toLowerCase() === lowerPeerId) ||
188
+ r.allowUsers?.some(u => u.toLowerCase() === lowerPeerId)
189
+ ) {
190
+ dirs.push(path.join(memDir, "knowledge", kbId));
191
+ }
192
+ }
193
+
194
+ // 3. 群组:_members.json 中 members 的 key 匹配且 left === null
195
+ const groupsDir = path.join(memDir, "groups");
196
+ try {
197
+ if (fs.existsSync(groupsDir)) {
198
+ for (const groupId of fs.readdirSync(groupsDir)) {
199
+ const groupPath = path.join(groupsDir, groupId);
200
+ if (!fs.statSync(groupPath).isDirectory()) continue;
201
+ const membersPath = path.join(groupPath, "_members.json");
202
+ const registry = loadMembersRegistry(membersPath);
203
+ if (!registry) continue;
204
+
205
+ const memberKey = Object.keys(registry.members)
206
+ .find(k => k.toLowerCase() === lowerPeerId);
207
+ if (!memberKey) continue;
208
+
209
+ const member = registry.members[memberKey];
210
+ const lastPeriod = member.periods?.[member.periods.length - 1];
211
+ if (lastPeriod && lastPeriod.left === null) {
212
+ dirs.push(groupPath);
213
+ }
214
+ }
215
+ }
216
+ } catch { /* ignore fs errors */ }
217
+ }
218
+
219
+ if (identity.type === "group") {
220
+ // 1. 自己的群组目录
221
+ dirs.push(path.join(memDir, "groups", identity.groupId));
222
+
223
+ // 2. 知识库
224
+ const acl = loadKnowledgeACL(wsDir);
225
+ const lowerGroupId = identity.groupId.toLowerCase();
226
+ for (const [kbId, kbEntry] of Object.entries(acl)) {
227
+ const r = kbEntry.rules;
228
+ if (
229
+ r.public ||
230
+ r.allowGroups?.some(g => g.toLowerCase() === lowerGroupId)
231
+ ) {
232
+ dirs.push(path.join(memDir, "knowledge", kbId));
233
+ }
234
+ }
235
+ }
236
+
237
+ return dirs;
238
+ }
239
+
240
+ /**
241
+ * 读取用户有权限的所有 memory 文件内容,拼接为一个字符串
242
+ * 跳过元数据文件(_members.json, _acl.json)
243
+ * maxChars: 最大字符数限制,防止注入过多内容撑爆上下文
244
+ */
245
+ export function loadAllowedMemoryContent(
246
+ wsDir: string,
247
+ identity: SessionIdentity,
248
+ maxChars: number = 50000
249
+ ): string {
250
+ const dirs = getAllowedDirs(wsDir, identity);
251
+ const sections: string[] = [];
252
+ let totalChars = 0;
253
+
254
+ for (const dir of dirs) {
255
+ if (!fs.existsSync(dir)) continue;
256
+
257
+ const stat = fs.statSync(dir);
258
+ if (stat.isFile() && dir.endsWith(".md")) {
259
+ // 单个文件
260
+ const content = readMdFile(dir);
261
+ if (content && totalChars + content.length <= maxChars) {
262
+ sections.push(content);
263
+ totalChars += content.length;
264
+ }
265
+ continue;
266
+ }
267
+
268
+ if (!stat.isDirectory()) continue;
269
+
270
+ // 递归扫描目录下的 .md 文件
271
+ try {
272
+ const files = scanMdFiles(dir);
273
+ for (const filePath of files) {
274
+ if (totalChars >= maxChars) break;
275
+ const content = readMdFile(filePath);
276
+ if (content) {
277
+ const remaining = maxChars - totalChars;
278
+ const trimmed = content.length > remaining ? content.slice(0, remaining) + "\n...(truncated)" : content;
279
+ sections.push(trimmed);
280
+ totalChars += trimmed.length;
281
+ }
282
+ }
283
+ } catch { /* ignore fs errors */ }
284
+ }
285
+
286
+ return sections.join("\n\n");
287
+ }
288
+
289
+ /**
290
+ * 递归扫描目录下的所有 .md 文件(排除元数据文件)
291
+ */
292
+ function scanMdFiles(dir: string): string[] {
293
+ const results: string[] = [];
294
+ try {
295
+ for (const entry of fs.readdirSync(dir)) {
296
+ // 跳过元数据文件
297
+ if (entry === "_members.json" || entry === "_acl.json") continue;
298
+
299
+ const fullPath = path.join(dir, entry);
300
+ const stat = fs.statSync(fullPath);
301
+ if (stat.isDirectory()) {
302
+ results.push(...scanMdFiles(fullPath));
303
+ } else if (entry.endsWith(".md")) {
304
+ results.push(fullPath);
305
+ }
306
+ }
307
+ } catch { /* ignore */ }
308
+ return results;
309
+ }
310
+
311
+ /**
312
+ * 读取单个 md 文件,返回纯内容(不暴露路径)
313
+ */
314
+ function readMdFile(filePath: string): string | null {
315
+ try {
316
+ const content = fs.readFileSync(filePath, "utf-8").trim();
317
+ return content || null;
318
+ } catch {
319
+ return null;
320
+ }
321
+ }