@contextableai/openclaw-memory-rebac 0.3.2 → 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/README.md +6 -2
- 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 +63 -34
- package/dist/index.js +30 -6
- package/package.json +1 -1
- package/schema.zed +7 -3
package/README.md
CHANGED
|
@@ -168,7 +168,10 @@ When enabled (default: `true`), the plugin captures the last N messages from eac
|
|
|
168
168
|
The SpiceDB schema defines four object types:
|
|
169
169
|
|
|
170
170
|
```
|
|
171
|
-
definition person {
|
|
171
|
+
definition person {
|
|
172
|
+
relation agent: agent
|
|
173
|
+
permission represents = agent
|
|
174
|
+
}
|
|
172
175
|
|
|
173
176
|
definition agent {
|
|
174
177
|
relation owner: person
|
|
@@ -186,7 +189,8 @@ definition memory_fragment {
|
|
|
186
189
|
relation involves: person | agent
|
|
187
190
|
relation shared_by: person | agent
|
|
188
191
|
|
|
189
|
-
|
|
192
|
+
// involves->represents: if a person is involved, their agent can also view
|
|
193
|
+
permission view = involves + shared_by + source_group->access + involves->represents
|
|
190
194
|
permission delete = shared_by
|
|
191
195
|
}
|
|
192
196
|
```
|
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) {
|
|
@@ -563,14 +574,23 @@ export function registerCommands(cmd, ctx) {
|
|
|
563
574
|
.argument("<person-id>", "Owner person ID")
|
|
564
575
|
.action(async (agentId, personId) => {
|
|
565
576
|
try {
|
|
566
|
-
await spicedb.writeRelationships([
|
|
577
|
+
await spicedb.writeRelationships([
|
|
578
|
+
{
|
|
567
579
|
resourceType: "agent",
|
|
568
580
|
resourceId: agentId,
|
|
569
581
|
relation: "owner",
|
|
570
582
|
subjectType: "person",
|
|
571
583
|
subjectId: personId,
|
|
572
|
-
}
|
|
573
|
-
|
|
584
|
+
},
|
|
585
|
+
{
|
|
586
|
+
resourceType: "person",
|
|
587
|
+
resourceId: personId,
|
|
588
|
+
relation: "agent",
|
|
589
|
+
subjectType: "agent",
|
|
590
|
+
subjectId: agentId,
|
|
591
|
+
},
|
|
592
|
+
]);
|
|
593
|
+
console.log(`Linked agent:${agentId} ↔ person:${personId}`);
|
|
574
594
|
}
|
|
575
595
|
catch (err) {
|
|
576
596
|
console.error(`Failed to write identity link: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -590,14 +610,23 @@ export function registerCommands(cmd, ctx) {
|
|
|
590
610
|
console.log(`No owner link found for agent:${agentId}`);
|
|
591
611
|
return;
|
|
592
612
|
}
|
|
593
|
-
await spicedb.deleteRelationships([
|
|
613
|
+
await spicedb.deleteRelationships([
|
|
614
|
+
{
|
|
594
615
|
resourceType: "agent",
|
|
595
616
|
resourceId: agentId,
|
|
596
617
|
relation: "owner",
|
|
597
618
|
subjectType: "person",
|
|
598
619
|
subjectId: ownerId,
|
|
599
|
-
}
|
|
600
|
-
|
|
620
|
+
},
|
|
621
|
+
{
|
|
622
|
+
resourceType: "person",
|
|
623
|
+
resourceId: ownerId,
|
|
624
|
+
relation: "agent",
|
|
625
|
+
subjectType: "agent",
|
|
626
|
+
subjectId: agentId,
|
|
627
|
+
},
|
|
628
|
+
]);
|
|
629
|
+
console.log(`Unlinked agent:${agentId} (was ↔ person:${ownerId})`);
|
|
601
630
|
}
|
|
602
631
|
catch (err) {
|
|
603
632
|
console.error(`Failed to remove identity link: ${err instanceof Error ? err.message : String(err)}`);
|
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
|
}
|
|
@@ -686,16 +701,25 @@ const rebacMemoryPlugin = {
|
|
|
686
701
|
// Write agent → owner relationships from identities config
|
|
687
702
|
for (const [agentId, personId] of Object.entries(cfg.identities)) {
|
|
688
703
|
try {
|
|
689
|
-
const token = await spicedb.writeRelationships([
|
|
704
|
+
const token = await spicedb.writeRelationships([
|
|
705
|
+
{
|
|
690
706
|
resourceType: "agent",
|
|
691
707
|
resourceId: agentId,
|
|
692
708
|
relation: "owner",
|
|
693
709
|
subjectType: "person",
|
|
694
710
|
subjectId: personId,
|
|
695
|
-
}
|
|
711
|
+
},
|
|
712
|
+
{
|
|
713
|
+
resourceType: "person",
|
|
714
|
+
resourceId: personId,
|
|
715
|
+
relation: "agent",
|
|
716
|
+
subjectType: "agent",
|
|
717
|
+
subjectId: agentId,
|
|
718
|
+
},
|
|
719
|
+
]);
|
|
696
720
|
if (token)
|
|
697
721
|
defaultState.lastWriteToken = token;
|
|
698
|
-
api.logger.info(`openclaw-memory-rebac: linked agent:${agentId}
|
|
722
|
+
api.logger.info(`openclaw-memory-rebac: linked agent:${agentId} ↔ person:${personId}`);
|
|
699
723
|
}
|
|
700
724
|
catch (err) {
|
|
701
725
|
api.logger.warn(`openclaw-memory-rebac: failed to write owner for agent:${agentId}: ${err}`);
|
package/package.json
CHANGED
package/schema.zed
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
definition person {
|
|
1
|
+
definition person {
|
|
2
|
+
relation agent: agent
|
|
3
|
+
permission represents = agent
|
|
4
|
+
}
|
|
2
5
|
|
|
3
6
|
definition agent {
|
|
4
7
|
relation owner: person
|
|
@@ -16,8 +19,9 @@ definition memory_fragment {
|
|
|
16
19
|
relation involves: person | agent
|
|
17
20
|
relation shared_by: person | agent
|
|
18
21
|
|
|
19
|
-
// Can view if: directly involved, shared it,
|
|
20
|
-
|
|
22
|
+
// Can view if: directly involved, shared it, have access to the source group,
|
|
23
|
+
// or are an agent whose owner is involved (involves->agent traversal)
|
|
24
|
+
permission view = involves + shared_by + source_group->access + involves->represents
|
|
21
25
|
// Can delete if: you shared it (owner-level control)
|
|
22
26
|
permission delete = shared_by
|
|
23
27
|
}
|