Skip to content

Commit b0619b3

Browse files
mason: fail closed workspace memory sharing
Co-authored-by: Alfonso [Magic Context] <288211368+alfonso-magic-context@users.noreply.github.com>
1 parent 160350e commit b0619b3

10 files changed

Lines changed: 497 additions & 112 deletions

File tree

packages/pi-plugin/src/inject-compartments-pi.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,41 @@ describe("workspace memory sharing", () => {
109109
closeQuietly(db);
110110
}
111111
});
112+
113+
it("does not render foreign memories when share_categories is malformed", () => {
114+
const db = createTestDb();
115+
const dir = mkdtempSync(join(tmpdir(), "mc-pi-share-malformed-"));
116+
try {
117+
db.exec(`
118+
INSERT INTO workspaces (id, name, share_categories, created_at, updated_at)
119+
VALUES (1, 'ws', 'not-json', 1, 1);
120+
INSERT INTO workspace_members (workspace_id, project_path, display_name, display_path, added_at)
121+
VALUES (1, 'git:own', 'Own', '/own', 1), (1, 'git:foreign', 'Foreign', '/foreign', 1);
122+
`);
123+
insertMemory(db, {
124+
projectPath: "git:own",
125+
category: "CONSTRAINTS",
126+
content: "own malformed Pi memory remains visible",
127+
});
128+
insertMemory(db, {
129+
projectPath: "git:foreign",
130+
category: "CONSTRAINTS",
131+
content: "foreign malformed Pi memory is hidden",
132+
});
133+
const state = {
134+
sessionId: "pi-share-malformed",
135+
projectIdentity: "git:own",
136+
projectDirectory: dir,
137+
};
138+
139+
const m0 = renderM0Pi(state, db, "");
140+
expect(m0).toContain("own malformed Pi memory remains visible");
141+
expect(m0).not.toContain("foreign malformed Pi memory is hidden");
142+
} finally {
143+
rmSync(dir, { recursive: true, force: true });
144+
closeQuietly(db);
145+
}
146+
});
112147
});
113148

114149
describe("trimPiMessagesToBoundary", () => {

packages/pi-plugin/src/tools/ctx-memory.test.ts

Lines changed: 82 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ describe("createCtxMemoryTool", () => {
130130
}
131131
});
132132

133-
it("updates a foreign workspace memory under the target identity", async () => {
133+
it("rejects updating a foreign workspace memory even when the category is shared", async () => {
134134
const db = createTestDb();
135135
try {
136136
const primary = createCtxMemoryTool({
@@ -141,54 +141,46 @@ describe("createCtxMemoryTool", () => {
141141
});
142142
const ctx = fakeContext("ses-memory") as never;
143143
const ownIdentity = resolveProjectIdentity((ctx as { cwd: string }).cwd);
144-
// Default workspace shares CONSTRAINTS; this test exercises
145-
// target-identity routing, so use a shared category (foreign memory
146-
// visible) and verify the mutation routes under the target identity.
147144
db.exec(`
148145
INSERT INTO workspaces (id, name, created_at, updated_at) VALUES (1, 'ws', 1, 1);
149146
INSERT INTO workspace_members (workspace_id, project_path, display_name, display_path, added_at)
150147
VALUES (1, '${ownIdentity}', 'Own', '${ownIdentity}', 1),
151148
(1, 'git:foreign', 'Foreign', '/foreign', 1);
152149
`);
153-
insertMemory(db, {
154-
projectPath: ownIdentity,
155-
category: "CONSTRAINTS",
156-
content: "Use the shared formatter.",
157-
});
158150
const foreign = insertMemory(db, {
159151
projectPath: "git:foreign",
160152
category: "CONSTRAINTS",
161-
content: "Old foreign directive.",
153+
content: "Old foreign shared constraint.",
162154
});
163155

164156
const result = await primary.execute(
165157
"call-u",
166158
{
167159
action: "update",
168160
ids: [foreign.id],
169-
content: "Use the shared formatter.",
161+
content: "Updated foreign shared constraint.",
170162
},
171163
new AbortController().signal,
172164
undefined,
173165
ctx,
174166
);
175167

176-
expect(result.isError).toBeUndefined();
168+
expect(result.isError).toBe(true);
169+
expect(result.content[0]?.text).toBe(
170+
`Error: Memory with ID ${foreign.id} was not found.`,
171+
);
177172
expect(getMemoryById(db, foreign.id)?.content).toBe(
178-
"Use the shared formatter.",
173+
"Old foreign shared constraint.",
179174
);
180-
expect(
181-
getMemoryMutationsForRender(db, ownIdentity, 0, [foreign.id]),
182-
).toHaveLength(0);
183175
expect(
184176
getMemoryMutationsForRender(db, "git:foreign", 0, [foreign.id]),
185-
).toHaveLength(1);
177+
).toHaveLength(0);
186178
} finally {
187179
closeQuietly(db);
188180
}
189181
});
190182

191-
it("archives a foreign workspace memory under the target identity", async () => {
183+
it("rejects archiving a foreign workspace memory even when the category is shared", async () => {
192184
const db = createTestDb();
193185
try {
194186
const primary = createCtxMemoryTool({
@@ -199,8 +191,6 @@ describe("createCtxMemoryTool", () => {
199191
});
200192
const ctx = fakeContext("ses-memory") as never;
201193
const ownIdentity = resolveProjectIdentity((ctx as { cwd: string }).cwd);
202-
// Default workspace shares CONSTRAINTS; this test exercises
203-
// target-identity routing, so use a shared category (foreign visible).
204194
db.exec(`
205195
INSERT INTO workspaces (id, name, created_at, updated_at) VALUES (1, 'ws', 1, 1);
206196
INSERT INTO workspace_members (workspace_id, project_path, display_name, display_path, added_at)
@@ -210,7 +200,7 @@ describe("createCtxMemoryTool", () => {
210200
const foreign = insertMemory(db, {
211201
projectPath: "git:foreign",
212202
category: "CONSTRAINTS",
213-
content: "Foreign issue.",
203+
content: "Foreign shared constraint.",
214204
});
215205

216206
const result = await primary.execute(
@@ -221,11 +211,14 @@ describe("createCtxMemoryTool", () => {
221211
ctx,
222212
);
223213

224-
expect(result.isError).toBeUndefined();
225-
expect(getMemoryById(db, foreign.id)?.status).toBe("archived");
214+
expect(result.isError).toBe(true);
215+
expect(result.content[0]?.text).toBe(
216+
`Error: Memory with ID ${foreign.id} was not found.`,
217+
);
218+
expect(getMemoryById(db, foreign.id)?.status).toBe("active");
226219
expect(
227220
getMemoryMutationsForRender(db, "git:foreign", 0, [foreign.id]),
228-
).toHaveLength(1);
221+
).toHaveLength(0);
229222
} finally {
230223
closeQuietly(db);
231224
}
@@ -271,7 +264,7 @@ describe("createCtxMemoryTool", () => {
271264
}
272265
});
273266

274-
it("archives a foreign memory in a SHARED category (P0 parity)", async () => {
267+
it("rejects archiving a foreign memory in a SHARED category", async () => {
275268
const db = createTestDb();
276269
try {
277270
const primary = createCtxMemoryTool({
@@ -302,8 +295,11 @@ describe("createCtxMemoryTool", () => {
302295
ctx,
303296
);
304297

305-
expect(result.isError).toBeUndefined();
306-
expect(getMemoryById(db, foreignShared.id)?.status).toBe("archived");
298+
expect(result.isError).toBe(true);
299+
expect(result.content[0]?.text).toBe(
300+
`Error: Memory with ID ${foreignShared.id} was not found.`,
301+
);
302+
expect(getMemoryById(db, foreignShared.id)?.status).toBe("active");
307303
} finally {
308304
closeQuietly(db);
309305
}
@@ -424,7 +420,7 @@ describe("createCtxMemoryTool", () => {
424420
}
425421
});
426422

427-
it("allows a PRIMARY merge of a foreign SHARED-category memory (P0 parity)", async () => {
423+
it("rejects a PRIMARY merge of a foreign SHARED-category memory", async () => {
428424
const db = createTestDb();
429425
try {
430426
const primary = createCtxMemoryTool({
@@ -465,12 +461,12 @@ describe("createCtxMemoryTool", () => {
465461
ctx,
466462
);
467463

468-
// New merged content matches neither source, so a FRESH canonical is
469-
// inserted and both sources are superseded → archived (parity with
470-
// OpenCode's shared-merge test).
471-
expect(result.isError).toBeUndefined();
472-
expect(getMemoryById(db, own.id)?.status).toBe("archived");
473-
expect(getMemoryById(db, foreignShared.id)?.status).toBe("archived");
464+
expect(result.isError).toBe(true);
465+
expect(result.content[0]?.text).toBe(
466+
`Error: Memory with ID ${foreignShared.id} was not found.`,
467+
);
468+
expect(getMemoryById(db, own.id)?.status).toBe("active");
469+
expect(getMemoryById(db, foreignShared.id)?.status).toBe("active");
474470
} finally {
475471
closeQuietly(db);
476472
}
@@ -575,6 +571,58 @@ describe("createCtxMemoryTool", () => {
575571
}
576572
});
577573

574+
it("REFUSES a DREAMER merge when workspace share_categories is malformed", async () => {
575+
const db = createTestDb();
576+
try {
577+
const dreamer = createCtxMemoryTool({
578+
db,
579+
memoryEnabled: true,
580+
embeddingEnabled: false,
581+
allowDreamerActions: true,
582+
});
583+
const ctx = fakeContext("ses-dreamer") as never;
584+
const ownIdentity = resolveProjectIdentity((ctx as { cwd: string }).cwd);
585+
db.exec(`
586+
INSERT INTO workspaces (id, name, created_at, updated_at, share_categories) VALUES (1, 'ws', 1, 1, 'not-json');
587+
INSERT INTO workspace_members (workspace_id, project_path, display_name, display_path, added_at)
588+
VALUES (1, '${ownIdentity}', 'Own', '${ownIdentity}', 1),
589+
(1, 'git:foreign', 'Foreign', '/foreign', 1);
590+
`);
591+
const own = insertMemory(db, {
592+
projectPath: ownIdentity,
593+
category: "CONSTRAINTS",
594+
content: "Own constraint malformed policy.",
595+
});
596+
const foreign = insertMemory(db, {
597+
projectPath: "git:foreign",
598+
category: "CONSTRAINTS",
599+
content: "Foreign constraint hidden by malformed policy.",
600+
});
601+
602+
const result = await dreamer.execute(
603+
"call-d1-malformed",
604+
{
605+
action: "merge",
606+
ids: [own.id, foreign.id],
607+
content: "Merged malformed policy constraint.",
608+
category: "CONSTRAINTS",
609+
},
610+
new AbortController().signal,
611+
undefined,
612+
ctx,
613+
);
614+
615+
expect(result.isError).toBe(true);
616+
expect(result.content[0]?.text).toContain(
617+
"not shared with this workspace member",
618+
);
619+
expect(getMemoryById(db, own.id)?.status).toBe("active");
620+
expect(getMemoryById(db, foreign.id)?.status).toBe("active");
621+
} finally {
622+
closeQuietly(db);
623+
}
624+
});
625+
578626
it("ALLOWS a DREAMER merge of a foreign SHARED-category memory INSIDE a workspace (D1 parity)", async () => {
579627
const db = createTestDb();
580628
try {

packages/pi-plugin/src/tools/ctx-memory.ts

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -349,18 +349,18 @@ export function createCtxMemoryTool(
349349
expandedWorkspace.canonicalIdentityByStoredPath,
350350
) ?? normalizeStoredProjectPath(rawProjectPath))
351351
: normalizeStoredProjectPath(rawProjectPath);
352-
// The workspace's share-category policy, identical to the render path
353-
// (resolveWorkspaceRenderContextPi): null = share all categories.
352+
// The workspace's share-category policy matches the render path.
353+
// null means there is no workspace filter; a workspaced caller gets
354+
// an explicit list where [] shares no foreign categories.
354355
const toolShareCategories =
355356
workspaceIdentitySet.identities.length > 1
356357
? resolveWorkspaceShareCategories(deps.db, projectIdentity)
357358
: null;
358-
// Tool visibility MUST match render visibility, or the agent could
359-
// mutate (update/archive) a foreign workspace memory it can't even
360-
// see. Own-project memories: every category is mutable. Foreign member
361-
// memories: only when shared — shareCategories===null shares all, an
362-
// empty list shares none, otherwise only the listed categories.
363-
// Mirrors buildWorkspaceMemorySqlFilter's own/foreign split.
359+
// Visibility is the READ contract: own memories are visible in every
360+
// category, while foreign workspace memories are visible only in
361+
// categories the workspace explicitly shares. Mutations by primary
362+
// agents use memoryOwnedByTool below so shared visibility never
363+
// grants write access to another project.
364364
const memoryVisibleToTool = (memory: Memory): boolean => {
365365
if (workspaceIdentitySet.identities.length <= 1) {
366366
return storedPathBelongsToIdentity(
@@ -381,11 +381,12 @@ export function createCtxMemoryTool(
381381
const isOwn =
382382
targetIdentityForStoredPath(memory.projectPath) === projectIdentity;
383383
if (isOwn) return true;
384-
return (
385-
toolShareCategories === null ||
386-
toolShareCategories.includes(memory.category)
387-
);
384+
return toolShareCategories?.includes(memory.category) ?? false;
388385
};
386+
const memoryOwnedByTool = (memory: Memory): boolean =>
387+
workspaceIdentitySet.identities.length > 1
388+
? targetIdentityForStoredPath(memory.projectPath) === projectIdentity
389+
: storedPathBelongsToIdentity(memory.projectPath, projectIdentity);
389390
const snapshot = getProjectEmbeddingSnapshot(projectIdentity);
390391
if (
391392
snapshot
@@ -463,7 +464,12 @@ export function createCtxMemoryTool(
463464
}
464465

465466
const memory = getMemoryById(deps.db, updateId);
466-
if (!memory || !memoryVisibleToTool(memory)) {
467+
const updateAllowed = memory
468+
? dreamerAllowed
469+
? memoryVisibleToTool(memory)
470+
: memoryOwnedByTool(memory)
471+
: false;
472+
if (!memory || !updateAllowed) {
467473
return err(`Error: Memory with ID ${updateId} was not found.`);
468474
}
469475
if (!dreamerAllowed && !isPrimaryMutableMemory(memory)) {
@@ -542,7 +548,7 @@ export function createCtxMemoryTool(
542548
// update/archive ownership (parity with OpenCode).
543549
if (!dreamerAllowed) {
544550
const foreign = sourceMemories.find(
545-
(memory) => !memoryVisibleToTool(memory),
551+
(memory) => !memoryOwnedByTool(memory),
546552
);
547553
if (foreign) {
548554
return err(`Error: Memory with ID ${foreign.id} was not found.`);
@@ -749,7 +755,12 @@ export function createCtxMemoryTool(
749755
// half-archive a batch (all-or-nothing, matching the transaction).
750756
for (const memoryId of archiveIds) {
751757
const memory = getMemoryById(deps.db, memoryId);
752-
if (!memory || !memoryVisibleToTool(memory)) {
758+
const archiveAllowed = memory
759+
? dreamerAllowed
760+
? memoryVisibleToTool(memory)
761+
: memoryOwnedByTool(memory)
762+
: false;
763+
if (!memory || !archiveAllowed) {
753764
return err(`Error: Memory with ID ${memoryId} was not found.`);
754765
}
755766
if (!dreamerAllowed && !isPrimaryMutableMemory(memory)) {

packages/plugin/src/features/magic-context/memory/storage-memory.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,7 @@ function uniqueValues(values: readonly string[]): string[] {
599599

600600
export interface WorkspaceMemorySharingFilter {
601601
ownIdentities?: readonly string[];
602+
/** null/undefined means no workspace filter is active; pass [] to share no foreign categories. */
602603
shareCategories?: readonly string[] | null;
603604
}
604605

0 commit comments

Comments
 (0)