Skip to content

Commit f73b9db

Browse files
authored
feat(memory): authorize scoped note access and durable cleanup (#9096)
* feat(memory): authorize scoped access and durable cleanup * refactor(memory): distinguish scoped note access controls Rename the scoped-note control kind from memory_access to memory_notes_access so it is distinct from graph memory_scope. The HTTP path, delegation audience sim:memory-access, and semantic operation id are unchanged. * chore(audits): exempt Mothership contract copies from the relative-import rule contracts:sync copies these files byte for byte from the worker, whose sibling imports are relative, so they cannot use the app's aliases. Exempt only files in lib/mothership/generated that carry the sync header, and drop their baseline entries. * fix(memory): read-lock note authorization, redact legacy tool calls, skip keyless cleanup Project note checks took the Project lock exclusively while already holding it shared, so overlapping workspace checks could deadlock; they now read under the shared Project lock and the permission-group read lock, as Project Files does. Exports also redact remember calls stored in the legacy top-level toolCalls list. A Sim without COPILOT_API_KEY never reached a worker, so it queues no memory cleanup it could not deliver. * test(memory): use the shared env and outbox mocks for cleanup enqueue
1 parent ee081a5 commit f73b9db

22 files changed

Lines changed: 1495 additions & 44 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { resolveMemoryNotesAccessContract } from '@/lib/api/contracts/mothership-memories'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
} from '@/lib/api/server/routes'
7+
import { internalCopilotAuth } from '@/lib/mothership/auth/internal'
8+
import {
9+
MEMORY_NOTES_ACCESS_AUDIENCE,
10+
resolveMemoryNotesAccess,
11+
resolveMemoryNotesAccessOperation,
12+
} from '@/lib/mothership/memories/application/resolve-access'
13+
14+
export const POST = defineInternalJsonRoute({
15+
contract: resolveMemoryNotesAccessContract,
16+
auth: internalCopilotAuth(MEMORY_NOTES_ACCESS_AUDIENCE, { organization: true }),
17+
operation: resolveMemoryNotesAccessOperation,
18+
rateLimit: internalRateLimits.none({
19+
reason:
20+
'Private worker preflight rechecks live memory authority without reading or writing note content.',
21+
}),
22+
errorPolicy: internalOrchestrationErrorPolicy,
23+
mapInput: ({ body }) => body,
24+
useCase: resolveMemoryNotesAccess,
25+
})

‎apps/sim/lib/api/contracts/mothership-assistant-tools.ts‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -404,12 +404,26 @@ export const listWorkspacesResultSchema = z.object({
404404
nextCursor: z.uuid().nullable(),
405405
storedNotes: z
406406
.array(
407-
z.object({
408-
id: z.string(),
409-
kind: z.enum(['user_pref', 'workspace_fact']),
410-
content: z.string(),
411-
updatedAt: z.iso.datetime(),
412-
})
407+
z
408+
.object({
409+
id: z.string(),
410+
content: z.string(),
411+
updatedAt: z.iso.datetime(),
412+
by: z.enum(['this user', 'another workspace member', 'another user']).optional(),
413+
chat: z.enum(['this chat', 'another chat']).optional(),
414+
})
415+
.and(
416+
z.union([
417+
z.object({ kind: z.enum(['user_pref', 'workspace_fact']) }),
418+
z.object({
419+
scope: z.enum(['personal', 'project', 'workspace']),
420+
visibility: z.enum(['private', 'shared']),
421+
projectId: z.uuid().nullable(),
422+
workspaceId: z.uuid().nullable(),
423+
revision: z.number().int().positive(),
424+
}),
425+
])
426+
)
413427
)
414428
.optional(),
415429
})
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { defineRouteContract } from '@/lib/api/contracts/types'
2+
import {
3+
MemoryNotesAccessInput,
4+
MemoryNotesAccessResult,
5+
} from '@/lib/mothership/generated/memory-notes-access'
6+
7+
export const resolveMemoryNotesAccessContract = defineRouteContract({
8+
method: 'POST',
9+
path: '/api/mothership/memories/access',
10+
body: MemoryNotesAccessInput,
11+
response: { mode: 'json', schema: MemoryNotesAccessResult },
12+
})

‎apps/sim/lib/core/outbox/handlers.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
import { KNOWLEDGE_DOCUMENT_RECOVERY_OUTBOX_EVENT } from '@/lib/knowledge/documents/processing-recovery-event'
3434
import { KNOWLEDGE_STORAGE_CLEANUP_EVENT } from '@/lib/knowledge/documents/storage-cleanup-event'
3535
import { INBOX_CLEANUP_EVENT } from '@/lib/mothership/inbox/cleanup-event'
36+
import { MEMORY_CLEANUP_EVENT } from '@/lib/mothership/memories/cleanup-event'
3637
import { ORGANIZATION_RESOURCE_CLEANUP_EVENT } from '@/lib/organizations/resource-cleanup-event'
3738
import {
3839
PROJECT_FILE_DOCUMENT_RETIRE_EVENT,
@@ -68,6 +69,15 @@ import { FORK_CONTENT_COPY_EVENT } from '@/ee/workspace-forking/application/cont
6869
* export of the module as used.
6970
*/
7071
export const OUTBOX_HANDLER_GROUPS: readonly LazyOutboxHandlerGroup[] = [
72+
{
73+
events: [MEMORY_CLEANUP_EVENT],
74+
load: async () => {
75+
const { memoryCleanupOutboxHandlers } = await import(
76+
'@/lib/mothership/memories/cleanup-outbox'
77+
)
78+
return memoryCleanupOutboxHandlers
79+
},
80+
},
7181
{
7282
events: [SLACK_SEARCH_TURN_EVENT],
7383
load: async () => {

‎apps/sim/lib/data-drains/sources/copilot-chats.ts‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { copilotChats, copilotMessages } from '@sim/db/schema'
2+
import { isRecordLike, omit } from '@sim/utils/object'
23
import { and, asc, eq, inArray, isNull, or, sql } from 'drizzle-orm'
4+
import { persistedContentBlockSchema } from '@/lib/api/contracts/copilot-messages'
35
import { assertKnownSizeWithinLimit } from '@/lib/core/utils/stream-limits'
46
import { DATA_DRAIN_LIMITS } from '@/lib/data-drains/limits'
57
import {
@@ -11,6 +13,7 @@ import {
1113
} from '@/lib/data-drains/sources/cursor'
1214
import { readBoundedSourcePage, workspaceInOrganization } from '@/lib/data-drains/sources/helpers'
1315
import type { Cursor, DrainSource, SourcePageInput } from '@/lib/data-drains/types'
16+
import { stripToolResultOutput } from '@/lib/mothership/chat/persisted-message'
1417

1518
/**
1619
* The transcript no longer lives on `copilot_chats.messages` — it is assembled
@@ -141,6 +144,47 @@ async function* pages(input: SourcePageInput): AsyncIterable<CopilotChatRow[]> {
141144
}
142145
}
143146

147+
/** A message written before content blocks keeps its tool calls in a top-level `toolCalls` list. */
148+
function projectLegacyToolCall(call: unknown): unknown {
149+
if (!isRecordLike(call) || call.name !== 'remember') return call
150+
return {
151+
name: 'remember',
152+
...(typeof call.id === 'string' ? { id: call.id } : {}),
153+
...(typeof call.status === 'string' ? { status: call.status } : {}),
154+
}
155+
}
156+
157+
/** Tool payloads can contain private notes even when their mutation failed or targeted a shared note. */
158+
function projectExportMessage(message: unknown): unknown {
159+
if (!isRecordLike(message)) return message
160+
const projected = Array.isArray(message.toolCalls)
161+
? { ...message, toolCalls: message.toolCalls.map(projectLegacyToolCall) }
162+
: message
163+
if (!Array.isArray(projected.contentBlocks)) return projected
164+
return {
165+
...projected,
166+
contentBlocks: projected.contentBlocks.map((block: unknown) => {
167+
if (!isRecordLike(block) || !isRecordLike(block.toolCall)) return block
168+
if (block.toolCall.name !== 'remember') {
169+
if (!isRecordLike(block.toolCall.result) || !('output' in block.toolCall.result))
170+
return block
171+
const parsed = persistedContentBlockSchema.safeParse(block)
172+
if (!parsed.success) return { ...block, toolCall: omit(block.toolCall, ['result']) }
173+
const projected = stripToolResultOutput({ contentBlocks: [parsed.data] }).contentBlocks[0]
174+
return { ...block, toolCall: { ...block.toolCall, result: projected.toolCall?.result } }
175+
}
176+
return {
177+
type: 'tool',
178+
toolCall: {
179+
name: 'remember',
180+
...(typeof block.toolCall.id === 'string' ? { id: block.toolCall.id } : {}),
181+
...(typeof block.toolCall.state === 'string' ? { state: block.toolCall.state } : {}),
182+
},
183+
}
184+
}),
185+
}
186+
}
187+
144188
export const copilotChatsSource: DrainSource<CopilotChatRow> = {
145189
type: 'copilot_chats',
146190
displayName: 'Chats',
@@ -153,7 +197,7 @@ export const copilotChatsSource: DrainSource<CopilotChatRow> = {
153197
workspaceId: row.workspaceId,
154198
type: row.type,
155199
title: row.title,
156-
messages: row.messages,
200+
messages: row.messages.map(projectExportMessage),
157201
model: row.model,
158202
conversationId: row.conversationId,
159203
previewYaml: row.previewYaml,

‎apps/sim/lib/data-drains/sources/coverage.integration.ts‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,98 @@ describe('Drain source coverage and payload safety in PostgreSQL', () => {
113113
expect(runs).toEqual([runId])
114114
})
115115

116+
it('omits memory payloads from exports without changing owner history or visible replies', async () => {
117+
const messageId = generateId()
118+
const content = {
119+
role: 'assistant',
120+
content: 'Visible reply',
121+
toolCalls: [
122+
{
123+
id: 'legacy',
124+
name: 'remember',
125+
status: 'success',
126+
params: { content: 'hidden-memory-legacy-args' },
127+
result: 'hidden-memory-legacy-result',
128+
error: 'hidden-memory-legacy-error',
129+
},
130+
{
131+
id: 'legacy-read',
132+
name: 'read',
133+
status: 'success',
134+
params: { path: 'visible-legacy-path' },
135+
},
136+
],
137+
contentBlocks: [
138+
{ type: 'text', content: 'Visible reply' },
139+
{
140+
type: 'tool',
141+
toolCall: {
142+
id: 'discovery',
143+
name: 'list_workspaces',
144+
state: 'success',
145+
result: {
146+
success: true,
147+
output: {
148+
stdout: JSON.stringify({ storedNotes: [{ content: 'hidden-memory-discovery' }] }),
149+
},
150+
},
151+
},
152+
},
153+
...['private', 'shared', 'unknown'].map((visibility) => ({
154+
type: 'tool',
155+
content: 'hidden-memory-content',
156+
error: 'hidden-memory-error',
157+
toolCall: {
158+
id: visibility,
159+
name: 'remember',
160+
state: 'success',
161+
params: { visibility, content: 'hidden-memory-args' },
162+
result: { success: true, output: 'hidden-memory-result' },
163+
activityDescription: 'hidden-memory-activity',
164+
display: { title: 'hidden-memory-title' },
165+
},
166+
})),
167+
{
168+
type: 'tool',
169+
toolCall: {
170+
id: 'other',
171+
name: 'read',
172+
state: 'success',
173+
params: { path: 'visible-path' },
174+
},
175+
},
176+
],
177+
}
178+
await runtime.db.insert(runtime.schema.copilotMessages).values({
179+
id: messageId,
180+
messageId,
181+
chatId,
182+
role: 'assistant',
183+
content,
184+
createdAt: old,
185+
})
186+
try {
187+
const exported: unknown[] = []
188+
for await (const rows of runtime.copilotChatsSource.pages(input())) {
189+
exported.push(...rows.map((row) => runtime.copilotChatsSource.serialize(row)))
190+
}
191+
const wire = JSON.stringify(exported)
192+
expect(wire).not.toContain('hidden-memory')
193+
expect(wire).toContain('Visible reply')
194+
expect(wire).toContain('visible-path')
195+
expect(wire).toContain('visible-legacy-path')
196+
const [stored] = await runtime.db
197+
.select({ content: runtime.schema.copilotMessages.content })
198+
.from(runtime.schema.copilotMessages)
199+
.where(runtime.eq(runtime.schema.copilotMessages.id, messageId))
200+
expect(stored.content).toEqual(content)
201+
} finally {
202+
await runtime.db
203+
.delete(runtime.schema.copilotMessages)
204+
.where(runtime.eq(runtime.schema.copilotMessages.id, messageId))
205+
}
206+
})
207+
116208
it('exports correlation metadata without internal controller tokens or billing receipts', async () => {
117209
const [row] = await runtime.db
118210
.select()

‎apps/sim/lib/mothership/chat/persisted-message.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,9 @@ export interface PersistedMessage {
141141
* reference when there is nothing to strip, preserving memoized identity for
142142
* read-side consumers.
143143
*/
144-
export function stripToolResultOutput(message: PersistedMessage): PersistedMessage {
144+
export function stripToolResultOutput<T extends Pick<PersistedMessage, 'contentBlocks'>>(
145+
message: T
146+
): T {
145147
if (!message.contentBlocks?.length) return message
146148
let changed = false
147149
const contentBlocks = message.contentBlocks.map((block) => {
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// GENERATED — do not edit. Source of truth: mothership worker packages/contracts/src/memory-notes-access.ts
2+
// Regenerate with `bun run contracts:sync` in the worker.
3+
4+
import { z } from "zod";
5+
6+
const permissionsSchema = z.strictObject({
7+
read: z.literal(true),
8+
privateMutation: z.literal(true),
9+
sharedMutation: z.boolean(),
10+
});
11+
const projectSchema = z.strictObject({
12+
scope: z.literal("project"),
13+
id: z.uuid(),
14+
name: z.string(),
15+
permissions: permissionsSchema,
16+
});
17+
18+
export const MemoryNotesAccessInput = z.strictObject({
19+
target: z.discriminatedUnion("scope", [
20+
z.strictObject({ scope: z.literal("personal") }),
21+
z.strictObject({ scope: z.literal("workspace"), workspaceId: z.uuid() }),
22+
z.strictObject({ scope: z.literal("project"), projectId: z.uuid() }),
23+
]),
24+
action: z.enum(["read", "private_mutation", "shared_mutation"]),
25+
});
26+
export type MemoryNotesAccessInput = z.infer<typeof MemoryNotesAccessInput>;
27+
28+
export const MemoryNotesAccessResult = z.strictObject({
29+
success: z.literal(true),
30+
version: z.literal(1),
31+
actor: z.strictObject({ userId: z.string().min(1), chatId: z.uuid() }),
32+
target: z.discriminatedUnion("scope", [
33+
z.strictObject({
34+
scope: z.literal("personal"),
35+
permissions: permissionsSchema.extend({ sharedMutation: z.literal(false) }),
36+
}),
37+
projectSchema,
38+
z.strictObject({
39+
scope: z.literal("workspace"),
40+
id: z.uuid(),
41+
name: z.string(),
42+
permissions: permissionsSchema,
43+
currentProject: projectSchema.nullable(),
44+
}),
45+
]),
46+
});
47+
export type MemoryNotesAccessResult = z.infer<typeof MemoryNotesAccessResult>;
48+
49+
export const MemoryCleanupInput = z.strictObject({
50+
version: z.literal(1),
51+
target: z.discriminatedUnion("kind", [
52+
z.strictObject({ kind: z.literal("private_owner"), userId: z.string().min(1).max(200) }),
53+
z.strictObject({ kind: z.literal("workspace"), workspaceId: z.uuid() }),
54+
z.strictObject({ kind: z.literal("project"), projectId: z.uuid() }),
55+
]),
56+
});
57+
export type MemoryCleanupInput = z.infer<typeof MemoryCleanupInput>;
58+
export const MemoryCleanupResult = z.strictObject({ success: z.literal(true) });

‎apps/sim/lib/mothership/generated/sim-assistant-tools.generated.ts‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -424,12 +424,26 @@ export const listWorkspacesResultSchema = z.object({
424424
nextCursor: z.uuid().nullable(),
425425
storedNotes: z
426426
.array(
427-
z.object({
428-
id: z.string(),
429-
kind: z.enum(['user_pref', 'workspace_fact']),
430-
content: z.string(),
431-
updatedAt: z.iso.datetime(),
432-
})
427+
z
428+
.object({
429+
id: z.string(),
430+
content: z.string(),
431+
updatedAt: z.iso.datetime(),
432+
by: z.enum(['this user', 'another workspace member', 'another user']).optional(),
433+
chat: z.enum(['this chat', 'another chat']).optional(),
434+
})
435+
.and(
436+
z.union([
437+
z.object({ kind: z.enum(['user_pref', 'workspace_fact']) }),
438+
z.object({
439+
scope: z.enum(['personal', 'project', 'workspace']),
440+
visibility: z.enum(['private', 'shared']),
441+
projectId: z.uuid().nullable(),
442+
workspaceId: z.uuid().nullable(),
443+
revision: z.number().int().positive(),
444+
}),
445+
])
446+
)
433447
)
434448
.optional(),
435449
})

‎apps/sim/lib/mothership/generated/sim-transport.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import { z } from "zod";
55
import { IntegrationCatalogRequest } from "./integration-catalog";
6+
import { MemoryNotesAccessInput } from "./memory-notes-access";
67
import { MemoryScopeRequest } from "./memory-scope";
78
import { RunControlRequest } from "./run-control";
89
import { TaskWakeRequest, WorkflowWatchRequest } from "./tasks";
@@ -29,6 +30,7 @@ export type SimScope = z.infer<typeof SimScope>;
2930

3031
/** Only idempotent controls use the idle channel; tool effects use the run event log. */
3132
export const SimControlOperation = z.discriminatedUnion("kind", [
33+
z.object({ kind: z.literal("memory_notes_access"), input: MemoryNotesAccessInput }),
3234
z.object({ kind: z.literal("memory_scope"), input: MemoryScopeRequest }),
3335
z.object({ kind: z.literal("run_control"), input: RunControlRequest }),
3436
z.object({ kind: z.literal("workflow_status"), input: WorkflowWatchRequest }),

0 commit comments

Comments
 (0)