fix(security): scope memory tools to session userId — M2-003/004 #294
@@ -106,17 +106,22 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the full set of custom tools scoped to the given sandbox directory.
|
* Build the full set of custom tools scoped to the given sandbox directory and session user.
|
||||||
* Brain/coord/memory/web tools are stateless with respect to cwd; file/git/shell
|
* Brain/coord/memory/web tools are stateless with respect to cwd; file/git/shell
|
||||||
* tools receive the resolved sandboxDir so they operate within the sandbox.
|
* tools receive the resolved sandboxDir so they operate within the sandbox.
|
||||||
|
* Memory tools are bound to sessionUserId so the LLM cannot access another user's data.
|
||||||
*/
|
*/
|
||||||
private buildToolsForSandbox(sandboxDir: string): ToolDefinition[] {
|
private buildToolsForSandbox(
|
||||||
|
sandboxDir: string,
|
||||||
|
sessionUserId: string | undefined,
|
||||||
|
): ToolDefinition[] {
|
||||||
return [
|
return [
|
||||||
...createBrainTools(this.brain),
|
...createBrainTools(this.brain),
|
||||||
...createCoordTools(this.coordService),
|
...createCoordTools(this.coordService),
|
||||||
...createMemoryTools(
|
...createMemoryTools(
|
||||||
this.memory,
|
this.memory,
|
||||||
this.embeddingService.available ? this.embeddingService : null,
|
this.embeddingService.available ? this.embeddingService : null,
|
||||||
|
sessionUserId,
|
||||||
),
|
),
|
||||||
...createFileTools(sandboxDir),
|
...createFileTools(sandboxDir),
|
||||||
...createGitTools(sandboxDir),
|
...createGitTools(sandboxDir),
|
||||||
@@ -216,8 +221,8 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build per-session tools scoped to the sandbox directory
|
// Build per-session tools scoped to the sandbox directory and authenticated user
|
||||||
const sandboxTools = this.buildToolsForSandbox(sandboxDir);
|
const sandboxTools = this.buildToolsForSandbox(sandboxDir, mergedOptions?.userId);
|
||||||
|
|
||||||
// Combine static tools with dynamically discovered MCP client tools and skill tools
|
// Combine static tools with dynamically discovered MCP client tools and skill tools
|
||||||
const mcpTools = this.mcpClientService.getToolDefinitions();
|
const mcpTools = this.mcpClientService.getToolDefinitions();
|
||||||
|
|||||||
@@ -3,23 +3,45 @@ import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
|||||||
import type { Memory } from '@mosaic/memory';
|
import type { Memory } from '@mosaic/memory';
|
||||||
import type { EmbeddingProvider } from '@mosaic/memory';
|
import type { EmbeddingProvider } from '@mosaic/memory';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create memory tools bound to the session's authenticated userId.
|
||||||
|
*
|
||||||
|
* SECURITY: userId is resolved from the authenticated session at tool-creation
|
||||||
|
* time and is never accepted as a user-supplied or LLM-supplied parameter.
|
||||||
|
* This prevents cross-user data access via parameter injection.
|
||||||
|
*/
|
||||||
export function createMemoryTools(
|
export function createMemoryTools(
|
||||||
memory: Memory,
|
memory: Memory,
|
||||||
embeddingProvider: EmbeddingProvider | null,
|
embeddingProvider: EmbeddingProvider | null,
|
||||||
|
/** Authenticated user ID from the session. All memory operations are scoped to this user. */
|
||||||
|
sessionUserId: string | undefined,
|
||||||
): ToolDefinition[] {
|
): ToolDefinition[] {
|
||||||
|
/** Return an error result when no session user is bound. */
|
||||||
|
function noUserError() {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: 'Memory tools unavailable — no authenticated user bound to this session',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const searchMemory: ToolDefinition = {
|
const searchMemory: ToolDefinition = {
|
||||||
name: 'memory_search',
|
name: 'memory_search',
|
||||||
label: 'Search Memory',
|
label: 'Search Memory',
|
||||||
description:
|
description:
|
||||||
'Search across stored insights and knowledge using natural language. Returns semantically similar results.',
|
'Search across stored insights and knowledge using natural language. Returns semantically similar results.',
|
||||||
parameters: Type.Object({
|
parameters: Type.Object({
|
||||||
userId: Type.String({ description: 'User ID to search memory for' }),
|
|
||||||
query: Type.String({ description: 'Natural language search query' }),
|
query: Type.String({ description: 'Natural language search query' }),
|
||||||
limit: Type.Optional(Type.Number({ description: 'Max results (default 5)' })),
|
limit: Type.Optional(Type.Number({ description: 'Max results (default 5)' })),
|
||||||
}),
|
}),
|
||||||
async execute(_toolCallId, params) {
|
async execute(_toolCallId, params) {
|
||||||
const { userId, query, limit } = params as {
|
if (!sessionUserId) return noUserError();
|
||||||
userId: string;
|
|
||||||
|
const { query, limit } = params as {
|
||||||
query: string;
|
query: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
};
|
};
|
||||||
@@ -37,7 +59,7 @@ export function createMemoryTools(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const embedding = await embeddingProvider.embed(query);
|
const embedding = await embeddingProvider.embed(query);
|
||||||
const results = await memory.insights.searchByEmbedding(userId, embedding, limit ?? 5);
|
const results = await memory.insights.searchByEmbedding(sessionUserId, embedding, limit ?? 5);
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }],
|
content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }],
|
||||||
details: undefined,
|
details: undefined,
|
||||||
@@ -48,9 +70,8 @@ export function createMemoryTools(
|
|||||||
const getPreferences: ToolDefinition = {
|
const getPreferences: ToolDefinition = {
|
||||||
name: 'memory_get_preferences',
|
name: 'memory_get_preferences',
|
||||||
label: 'Get User Preferences',
|
label: 'Get User Preferences',
|
||||||
description: 'Retrieve stored preferences for a user.',
|
description: 'Retrieve stored preferences for the current session user.',
|
||||||
parameters: Type.Object({
|
parameters: Type.Object({
|
||||||
userId: Type.String({ description: 'User ID' }),
|
|
||||||
category: Type.Optional(
|
category: Type.Optional(
|
||||||
Type.String({
|
Type.String({
|
||||||
description: 'Filter by category: communication, coding, workflow, appearance, general',
|
description: 'Filter by category: communication, coding, workflow, appearance, general',
|
||||||
@@ -58,11 +79,13 @@ export function createMemoryTools(
|
|||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
async execute(_toolCallId, params) {
|
async execute(_toolCallId, params) {
|
||||||
const { userId, category } = params as { userId: string; category?: string };
|
if (!sessionUserId) return noUserError();
|
||||||
|
|
||||||
|
const { category } = params as { category?: string };
|
||||||
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
||||||
const prefs = category
|
const prefs = category
|
||||||
? await memory.preferences.findByUserAndCategory(userId, category as Cat)
|
? await memory.preferences.findByUserAndCategory(sessionUserId, category as Cat)
|
||||||
: await memory.preferences.findByUser(userId);
|
: await memory.preferences.findByUser(sessionUserId);
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify(prefs, null, 2) }],
|
content: [{ type: 'text' as const, text: JSON.stringify(prefs, null, 2) }],
|
||||||
details: undefined,
|
details: undefined,
|
||||||
@@ -76,7 +99,6 @@ export function createMemoryTools(
|
|||||||
description:
|
description:
|
||||||
'Store a learned user preference (e.g., "prefers tables over paragraphs", "timezone: America/Chicago").',
|
'Store a learned user preference (e.g., "prefers tables over paragraphs", "timezone: America/Chicago").',
|
||||||
parameters: Type.Object({
|
parameters: Type.Object({
|
||||||
userId: Type.String({ description: 'User ID' }),
|
|
||||||
key: Type.String({ description: 'Preference key' }),
|
key: Type.String({ description: 'Preference key' }),
|
||||||
value: Type.String({ description: 'Preference value (JSON string)' }),
|
value: Type.String({ description: 'Preference value (JSON string)' }),
|
||||||
category: Type.Optional(
|
category: Type.Optional(
|
||||||
@@ -86,8 +108,9 @@ export function createMemoryTools(
|
|||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
async execute(_toolCallId, params) {
|
async execute(_toolCallId, params) {
|
||||||
const { userId, key, value, category } = params as {
|
if (!sessionUserId) return noUserError();
|
||||||
userId: string;
|
|
||||||
|
const { key, value, category } = params as {
|
||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
@@ -100,7 +123,7 @@ export function createMemoryTools(
|
|||||||
parsedValue = value;
|
parsedValue = value;
|
||||||
}
|
}
|
||||||
const pref = await memory.preferences.upsert({
|
const pref = await memory.preferences.upsert({
|
||||||
userId,
|
userId: sessionUserId,
|
||||||
key,
|
key,
|
||||||
value: parsedValue,
|
value: parsedValue,
|
||||||
category: (category as Cat) ?? 'general',
|
category: (category as Cat) ?? 'general',
|
||||||
@@ -119,7 +142,6 @@ export function createMemoryTools(
|
|||||||
description:
|
description:
|
||||||
'Store a learned insight, decision, or knowledge extracted from the current interaction.',
|
'Store a learned insight, decision, or knowledge extracted from the current interaction.',
|
||||||
parameters: Type.Object({
|
parameters: Type.Object({
|
||||||
userId: Type.String({ description: 'User ID' }),
|
|
||||||
content: Type.String({ description: 'The insight or knowledge to store' }),
|
content: Type.String({ description: 'The insight or knowledge to store' }),
|
||||||
category: Type.Optional(
|
category: Type.Optional(
|
||||||
Type.String({
|
Type.String({
|
||||||
@@ -128,8 +150,9 @@ export function createMemoryTools(
|
|||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
async execute(_toolCallId, params) {
|
async execute(_toolCallId, params) {
|
||||||
const { userId, content, category } = params as {
|
if (!sessionUserId) return noUserError();
|
||||||
userId: string;
|
|
||||||
|
const { content, category } = params as {
|
||||||
content: string;
|
content: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
};
|
};
|
||||||
@@ -141,7 +164,7 @@ export function createMemoryTools(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const insight = await memory.insights.create({
|
const insight = await memory.insights.create({
|
||||||
userId,
|
userId: sessionUserId,
|
||||||
content,
|
content,
|
||||||
embedding,
|
embedding,
|
||||||
source: 'agent',
|
source: 'agent',
|
||||||
|
|||||||
Reference in New Issue
Block a user