memory-get-acl 2026.3.31

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 ADDED
@@ -0,0 +1,52 @@
1
+ # memory-get-acl
2
+
3
+ ACL-aware `memory_get` plus a tiny "Memory Gateway" HTTP surface, implemented as an in-process OpenClaw plugin.
4
+
5
+ ## What it provides
6
+
7
+ - **Gateway HTTP routes** (auth: `gateway`)
8
+ - `GET /api/memory/index?agent_id=main&identity=owner&identity_id=<id>`
9
+ - `GET /api/memory/get?agent_id=main&identity=owner&identity_id=<id>&path=MEMORY.md&from=1&lines=200`
10
+ - **Tool**
11
+ - `memory_get` (reads `MEMORY.md` / `memory/**/*.md` with ACL filtering; non-memory paths fall back to local filesystem reads)
12
+
13
+ This plugin is `kind: "memory"` and is selected by `plugins.slots.memory`.
14
+
15
+ ## Install (host)
16
+
17
+ ```bash
18
+ openclaw plugins install memory-get-acl --pin
19
+ openclaw config set plugins.allow '["memory-get-acl"]' --strict-json
20
+ openclaw config set plugins.slots.memory "memory-get-acl"
21
+ openclaw config validate
22
+ ```
23
+
24
+ Restart the gateway after config changes.
25
+
26
+ ## Docker (server)
27
+
28
+ If you are running OpenClaw via the repository `docker-compose.yml` (or a compatible setup), you can install and enable the plugin through the `openclaw-cli` container:
29
+
30
+ ```bash
31
+ docker compose run -T --rm openclaw-cli plugins install memory-get-acl --pin
32
+ docker compose run -T --rm openclaw-cli config set plugins.allow '["memory-get-acl"]' --strict-json
33
+ docker compose run -T --rm openclaw-cli config set plugins.slots.memory "memory-get-acl"
34
+ docker compose run -T --rm openclaw-cli config validate
35
+ docker compose restart openclaw-gateway
36
+ ```
37
+
38
+ Then call the routes with your gateway token:
39
+
40
+ ```bash
41
+ curl -fsSL \
42
+ -H "Authorization: Bearer $OPENCLAW_GATEWAY_TOKEN" \
43
+ "http://127.0.0.1:18789/api/memory/get?agent_id=main&identity=owner&identity_id=owner&path=MEMORY.md"
44
+ ```
45
+
46
+ ## Publish (npm)
47
+
48
+ From this directory:
49
+
50
+ ```bash
51
+ npm publish --access public
52
+ ```
package/index.ts ADDED
@@ -0,0 +1,741 @@
1
+ /**
2
+ * memory-get-acl — minimal plugin that only provides memory_get.
3
+ *
4
+ * - MEMORY paths → routed to Memory Gateway with ACL
5
+ * - Non-MEMORY paths → passthrough to local filesystem
6
+ * - No memory_search, no hooks, no CLI
7
+ */
8
+
9
+ import { Type } from "@sinclair/typebox";
10
+ import { definePluginEntry } from "openclaw/plugin-sdk/core";
11
+ import type { IncomingMessage, ServerResponse } from "node:http";
12
+ import { readFile } from "node:fs/promises";
13
+ import { join, relative, resolve } from "node:path";
14
+ import fs from "node:fs/promises";
15
+
16
+ // ── Types ────────────────────────────────────────────────────────────────
17
+
18
+ export interface SessionIdentity {
19
+ identity: "owner" | "peer" | "group";
20
+ agentId: string;
21
+ userId?: string;
22
+ /**
23
+ * Agent owner user id (best-effort) inferred from commands.ownerAllowFrom for the session channel.
24
+ * Used to apply "shared group" checks for external peer chats.
25
+ */
26
+ ownerUserId?: string;
27
+ groupId?: string;
28
+ }
29
+
30
+ export interface PathInfo {
31
+ memoryType: "owner" | "peer" | "group" | "knowledge" | "index";
32
+ peerId?: string;
33
+ groupId?: string;
34
+ kbId?: string;
35
+ fileDate?: string;
36
+ }
37
+
38
+ interface ToolCtx {
39
+ workspaceDir?: string;
40
+ agentId?: string;
41
+ sessionKey?: string;
42
+ requesterSenderId?: string;
43
+ senderIsOwner?: boolean;
44
+ // Provided by OpenClaw tool context (not by tool params).
45
+ config?: unknown;
46
+ }
47
+
48
+ // ── Path helpers ─────────────────────────────────────────────────────────
49
+
50
+ export function isMemoryPath(p: string): boolean {
51
+ const n = p.replace(/\\/g, "/").toLowerCase();
52
+ return n === "memory.md" || n === "memory/memory.md" || n.startsWith("memory/");
53
+ }
54
+
55
+ export function parseMemoryPath(p: string): PathInfo {
56
+ const n = p.replace(/\\/g, "/");
57
+ if (/^memory\.md$/i.test(n)) return { memoryType: "index" };
58
+
59
+ let m = n.match(/^memory\/owner\/(.+)$/);
60
+ if (m) return { memoryType: "owner", fileDate: extractDate(m[1]) };
61
+
62
+ m = n.match(/^memory\/peers\/([^/]+)\/(.+)$/);
63
+ if (m) return { memoryType: "peer", peerId: m[1], fileDate: extractDate(m[2]) };
64
+
65
+ m = n.match(/^memory\/peers\/([^/]+)\/?$/);
66
+ if (m) return { memoryType: "peer", peerId: m[1] };
67
+
68
+ m = n.match(/^memory\/groups\/([^/]+)\/(.+)$/);
69
+ if (m) return { memoryType: "group", groupId: m[1], fileDate: extractDate(m[2]) };
70
+
71
+ m = n.match(/^memory\/groups\/([^/]+)\/?$/);
72
+ if (m) return { memoryType: "group", groupId: m[1] };
73
+
74
+ m = n.match(/^memory\/knowledge\/([^/]+)/);
75
+ if (m) return { memoryType: "knowledge", kbId: m[1] };
76
+
77
+ return { memoryType: "index" };
78
+ }
79
+
80
+ function extractDate(s: string): string | undefined {
81
+ return s.match(/(\d{4}-\d{2}-\d{2})/)?.[1];
82
+ }
83
+
84
+ // ── Identity ─────────────────────────────────────────────────────────────
85
+
86
+ type OwnerAllowFromEntry = { channel?: string; id: string };
87
+
88
+ function normalizeToken(value: string): string {
89
+ return value.trim().toLowerCase();
90
+ }
91
+
92
+ function parseOwnerAllowFromEntry(raw: string): OwnerAllowFromEntry | null {
93
+ const trimmed = raw.trim();
94
+ if (!trimmed) return null;
95
+ if (trimmed === "*") return { id: "*" };
96
+
97
+ const sep = trimmed.indexOf(":");
98
+ if (sep > 0) {
99
+ const maybeChannel = trimmed.slice(0, sep).trim();
100
+ const remainder = trimmed.slice(sep + 1).trim();
101
+ if (maybeChannel && remainder) {
102
+ return { channel: normalizeToken(maybeChannel), id: remainder };
103
+ }
104
+ }
105
+
106
+ return { id: trimmed };
107
+ }
108
+
109
+ function readOwnerAllowFrom(ctx: ToolCtx): string[] {
110
+ const cfg = (ctx as unknown as { config?: { commands?: { ownerAllowFrom?: unknown } } }).config;
111
+ const raw = cfg?.commands?.ownerAllowFrom;
112
+ if (!Array.isArray(raw)) return [];
113
+ return raw
114
+ .map((v) => (typeof v === "string" ? v : String(v ?? "")))
115
+ .map((v) => v.trim())
116
+ .filter((v) => v.length > 0);
117
+ }
118
+
119
+ function isOwnerByAllowFrom(params: {
120
+ userId: string;
121
+ channelId?: string;
122
+ ownerAllowFrom: string[];
123
+ }): boolean {
124
+ if (params.ownerAllowFrom.length === 0) return false;
125
+
126
+ const userIdNorm = normalizeToken(params.userId);
127
+ const channelNorm = params.channelId ? normalizeToken(params.channelId) : undefined;
128
+
129
+ for (const raw of params.ownerAllowFrom) {
130
+ const entry = parseOwnerAllowFromEntry(raw);
131
+ if (!entry) continue;
132
+
133
+ if (entry.id === "*") return true;
134
+
135
+ if (entry.channel) {
136
+ if (!channelNorm || entry.channel !== channelNorm) continue;
137
+ if (normalizeToken(entry.id) === userIdNorm) return true;
138
+ continue;
139
+ }
140
+
141
+ if (normalizeToken(entry.id) === userIdNorm) return true;
142
+ }
143
+
144
+ return false;
145
+ }
146
+
147
+ function resolveOwnerUserIdFromAllowFrom(ownerAllowFrom: string[], channelId?: string): string | undefined {
148
+ if (ownerAllowFrom.length === 0) return undefined;
149
+ const channelNorm = channelId ? normalizeToken(channelId) : undefined;
150
+
151
+ const parsed = ownerAllowFrom
152
+ .map((raw) => (typeof raw === "string" ? raw : String(raw ?? "")))
153
+ .map((raw) => raw.trim())
154
+ .filter((raw) => raw.length > 0)
155
+ .map((raw) => parseOwnerAllowFromEntry(raw))
156
+ .filter((v): v is OwnerAllowFromEntry => Boolean(v));
157
+
158
+ // If allowFrom has "*", everyone is owner; caller will already be treated as owner.
159
+ if (parsed.some((e) => e.id === "*")) return undefined;
160
+
161
+ if (channelNorm) {
162
+ const match = parsed.find((e) => e.channel && e.channel === channelNorm && e.id);
163
+ if (match) return match.id;
164
+ }
165
+
166
+ const noChannel = parsed.filter((e) => !e.channel && e.id);
167
+ if (noChannel.length === 1) return noChannel[0]?.id;
168
+
169
+ return undefined;
170
+ }
171
+
172
+ function parseSessionKeyIdentity(
173
+ sessionKey: string | undefined,
174
+ ): { channelId?: string; kind?: "direct" | "dm" | "group" | "channel"; id?: string } {
175
+ const sk = (sessionKey ?? "").trim();
176
+ if (!sk) return {};
177
+
178
+ const parts = sk.split(":").filter((p) => p.trim().length > 0);
179
+ if (parts.length < 3 || parts[0]?.toLowerCase() !== "agent") {
180
+ // Legacy/non-canonical session keys: best-effort suffix match.
181
+ const gm = sk.match(/:group:([^:]+)(?::|$)/);
182
+ if (gm) return { kind: "group", id: gm[1] };
183
+ const dm = sk.match(/:(?:direct|dm):([^:]+)(?::|$)/);
184
+ if (dm) return { kind: "direct", id: dm[1] };
185
+ return {};
186
+ }
187
+
188
+ const channelId = parts[2]?.trim() || undefined;
189
+ // Scan backwards so suffixes like :topic:5 don't break parsing.
190
+ for (let i = parts.length - 2; i >= 1; i -= 1) {
191
+ const kindRaw = (parts[i] ?? "").trim().toLowerCase();
192
+ const id = (parts[i + 1] ?? "").trim();
193
+ if (!id) continue;
194
+ if (kindRaw === "group" || kindRaw === "channel") return { channelId, kind: "group", id };
195
+ if (kindRaw === "direct" || kindRaw === "dm") return { channelId, kind: "direct", id };
196
+ }
197
+
198
+ return { channelId };
199
+ }
200
+
201
+ export function resolveIdentity(ctx: ToolCtx): SessionIdentity {
202
+ const agentId = ctx.agentId ?? "main";
203
+
204
+ const parsed = parseSessionKeyIdentity(ctx.sessionKey);
205
+
206
+ if (parsed.kind === "group" && parsed.id) {
207
+ return { identity: "group", agentId, groupId: parsed.id };
208
+ }
209
+
210
+ const directId = parsed.kind === "direct" ? parsed.id : undefined;
211
+ const userId = directId ?? ctx.requesterSenderId;
212
+
213
+ const ownerAllowFrom = readOwnerAllowFrom(ctx);
214
+ const allowFromOwner =
215
+ typeof userId === "string" &&
216
+ userId.trim().length > 0 &&
217
+ isOwnerByAllowFrom({ userId, channelId: parsed.channelId, ownerAllowFrom });
218
+
219
+ if (ctx.senderIsOwner || allowFromOwner) {
220
+ return { identity: "owner", agentId, userId: ctx.requesterSenderId ?? userId };
221
+ }
222
+
223
+ return {
224
+ identity: "peer",
225
+ agentId,
226
+ userId,
227
+ ownerUserId: resolveOwnerUserIdFromAllowFrom(ownerAllowFrom, parsed.channelId),
228
+ };
229
+ }
230
+
231
+ // ── ACL ──────────────────────────────────────────────────────────────────
232
+
233
+ type MemberPeriod = { joined: number; left: number | null };
234
+ type MemberGroupInfo = { group_id: string; periods: MemberPeriod[] };
235
+ type MemberApiResponse = { user_id: string; groups: MemberGroupInfo[] };
236
+
237
+ const MEMBER_API_TIMEOUT_MS = 3500;
238
+ const MEMBER_API_TTL_MS = 30_000;
239
+ const memberCache = new Map<string, { at: number; data: MemberApiResponse | null }>();
240
+
241
+ function memberApiBaseUrl(): string | null {
242
+ const raw = process.env.MEMBER_API_BASE_URL;
243
+ if (!raw) return null;
244
+ const trimmed = raw.trim().replace(/\/+$/, "");
245
+ return trimmed.length > 0 ? trimmed : null;
246
+ }
247
+
248
+ async function fetchMemberInfo(userId: string): Promise<MemberApiResponse | null> {
249
+ const key = userId.trim();
250
+ if (!key) return null;
251
+
252
+ const cached = memberCache.get(key);
253
+ if (cached && Date.now() - cached.at < MEMBER_API_TTL_MS) {
254
+ return cached.data;
255
+ }
256
+
257
+ const base = memberApiBaseUrl();
258
+ if (!base) {
259
+ memberCache.set(key, { at: Date.now(), data: null });
260
+ return null;
261
+ }
262
+
263
+ const controller = new AbortController();
264
+ const timeout = setTimeout(() => controller.abort(), MEMBER_API_TIMEOUT_MS);
265
+ try {
266
+ const url = `${base}/api/members?user_id=${encodeURIComponent(key)}`;
267
+ const res = await fetch(url, { signal: controller.signal });
268
+ if (!res.ok) {
269
+ memberCache.set(key, { at: Date.now(), data: null });
270
+ return null;
271
+ }
272
+ const json = (await res.json()) as MemberApiResponse;
273
+ if (!json || typeof json !== "object" || !Array.isArray(json.groups)) {
274
+ memberCache.set(key, { at: Date.now(), data: null });
275
+ return null;
276
+ }
277
+ memberCache.set(key, { at: Date.now(), data: json });
278
+ return json;
279
+ } catch {
280
+ memberCache.set(key, { at: Date.now(), data: null });
281
+ return null;
282
+ } finally {
283
+ clearTimeout(timeout);
284
+ }
285
+ }
286
+
287
+ function resolveActivePeriodForGroup(resp: MemberApiResponse, groupId: string): MemberPeriod | null {
288
+ const g = resp.groups?.find((x) => String(x.group_id ?? "") === groupId);
289
+ if (!g || !Array.isArray(g.periods) || g.periods.length === 0) return null;
290
+ const last = g.periods[g.periods.length - 1] as MemberPeriod | undefined;
291
+ if (!last || typeof last.joined !== "number") return null;
292
+ // Requirement: if already left group, cannot view ANY group memory.
293
+ if (last.left !== null) return null;
294
+ return last;
295
+ }
296
+
297
+ function fileTimestampFromDate(fileDate: string | undefined): number | null {
298
+ if (!fileDate) return null;
299
+ const d = new Date(`${fileDate}T00:00:00Z`);
300
+ const ts = d.getTime();
301
+ return Number.isFinite(ts) ? ts : null;
302
+ }
303
+
304
+ async function canPeerReadGroupMemory(params: {
305
+ peerId: string | undefined;
306
+ ownerUserId: string | undefined;
307
+ groupId: string | undefined;
308
+ fileDate: string | undefined;
309
+ }): Promise<boolean> {
310
+ const peerId = (params.peerId ?? "").trim();
311
+ const ownerUserId = (params.ownerUserId ?? "").trim();
312
+ const groupId = (params.groupId ?? "").trim();
313
+ if (!peerId || !ownerUserId || !groupId) return false;
314
+
315
+ const ts = fileTimestampFromDate(params.fileDate);
316
+ // No date → deny (avoid listing/group traversal without filtering).
317
+ if (ts === null) return false;
318
+
319
+ const [peerInfo, ownerInfo] = await Promise.all([
320
+ fetchMemberInfo(peerId),
321
+ fetchMemberInfo(ownerUserId),
322
+ ]);
323
+ if (!peerInfo || !ownerInfo) return false;
324
+
325
+ const peerActive = resolveActivePeriodForGroup(peerInfo, groupId);
326
+ const ownerActive = resolveActivePeriodForGroup(ownerInfo, groupId);
327
+ if (!peerActive || !ownerActive) return false;
328
+
329
+ // Only allow reading memories from the time both are in the group.
330
+ const overlapStart = Math.max(peerActive.joined, ownerActive.joined);
331
+ return ts >= overlapStart;
332
+ }
333
+
334
+ export function checkAcl(id: SessionIdentity, pi: PathInfo): { ok: boolean; reason?: string } {
335
+ if (id.identity === "owner") return { ok: true };
336
+
337
+ if (id.identity === "peer") {
338
+ if (pi.memoryType === "owner") return { ok: false, reason: "access denied" };
339
+ if (pi.memoryType === "peer" && pi.peerId && pi.peerId !== id.userId)
340
+ return { ok: false, reason: "access denied" };
341
+ if (pi.memoryType === "group") {
342
+ // External peer chatting with someone else's shrimp:
343
+ // - must call member API
344
+ // - only shared groups (peer + owner both active in group)
345
+ // - if join times differ, only allow reading after later join timestamp
346
+ // - if either already left, deny ANY group memory
347
+ // NOTE: This is async; caller should use checkAclAsync for group paths.
348
+ return { ok: false, reason: "access denied" };
349
+ }
350
+ return { ok: true };
351
+ }
352
+
353
+ if (id.identity === "group") {
354
+ if (pi.memoryType === "owner" || pi.memoryType === "peer")
355
+ return { ok: false, reason: "access denied" };
356
+ if (pi.memoryType === "group" && pi.groupId && pi.groupId !== id.groupId)
357
+ return { ok: false, reason: "access denied" };
358
+ return { ok: true };
359
+ }
360
+
361
+ return { ok: false, reason: "access denied" };
362
+ }
363
+
364
+ async function checkAclAsync(
365
+ id: SessionIdentity,
366
+ pi: PathInfo,
367
+ ): Promise<{ ok: boolean; reason?: string }> {
368
+ const base = checkAcl(id, pi);
369
+ if (base.ok) return base;
370
+
371
+ if (id.identity === "peer" && pi.memoryType === "group") {
372
+ const ok = await canPeerReadGroupMemory({
373
+ peerId: id.userId,
374
+ ownerUserId: id.ownerUserId,
375
+ groupId: pi.groupId,
376
+ fileDate: pi.fileDate,
377
+ });
378
+ return ok ? { ok: true } : { ok: false, reason: "access denied" };
379
+ }
380
+
381
+ return base;
382
+ }
383
+
384
+ // ── Result helper ────────────────────────────────────────────────────────
385
+
386
+ function jsonResult(payload: unknown) {
387
+ return {
388
+ content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }],
389
+ details: payload,
390
+ };
391
+ }
392
+
393
+ function normalizeRelPath(p: string): string {
394
+ return p.trim().replace(/^[./]+/, "").replace(/\\/g, "/");
395
+ }
396
+
397
+ function sendJson(res: ServerResponse, status: number, payload: unknown): void {
398
+ res.statusCode = status;
399
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
400
+ res.end(`${JSON.stringify(payload)}\n`);
401
+ }
402
+
403
+ function resolveQueryUrl(req: IncomingMessage): URL {
404
+ return new URL(req.url ?? "/", "http://localhost");
405
+ }
406
+
407
+ function readQueryString(url: URL, key: string): string | undefined {
408
+ const value = url.searchParams.get(key);
409
+ if (value === null) return undefined;
410
+ const trimmed = value.trim();
411
+ return trimmed.length > 0 ? trimmed : undefined;
412
+ }
413
+
414
+ function readQueryNumber(url: URL, key: string): number | undefined {
415
+ const raw = readQueryString(url, key);
416
+ if (!raw) return undefined;
417
+ const n = Number(raw);
418
+ return Number.isFinite(n) ? n : undefined;
419
+ }
420
+
421
+ function resolveHttpIdentity(url: URL): SessionIdentity {
422
+ const identityRaw = (readQueryString(url, "identity") ?? "").toLowerCase();
423
+ const identity =
424
+ identityRaw === "owner" || identityRaw === "peer" || identityRaw === "group"
425
+ ? (identityRaw as SessionIdentity["identity"])
426
+ : "owner";
427
+ const agentId = readQueryString(url, "agent_id") ?? "main";
428
+ const identityId = readQueryString(url, "identity_id");
429
+ const ownerId = readQueryString(url, "owner_id") ?? undefined;
430
+ if (identity === "group") {
431
+ return { identity, agentId, groupId: identityId };
432
+ }
433
+ return {
434
+ identity,
435
+ agentId,
436
+ userId: identityId,
437
+ ...(identity === "peer" && ownerId ? { ownerUserId: ownerId } : {}),
438
+ };
439
+ }
440
+
441
+ async function listMarkdownFilesRecursive(dir: string, rootDir: string): Promise<string[]> {
442
+ const entries = await fs.readdir(dir, { withFileTypes: true });
443
+ const out: string[] = [];
444
+ for (const entry of entries) {
445
+ const full = join(dir, entry.name);
446
+ if (entry.isSymbolicLink()) {
447
+ continue;
448
+ }
449
+ if (entry.isDirectory()) {
450
+ out.push(...(await listMarkdownFilesRecursive(full, rootDir)));
451
+ continue;
452
+ }
453
+ if (!entry.isFile()) {
454
+ continue;
455
+ }
456
+ if (!entry.name.endsWith(".md")) {
457
+ continue;
458
+ }
459
+ const rel = relative(rootDir, full).replace(/\\/g, "/");
460
+ out.push(rel);
461
+ }
462
+ return out;
463
+ }
464
+
465
+ async function readWorkspaceFile(params: {
466
+ workspaceDir: string;
467
+ relPath: string;
468
+ }): Promise<{ ok: true; text: string } | { ok: false; error: string }> {
469
+ const abs = resolve(join(params.workspaceDir, params.relPath));
470
+ if (!abs.startsWith(resolve(params.workspaceDir))) {
471
+ return { ok: false, error: "path traversal denied" };
472
+ }
473
+ try {
474
+ const text = await readFile(abs, "utf-8");
475
+ return { ok: true, text };
476
+ } catch {
477
+ return { ok: false, error: "file not found" };
478
+ }
479
+ }
480
+
481
+ async function listVisibleMemoryPaths(params: {
482
+ workspaceDir: string;
483
+ identity: SessionIdentity;
484
+ }): Promise<string[]> {
485
+ let paths: string[] = [];
486
+ try {
487
+ const memoryDir = join(params.workspaceDir, "memory");
488
+ const stat = await fs.lstat(memoryDir);
489
+ if (!stat.isSymbolicLink() && stat.isDirectory()) {
490
+ paths = await listMarkdownFilesRecursive(memoryDir, params.workspaceDir);
491
+ }
492
+ } catch {
493
+ paths = [];
494
+ }
495
+
496
+ return paths
497
+ .map((p) => normalizeRelPath(p))
498
+ .filter((p) => p.startsWith("memory/"))
499
+ .filter((p) => checkAcl(params.identity, parseMemoryPath(p)).ok)
500
+ .toSorted((a, b) => a.localeCompare(b));
501
+ }
502
+
503
+ async function loadOrBuildIndex(params: {
504
+ workspaceDir: string;
505
+ identity: SessionIdentity;
506
+ }): Promise<{ path: "MEMORY.md"; text: string }> {
507
+ // Prefer the canonical MEMORY.md (or memory.md fallback) if present.
508
+ for (const candidate of ["MEMORY.md", "memory.md"]) {
509
+ const loaded = await readWorkspaceFile({ workspaceDir: params.workspaceDir, relPath: candidate });
510
+ if (loaded.ok) {
511
+ return { path: "MEMORY.md", text: loaded.text };
512
+ }
513
+ }
514
+
515
+ const paths = await listVisibleMemoryPaths({
516
+ workspaceDir: params.workspaceDir,
517
+ identity: params.identity,
518
+ });
519
+ const text =
520
+ paths.length === 0
521
+ ? "# Memory Index\n\nNo memories stored yet.\n"
522
+ : `# Memory Index\n\n${paths.map((p) => `- ${p}`).join("\n")}\n`;
523
+ return { path: "MEMORY.md", text };
524
+ }
525
+
526
+ function sliceText(text: string, from?: number, lines?: number): string {
527
+ if (from === undefined && lines === undefined) {
528
+ return text;
529
+ }
530
+ const all = text.split("\n");
531
+ const s = (from ?? 1) - 1;
532
+ return all.slice(s, s + (lines ?? all.length)).join("\n");
533
+ }
534
+
535
+ // ── Plugin ───────────────────────────────────────────────────────────────
536
+
537
+ export default definePluginEntry({
538
+ id: "memory-get-acl",
539
+ name: "Memory Get (ACL)",
540
+ description: "Minimal ACL-aware memory_get backed by Memory Gateway",
541
+ kind: "memory",
542
+
543
+ register(api) {
544
+ const token =
545
+ typeof (api.config as { gateway?: { auth?: { token?: unknown } } }).gateway?.auth?.token ===
546
+ "string"
547
+ ? ((api.config as { gateway?: { auth?: { token?: unknown } } }).gateway?.auth?.token as string)
548
+ : "";
549
+ const authHeader = token.trim() ? `Bearer ${token.trim()}` : "";
550
+
551
+ const defaultGatewayUrl = `http://127.0.0.1:${api.config.gateway?.port ?? 18789}`;
552
+ const gatewayUrl = (api.pluginConfig?.gatewayUrl as string | undefined) ?? defaultGatewayUrl;
553
+
554
+ // Provide the Memory Gateway HTTP routes *inside* the OpenClaw Gateway process,
555
+ // so memory-get-acl can work without a separate PG-backed service.
556
+ api.registerHttpRoute({
557
+ path: "/api/memory/index",
558
+ auth: "gateway",
559
+ match: "exact",
560
+ handler: async (req, res) => {
561
+ const url = resolveQueryUrl(req);
562
+ const identity = resolveHttpIdentity(url);
563
+ const workspaceDir = api.runtime.agent.resolveAgentWorkspaceDir(api.config, identity.agentId);
564
+ const index = await loadOrBuildIndex({ workspaceDir, identity });
565
+ sendJson(res, 200, index);
566
+ return true;
567
+ },
568
+ });
569
+
570
+ api.registerHttpRoute({
571
+ path: "/api/memory/get",
572
+ auth: "gateway",
573
+ match: "exact",
574
+ handler: async (req, res) => {
575
+ const url = resolveQueryUrl(req);
576
+ const identity = resolveHttpIdentity(url);
577
+ const rawPath = readQueryString(url, "path") ?? "MEMORY.md";
578
+ const from = readQueryNumber(url, "from");
579
+ const lines = readQueryNumber(url, "lines");
580
+ const relPath = normalizeRelPath(rawPath);
581
+ const workspaceDir = api.runtime.agent.resolveAgentWorkspaceDir(api.config, identity.agentId);
582
+
583
+ // Treat MEMORY.md as an index alias.
584
+ if (/^memory\.md$/i.test(relPath)) {
585
+ const index = await loadOrBuildIndex({ workspaceDir, identity });
586
+ sendJson(res, 200, { ...index, text: sliceText(index.text, from, lines) });
587
+ return true;
588
+ }
589
+
590
+ if (!isMemoryPath(relPath)) {
591
+ sendJson(res, 400, { path: rawPath, text: "", error: "unsupported path" });
592
+ return true;
593
+ }
594
+
595
+ const acl = await checkAclAsync(identity, parseMemoryPath(relPath));
596
+ if (!acl.ok) {
597
+ sendJson(res, 403, { path: rawPath, text: "", error: acl.reason ?? "access denied" });
598
+ return true;
599
+ }
600
+
601
+ const loaded = await readWorkspaceFile({ workspaceDir, relPath });
602
+ if (!loaded.ok) {
603
+ sendJson(res, 404, { path: rawPath, text: "", error: loaded.error });
604
+ return true;
605
+ }
606
+ sendJson(res, 200, { path: rawPath, text: sliceText(loaded.text, from, lines) });
607
+ return true;
608
+ },
609
+ });
610
+
611
+ api.registerTool(
612
+ (ctx) => {
613
+ const identity = resolveIdentity(ctx as ToolCtx);
614
+ const workspaceDir = (ctx as ToolCtx).workspaceDir;
615
+
616
+ return {
617
+ label: "Memory Get",
618
+ name: "memory_get",
619
+ description:
620
+ "Read from MEMORY.md or memory/*.md (ACL-filtered). " +
621
+ "Non-memory files read from local filesystem.",
622
+ parameters: Type.Object({
623
+ path: Type.String(),
624
+ from: Type.Optional(Type.Number()),
625
+ lines: Type.Optional(Type.Number()),
626
+ }),
627
+
628
+ async execute(_toolCallId: string, params: Record<string, unknown>) {
629
+ const relPath = typeof params.path === "string" ? params.path.trim() : "";
630
+ if (!relPath) return jsonResult({ path: "", text: "", error: "path is required" });
631
+
632
+ const from = typeof params.from === "number" ? params.from : undefined;
633
+ const lines = typeof params.lines === "number" ? params.lines : undefined;
634
+
635
+ // ── Non-MEMORY → local passthrough ───────────────
636
+ if (!isMemoryPath(relPath)) {
637
+ return jsonResult(await readLocal(workspaceDir, relPath, from, lines));
638
+ }
639
+
640
+ // ── MEMORY → ACL check ──────────────────────────
641
+ const normalizedRelPath = normalizeRelPath(relPath);
642
+ const pathInfo = parseMemoryPath(normalizedRelPath);
643
+ const acl = await checkAclAsync(identity, pathInfo);
644
+ if (!acl.ok) {
645
+ return jsonResult({ path: relPath, text: "", error: acl.reason });
646
+ }
647
+
648
+ // ── MEMORY.md index ─────────────────────────────
649
+ if (pathInfo.memoryType === "index") {
650
+ return jsonResult(
651
+ await fetchGateway(
652
+ `${gatewayUrl}/api/memory/index`,
653
+ {
654
+ agent_id: identity.agentId,
655
+ identity: identity.identity,
656
+ identity_id: identity.userId ?? identity.groupId ?? "",
657
+ },
658
+ relPath,
659
+ { authHeader },
660
+ from,
661
+ lines,
662
+ ),
663
+ );
664
+ }
665
+
666
+ // ── Regular MEMORY path ─────────────────────────
667
+ return jsonResult(
668
+ await fetchGateway(
669
+ `${gatewayUrl}/api/memory/get`,
670
+ {
671
+ agent_id: identity.agentId,
672
+ path: normalizedRelPath,
673
+ identity: identity.identity,
674
+ identity_id: identity.userId ?? identity.groupId ?? "",
675
+ ...(identity.identity === "peer" && identity.ownerUserId
676
+ ? { owner_id: identity.ownerUserId }
677
+ : {}),
678
+ ...(from !== undefined && { from: String(from) }),
679
+ ...(lines !== undefined && { lines: String(lines) }),
680
+ },
681
+ relPath,
682
+ { authHeader },
683
+ ),
684
+ );
685
+ },
686
+ };
687
+ },
688
+ { names: ["memory_get"] },
689
+ );
690
+ },
691
+ });
692
+
693
+ // ── Gateway fetch ────────────────────────────────────────────────────────
694
+
695
+ async function fetchGateway(
696
+ url: string,
697
+ params: Record<string, string>,
698
+ fallbackPath: string,
699
+ opts?: { authHeader?: string },
700
+ from?: number,
701
+ lines?: number,
702
+ ): Promise<{ path: string; text: string; error?: string }> {
703
+ try {
704
+ const res = await fetch(`${url}?${new URLSearchParams(params)}`, {
705
+ headers: opts?.authHeader ? { Authorization: opts.authHeader } : undefined,
706
+ });
707
+ if (!res.ok) return { path: fallbackPath, text: "", error: `Gateway: ${res.status}` };
708
+ const json = (await res.json()) as { path?: string; text?: string };
709
+ let text = json.text ?? "";
710
+ if (from !== undefined || lines !== undefined) {
711
+ const all = text.split("\n");
712
+ const s = (from ?? 1) - 1;
713
+ text = all.slice(s, s + (lines ?? all.length)).join("\n");
714
+ }
715
+ return { path: fallbackPath, text };
716
+ } catch (e) {
717
+ return { path: fallbackPath, text: "", error: `Gateway unreachable: ${(e as Error).message}` };
718
+ }
719
+ }
720
+
721
+ // ── Local file read ──────────────────────────────────────────────────────
722
+
723
+ async function readLocal(
724
+ wsDir: string | undefined,
725
+ relPath: string,
726
+ from?: number,
727
+ lines?: number,
728
+ ): Promise<{ path: string; text: string; error?: string }> {
729
+ if (!wsDir) return { path: relPath, text: "", error: "workspace not available" };
730
+
731
+ const abs = resolve(join(wsDir, relPath));
732
+ if (!abs.startsWith(resolve(wsDir))) return { path: relPath, text: "", error: "path traversal denied" };
733
+
734
+ try {
735
+ let content = await readFile(abs, "utf-8");
736
+ content = sliceText(content, from, lines);
737
+ return { path: relPath, text: content };
738
+ } catch {
739
+ return { path: relPath, text: "", error: `file not found: ${relPath}` };
740
+ }
741
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "id": "memory-get-acl",
3
+ "kind": "memory",
4
+ "configSchema": {
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {
8
+ "gatewayUrl": {
9
+ "type": "string",
10
+ "description": "Memory Gateway service URL"
11
+ }
12
+ }
13
+ }
14
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "memory-get-acl",
3
+ "version": "2026.3.31",
4
+ "description": "Minimal ACL-aware memory_get tool backed by Memory Gateway",
5
+ "type": "module",
6
+ "files": [
7
+ "index.ts",
8
+ "openclaw.plugin.json",
9
+ "README.md"
10
+ ],
11
+ "publishConfig": {
12
+ "access": "public",
13
+ "registry": "https://registry.npmjs.org/"
14
+ },
15
+ "dependencies": {
16
+ "@sinclair/typebox": "0.34.48"
17
+ },
18
+ "peerDependencies": {
19
+ "openclaw": ">=2026.3.11"
20
+ },
21
+ "peerDependenciesMeta": {
22
+ "openclaw": {
23
+ "optional": true
24
+ }
25
+ },
26
+ "openclaw": {
27
+ "extensions": [
28
+ "./index.ts"
29
+ ]
30
+ }
31
+ }