@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 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
- permission view = involves + shared_by + source_group->access
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
  ```
@@ -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).
@@ -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
- if (backend.getFragmentsByIds) {
76
- try {
77
- // Determine the person to search as
78
- let personSubject;
79
- if (subject.type === "person") {
80
- // Direct person search — look up fragments they can view
81
- personSubject = subject;
82
- }
83
- else if (subject.type === "agent") {
84
- // Agent search resolve owner identity first
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
- if (personSubject) {
91
- const viewableIds = await lookupViewableFragments(spicedb, personSubject, token);
92
- if (viewableIds.length > 0) {
93
- const groupResultIds = new Set(groupResults.map((r) => r.uuid));
94
- const newIds = viewableIds.filter((id) => !groupResultIds.has(id));
95
- if (newIds.length > 0) {
96
- fragmentResults = await backend.getFragmentsByIds(newIds);
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
- if (fragmentResults.length > 0) {
100
- console.log(` + ${fragmentResults.length} result(s) via involves (${personSubject.type}:${personSubject.id})`);
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
- catch {
105
- // Fragment lookup failed — proceed with group results only
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
- console.log(`Linked agent:${agentId} → person:${personId}`);
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
- console.log(`Unlinked agent:${agentId} (was → person:${ownerId})`);
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" && backend.getFragmentsByIds) {
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
- ownerFragmentResults = await backend.getFragmentsByIds(newIds);
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} person:${personId}`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contextableai/openclaw-memory-rebac",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "OpenClaw two-layer memory plugin: SpiceDB ReBAC authorization + Graphiti knowledge graph",
5
5
  "type": "module",
6
6
  "license": "MIT",
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, or have access to the source group
20
- permission view = involves + shared_by + source_group->access
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
  }