@contextableai/openclaw-memory-rebac 0.3.3 → 0.3.4
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/dist/authorization.d.ts +6 -0
- package/dist/authorization.js +21 -0
- package/dist/backend.d.ts +0 -6
- package/dist/backends/graphiti.d.ts +0 -1
- package/dist/backends/graphiti.js +0 -20
- package/dist/cli.js +39 -28
- package/dist/index.js +18 -3
- package/package.json +1 -1
package/dist/authorization.d.ts
CHANGED
|
@@ -55,6 +55,12 @@ export declare function canDeleteFragment(spicedb: SpiceDbClient, subject: Subje
|
|
|
55
55
|
* Used to gate writes to non-session groups — prevents unauthorized memory injection.
|
|
56
56
|
*/
|
|
57
57
|
export declare function canWriteToGroup(spicedb: SpiceDbClient, subject: Subject, groupId: string, zedToken?: string): Promise<boolean>;
|
|
58
|
+
/**
|
|
59
|
+
* Discover which groups a set of memory fragments belong to.
|
|
60
|
+
* Reads the `source_group` relation for each fragment ID.
|
|
61
|
+
* Returns deduplicated group IDs.
|
|
62
|
+
*/
|
|
63
|
+
export declare function lookupFragmentSourceGroups(spicedb: SpiceDbClient, fragmentIds: string[], zedToken?: string): Promise<string[]>;
|
|
58
64
|
/**
|
|
59
65
|
* Ensure a subject is registered as a member of a group.
|
|
60
66
|
* Idempotent (uses TOUCH operation).
|
package/dist/authorization.js
CHANGED
|
@@ -130,6 +130,27 @@ export async function canWriteToGroup(spicedb, subject, groupId, zedToken) {
|
|
|
130
130
|
consistency: tokenConsistency(zedToken),
|
|
131
131
|
});
|
|
132
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Discover which groups a set of memory fragments belong to.
|
|
135
|
+
* Reads the `source_group` relation for each fragment ID.
|
|
136
|
+
* Returns deduplicated group IDs.
|
|
137
|
+
*/
|
|
138
|
+
export async function lookupFragmentSourceGroups(spicedb, fragmentIds, zedToken) {
|
|
139
|
+
const groupIds = new Set();
|
|
140
|
+
for (const fid of fragmentIds) {
|
|
141
|
+
const tuples = await spicedb.readRelationships({
|
|
142
|
+
resourceType: "memory_fragment",
|
|
143
|
+
resourceId: fid,
|
|
144
|
+
relation: "source_group",
|
|
145
|
+
consistency: tokenConsistency(zedToken),
|
|
146
|
+
});
|
|
147
|
+
for (const t of tuples) {
|
|
148
|
+
if (t.subjectType === "group")
|
|
149
|
+
groupIds.add(t.subjectId);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return Array.from(groupIds);
|
|
153
|
+
}
|
|
133
154
|
/**
|
|
134
155
|
* Ensure a subject is registered as a member of a group.
|
|
135
156
|
* Idempotent (uses TOUCH operation).
|
package/dist/backend.d.ts
CHANGED
|
@@ -118,12 +118,6 @@ export interface MemoryBackend {
|
|
|
118
118
|
* Returns true if deleted, false if the backend doesn't support it.
|
|
119
119
|
*/
|
|
120
120
|
deleteFragment?(uuid: string, type?: string): Promise<boolean>;
|
|
121
|
-
/**
|
|
122
|
-
* Fetch fragment details by their IDs.
|
|
123
|
-
* Used for fragment-level recall (e.g., finding memories via `involves` permissions).
|
|
124
|
-
* Optional: not all backends support fetching individual fragments by ID.
|
|
125
|
-
*/
|
|
126
|
-
getFragmentsByIds?(ids: string[]): Promise<SearchResult[]>;
|
|
127
121
|
/**
|
|
128
122
|
* Discover fragment (fact/edge) UUIDs that were extracted from a stored episode.
|
|
129
123
|
* Called after store() resolves the episode ID to write per-fragment SpiceDB
|
|
@@ -63,7 +63,6 @@ export declare class GraphitiBackend implements MemoryBackend {
|
|
|
63
63
|
listGroups(): Promise<BackendDataset[]>;
|
|
64
64
|
deleteFragment(uuid: string, type?: string): Promise<boolean>;
|
|
65
65
|
getEpisodes(groupId: string, lastN: number): Promise<GraphitiEpisode[]>;
|
|
66
|
-
getFragmentsByIds(ids: string[]): Promise<SearchResult[]>;
|
|
67
66
|
discoverFragmentIds(episodeId: string): Promise<string[]>;
|
|
68
67
|
getEntityEdge(uuid: string): Promise<FactResult>;
|
|
69
68
|
registerCliCommands(cmd: Command): void;
|
|
@@ -159,26 +159,6 @@ export class GraphitiBackend {
|
|
|
159
159
|
async getEpisodes(groupId, lastN) {
|
|
160
160
|
return this.restCall("GET", `/episodes/${encodeURIComponent(groupId)}?last_n=${lastN}`);
|
|
161
161
|
}
|
|
162
|
-
async getFragmentsByIds(ids) {
|
|
163
|
-
const results = [];
|
|
164
|
-
for (const id of ids) {
|
|
165
|
-
try {
|
|
166
|
-
const fact = await this.getEntityEdge(id);
|
|
167
|
-
results.push({
|
|
168
|
-
type: "fact",
|
|
169
|
-
uuid: id,
|
|
170
|
-
group_id: "unknown",
|
|
171
|
-
summary: fact.fact,
|
|
172
|
-
context: fact.name,
|
|
173
|
-
created_at: fact.created_at,
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
catch {
|
|
177
|
-
// Fragment not found or unreachable — skip
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
return results;
|
|
181
|
-
}
|
|
182
162
|
async discoverFragmentIds(episodeId) {
|
|
183
163
|
const edges = await this.restCall("GET", `/episodes/${encodeURIComponent(episodeId)}/edges`);
|
|
184
164
|
return edges.map((e) => e.uuid);
|
package/dist/cli.js
CHANGED
|
@@ -14,7 +14,7 @@ import { join, dirname, basename, resolve } from "node:path";
|
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { defaultGroupId } from "./config.js";
|
|
17
|
-
import { lookupAuthorizedGroups, lookupAgentOwner, lookupViewableFragments, ensureGroupMembership, } from "./authorization.js";
|
|
17
|
+
import { lookupAuthorizedGroups, lookupAgentOwner, lookupViewableFragments, lookupFragmentSourceGroups, ensureGroupMembership, } from "./authorization.js";
|
|
18
18
|
import { searchAuthorizedMemories } from "./search.js";
|
|
19
19
|
// ============================================================================
|
|
20
20
|
// Session helper (mirrors index.ts — no shared module to avoid circular import)
|
|
@@ -71,39 +71,50 @@ export function registerCommands(cmd, ctx) {
|
|
|
71
71
|
})
|
|
72
72
|
: [];
|
|
73
73
|
// Fragment-based recall via involves/view permission
|
|
74
|
+
// Uses search-then-post-filter: search source groups for query relevance,
|
|
75
|
+
// then intersect with authorized fragment set for security.
|
|
74
76
|
let fragmentResults = [];
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const ownerId = await lookupAgentOwner(spicedb, subject.id, token);
|
|
86
|
-
if (ownerId) {
|
|
87
|
-
personSubject = { type: "person", id: ownerId };
|
|
88
|
-
}
|
|
77
|
+
try {
|
|
78
|
+
// Determine the person to search as
|
|
79
|
+
let personSubject;
|
|
80
|
+
if (subject.type === "person") {
|
|
81
|
+
personSubject = subject;
|
|
82
|
+
}
|
|
83
|
+
else if (subject.type === "agent") {
|
|
84
|
+
const ownerId = await lookupAgentOwner(spicedb, subject.id, token);
|
|
85
|
+
if (ownerId) {
|
|
86
|
+
personSubject = { type: "person", id: ownerId };
|
|
89
87
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
88
|
+
}
|
|
89
|
+
if (personSubject) {
|
|
90
|
+
const viewableIds = await lookupViewableFragments(spicedb, personSubject, token);
|
|
91
|
+
if (viewableIds.length > 0) {
|
|
92
|
+
const groupResultIds = new Set(groupResults.map((r) => r.uuid));
|
|
93
|
+
const newIds = viewableIds.filter((id) => !groupResultIds.has(id));
|
|
94
|
+
if (newIds.length > 0) {
|
|
95
|
+
// Discover which groups the viewable fragments belong to
|
|
96
|
+
const ownerGroups = await lookupFragmentSourceGroups(spicedb, newIds, token);
|
|
97
|
+
const alreadySearched = new Set(authorizedGroups);
|
|
98
|
+
const newGroups = ownerGroups.filter(g => !alreadySearched.has(g));
|
|
99
|
+
if (newGroups.length > 0) {
|
|
100
|
+
const candidateResults = await searchAuthorizedMemories(backend, {
|
|
101
|
+
query,
|
|
102
|
+
groupIds: newGroups,
|
|
103
|
+
limit: parseInt(opts.limit),
|
|
104
|
+
});
|
|
105
|
+
// Post-filter: only keep results the person is authorized to view
|
|
106
|
+
const viewableSet = new Set(newIds);
|
|
107
|
+
fragmentResults = candidateResults.filter(r => viewableSet.has(r.uuid));
|
|
97
108
|
}
|
|
98
109
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
110
|
+
}
|
|
111
|
+
if (fragmentResults.length > 0) {
|
|
112
|
+
console.log(` + ${fragmentResults.length} result(s) via involves (${personSubject.type}:${personSubject.id})`);
|
|
102
113
|
}
|
|
103
114
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Fragment lookup failed — proceed with group results only
|
|
107
118
|
}
|
|
108
119
|
const allResults = [...groupResults, ...fragmentResults];
|
|
109
120
|
if (allResults.length === 0) {
|
package/dist/index.js
CHANGED
|
@@ -21,7 +21,7 @@ import { join, dirname } from "node:path";
|
|
|
21
21
|
import { fileURLToPath } from "node:url";
|
|
22
22
|
import { rebacMemoryConfigSchema, createBackend, defaultGroupId } from "./config.js";
|
|
23
23
|
import { SpiceDbClient } from "./spicedb.js";
|
|
24
|
-
import { lookupAuthorizedGroups, lookupViewableFragments, lookupAgentOwner, writeFragmentRelationships, deleteFragmentRelationships, canDeleteFragment, canWriteToGroup, ensureGroupMembership, } from "./authorization.js";
|
|
24
|
+
import { lookupAuthorizedGroups, lookupViewableFragments, lookupFragmentSourceGroups, lookupAgentOwner, writeFragmentRelationships, deleteFragmentRelationships, canDeleteFragment, canWriteToGroup, ensureGroupMembership, } from "./authorization.js";
|
|
25
25
|
import { searchAuthorizedMemories, formatDualResults, deduplicateSessionResults, } from "./search.js";
|
|
26
26
|
import { registerCommands } from "./cli.js";
|
|
27
27
|
// ============================================================================
|
|
@@ -154,8 +154,10 @@ const rebacMemoryPlugin = {
|
|
|
154
154
|
const groupResults = [...longTermResults, ...sessionResults];
|
|
155
155
|
// Owner-aware fragment search: if the subject is an agent with an owner,
|
|
156
156
|
// also find fragments where the owner is in `involves`.
|
|
157
|
+
// Uses search-then-post-filter: search the source groups for query relevance,
|
|
158
|
+
// then intersect with the authorized fragment set for security.
|
|
157
159
|
let ownerFragmentResults = [];
|
|
158
|
-
if (subject.type === "agent"
|
|
160
|
+
if (subject.type === "agent") {
|
|
159
161
|
try {
|
|
160
162
|
const ownerId = await lookupAgentOwner(spicedb, subject.id, state.lastWriteToken);
|
|
161
163
|
if (ownerId) {
|
|
@@ -165,7 +167,20 @@ const rebacMemoryPlugin = {
|
|
|
165
167
|
const groupResultIds = new Set(groupResults.map((r) => r.uuid));
|
|
166
168
|
const newIds = viewableIds.filter((id) => !groupResultIds.has(id));
|
|
167
169
|
if (newIds.length > 0) {
|
|
168
|
-
|
|
170
|
+
// Discover which groups the viewable fragments belong to
|
|
171
|
+
const ownerGroups = await lookupFragmentSourceGroups(spicedb, newIds, state.lastWriteToken);
|
|
172
|
+
const alreadySearched = new Set([...longTermGroups, ...sessionGroups]);
|
|
173
|
+
const newGroups = ownerGroups.filter(g => !alreadySearched.has(g));
|
|
174
|
+
if (newGroups.length > 0) {
|
|
175
|
+
const candidateResults = await searchAuthorizedMemories(backend, {
|
|
176
|
+
query,
|
|
177
|
+
groupIds: newGroups,
|
|
178
|
+
limit,
|
|
179
|
+
});
|
|
180
|
+
// Post-filter: only keep results the owner is authorized to view
|
|
181
|
+
const viewableSet = new Set(newIds);
|
|
182
|
+
ownerFragmentResults = candidateResults.filter(r => viewableSet.has(r.uuid));
|
|
183
|
+
}
|
|
169
184
|
}
|
|
170
185
|
}
|
|
171
186
|
}
|
package/package.json
CHANGED