From c7b2df9e344aea8b969517d6db98fb6decf2cf4d Mon Sep 17 00:00:00 2001 From: viettranx Date: Thu, 23 Apr 2026 18:43:31 +0700 Subject: [PATCH] feat(vault): chat_id isolation for isolated teams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add vault_documents.chat_id + composite index (migration 000056) - Filter vault_search by chat_id when team.workspace_scope=isolated - Stamp chat_id on AfterWrite/AfterWriteMedia for isolated teams - Deny cross-chat vault_read in isolated teams (M2 fix) - RunContext.TeamIsolated flag resolved once per run - Fallback WorkspaceChatID → ChatID in loop_context for entry points that don't set WorkspaceChatID explicitly (WS direct, HTTP, cron) Fixes cross-chat doc leak where agent in chat A could see vault docs from chat B within the same isolated team. --- cmd/gateway_consumer_normal.go | 1 + internal/agent/loop_context.go | 42 +++++- internal/gateway/methods/chat.go | 15 ++- internal/store/pg/vault_documents.go | 70 +++++++--- .../store/pg/vault_documents_enrichment.go | 4 +- internal/store/pg/vault_scan_rows.go | 5 + internal/store/pg/vault_source_cleanup.go | 11 +- internal/store/run_context.go | 1 + internal/store/sqlitestore/schema.go | 7 +- internal/store/sqlitestore/schema.sql | 2 + .../sqlitestore/schema_migration_test.go | 7 + internal/store/sqlitestore/vault_documents.go | 41 ++++-- internal/store/vault_store.go | 3 + internal/tools/vault_interceptor.go | 17 +++ internal/tools/vault_read.go | 14 +- internal/tools/vault_read_test.go | 87 ++++++++++++ internal/tools/vault_search.go | 8 +- internal/upgrade/version.go | 2 +- internal/vault/search.go | 40 +++--- migrations/000056_vault_chat_id.down.sql | 2 + migrations/000056_vault_chat_id.up.sql | 19 +++ .../v3_vault_chat_isolation_test.go | 127 ++++++++++++++++++ 22 files changed, 457 insertions(+), 68 deletions(-) create mode 100644 migrations/000056_vault_chat_id.down.sql create mode 100644 migrations/000056_vault_chat_id.up.sql create mode 100644 tests/integration/v3_vault_chat_isolation_test.go diff --git a/cmd/gateway_consumer_normal.go b/cmd/gateway_consumer_normal.go index 4348bb5d..59c7e6e8 100644 --- a/cmd/gateway_consumer_normal.go +++ b/cmd/gateway_consumer_normal.go @@ -389,6 +389,7 @@ func processNormalMessage( ChannelType: resolveChannelType(deps.ChannelMgr, msg.Channel), ChatTitle: msg.Metadata[tools.MetaChatTitle], ChatID: msg.ChatID, + WorkspaceChatID: msg.ChatID, PeerKind: peerKind, LocalKey: msg.Metadata["local_key"], UserID: userID, diff --git a/internal/agent/loop_context.go b/internal/agent/loop_context.go index e8b5cb9e..7767ecab 100644 --- a/internal/agent/loop_context.go +++ b/internal/agent/loop_context.go @@ -119,8 +119,16 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup if req.WorkspaceChannel != "" { ctx = tools.WithWorkspaceChannel(ctx, req.WorkspaceChannel) } - if req.WorkspaceChatID != "" { - ctx = tools.WithWorkspaceChatID(ctx, req.WorkspaceChatID) + // WorkspaceChatID drives vault chat_id isolation in isolated teams. Callers + // that don't set it explicitly fall back to req.ChatID — the chat segment + // used for workspace path layering — so the vault filter activates uniformly + // across every RunRequest entry point (WS direct, HTTP, cron, subagent). + effectiveWorkspaceChatID := req.WorkspaceChatID + if effectiveWorkspaceChatID == "" { + effectiveWorkspaceChatID = req.ChatID + } + if effectiveWorkspaceChatID != "" { + ctx = tools.WithWorkspaceChatID(ctx, effectiveWorkspaceChatID) } if req.TeamTaskID != "" { ctx = tools.WithTeamTaskID(ctx, req.TeamTaskID) @@ -178,6 +186,15 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup } if req.TeamID != "" { ctx = tools.WithToolTeamID(ctx, req.TeamID) + // Team root for dispatched tasks: resolve the UserChatLayer-stripped root + // so the dispatched agent can still read peer-scoped files in the same team. + if teamUUID, err := uuid.Parse(req.TeamID); err == nil && l.dataDir != "" { + teamRoot := tools.ResolveWorkspace(l.dataDir, + tools.TenantLayer(store.TenantIDFromContext(ctx), store.TenantSlugFromContext(ctx)), + tools.TeamLayer(teamUUID), + ) + ctx = tools.WithToolTeamRoot(ctx, teamRoot) + } } if req.LeaderAgentID != "" { ctx = tools.WithLeaderAgentID(ctx, req.LeaderAgentID) @@ -186,6 +203,15 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup // Team workspace: auto-resolve for agents with team membership (not dispatched). // Lead agents default to team workspace; non-lead members keep own workspace. var resolvedTeamSettings json.RawMessage + // Dispatched tasks already have TeamWorkspace set but still need team settings + // for TeamIsolated flag. Fetch by explicit TeamID in that branch. + if req.TeamWorkspace != "" && req.TeamID != "" && l.teamStore != nil { + if teamUUID, err := uuid.Parse(req.TeamID); err == nil { + if team, _ := l.teamStore.GetTeam(ctx, teamUUID); team != nil { + resolvedTeamSettings = team.Settings + } + } + } if req.TeamWorkspace == "" && l.teamStore != nil && l.agentUUID != uuid.Nil { if team, _ := l.teamStore.GetTeamForAgent(ctx, l.agentUUID); team != nil { resolvedTeamSettings = team.Settings @@ -204,6 +230,15 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup slog.Warn("failed to create team workspace directory", "workspace", wsDir, "error", err) } ctx = tools.WithToolTeamWorkspace(ctx, wsDir) + // Team root (no UserChatLayer): lets any team agent — leader or member — + // read files produced by peers under different chat/user scopes within + // the same team. Writes still default to wsDir above; team root only + // widens the allowed-prefix set for path boundary checks. + teamRoot := tools.ResolveWorkspace(l.dataDir, + tools.TenantLayer(store.TenantIDFromContext(ctx), store.TenantSlugFromContext(ctx)), + tools.TeamLayer(team.ID), + ) + ctx = tools.WithToolTeamRoot(ctx, teamRoot) // Leader keeps personal workspace (set at line 110-132) as default. // Team workspace accessible via ToolTeamWorkspaceFromCtx for delegation. if req.TeamID == "" { @@ -337,7 +372,8 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup TeamWorkspace: tools.ToolTeamWorkspaceFromCtx(ctx), TeamID: tools.ToolTeamIDFromCtx(ctx), WorkspaceChannel: req.WorkspaceChannel, - WorkspaceChatID: req.WorkspaceChatID, + WorkspaceChatID: effectiveWorkspaceChatID, + TeamIsolated: resolvedTeamSettings != nil && !tools.IsSharedWorkspace(resolvedTeamSettings), TeamTaskID: req.TeamTaskID, LeaderAgentID: tools.LeaderAgentIDFromCtx(ctx), AgentToolKey: l.id, diff --git a/internal/gateway/methods/chat.go b/internal/gateway/methods/chat.go index 40cef583..1b6bede5 100644 --- a/internal/gateway/methods/chat.go +++ b/internal/gateway/methods/chat.go @@ -276,13 +276,14 @@ func (m *ChatMethods) handleSend(ctx context.Context, client *gateway.Client, re } result, err := loop.Run(runCtx, agent.RunRequest{ - SessionKey: sessionKey, - Message: message, - Media: mediaFiles, - Channel: "ws", - ChatID: userID, // use stable userID for team/workspace isolation (not ephemeral client.ID()) - RunID: runID, - UserID: userID, + SessionKey: sessionKey, + Message: message, + Media: mediaFiles, + Channel: "ws", + ChatID: userID, // use stable userID for team/workspace isolation (not ephemeral client.ID()) + WorkspaceChatID: userID, // mirror ChatID so vault chat_id isolation activates for WS direct flow + RunID: runID, + UserID: userID, Stream: params.Stream, InjectCh: injectCh, // Wire trace ID back to the active run so force-abort can mark the diff --git a/internal/store/pg/vault_documents.go b/internal/store/pg/vault_documents.go index fe6dd806..a8ac8ef9 100644 --- a/internal/store/pg/vault_documents.go +++ b/internal/store/pg/vault_documents.go @@ -112,10 +112,17 @@ func (s *PGVaultStore) UpsertDocument(ctx context.Context, doc *store.VaultDocum } var actualID uuid.UUID + // Normalize chat_id: empty string → NULL. + var chatID *string + if doc.ChatID != nil && *doc.ChatID != "" { + c := *doc.ChatID + chatID = &c + } + err = s.db.QueryRowContext(ctx, ` INSERT INTO vault_documents - (id, tenant_id, agent_id, team_id, scope, custom_scope, path, title, doc_type, content_hash, summary, embedding, metadata, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $14) + (id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, title, doc_type, content_hash, summary, embedding, metadata, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $15) ON CONFLICT (tenant_id, COALESCE(agent_id, '00000000-0000-0000-0000-000000000000'::uuid), COALESCE(team_id, '00000000-0000-0000-0000-000000000000'::uuid), scope, path) DO UPDATE SET title = EXCLUDED.title, doc_type = EXCLUDED.doc_type, @@ -123,10 +130,11 @@ func (s *PGVaultStore) UpsertDocument(ctx context.Context, doc *store.VaultDocum summary = EXCLUDED.summary, embedding = COALESCE(EXCLUDED.embedding, vault_documents.embedding), metadata = EXCLUDED.metadata, + chat_id = COALESCE(EXCLUDED.chat_id, vault_documents.chat_id), tenant_id = EXCLUDED.tenant_id, updated_at = EXCLUDED.updated_at RETURNING id`, - id, tid, aid, teamID, doc.Scope, doc.CustomScope, doc.Path, doc.Title, doc.DocType, + id, tid, aid, teamID, chatID, doc.Scope, doc.CustomScope, doc.Path, doc.Title, doc.DocType, doc.ContentHash, doc.Summary, embStr, meta, now, ).Scan(&actualID) if err != nil { @@ -145,7 +153,7 @@ func (s *PGVaultStore) GetDocument(ctx context.Context, tenantID, agentID, path return nil, fmt.Errorf("vault get document: tenant: %w", err) } - q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at + q := `SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at FROM vault_documents WHERE tenant_id = $1 AND path = $2` args := []any{tid, path} p := 3 @@ -177,7 +185,7 @@ func (s *PGVaultStore) GetDocument(ctx context.Context, tenantID, agentID, path // Scan order MUST match SELECT order above: 15 columns including // path_basename (generated column added in migration 000047). err = s.db.QueryRowContext(ctx, q, args...).Scan( - &row.ID, &row.TenantID, &row.AgentID, &row.TeamID, &row.Scope, &row.CustomScope, + &row.ID, &row.TenantID, &row.AgentID, &row.TeamID, &row.ChatID, &row.Scope, &row.CustomScope, &row.Path, &row.PathBasename, &row.Title, &row.DocType, &row.ContentHash, &row.Summary, &row.MetaJSON, &row.CreatedAt, &row.UpdatedAt) if err != nil { @@ -199,9 +207,9 @@ func (s *PGVaultStore) GetDocumentByID(ctx context.Context, tenantID, id string) } var row vaultDocRow err = s.db.QueryRowContext(ctx, ` - SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at + SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at FROM vault_documents WHERE id = $1 AND tenant_id = $2`, uid, tid, - ).Scan(&row.ID, &row.TenantID, &row.AgentID, &row.TeamID, &row.Scope, &row.CustomScope, + ).Scan(&row.ID, &row.TenantID, &row.AgentID, &row.TeamID, &row.ChatID, &row.Scope, &row.CustomScope, &row.Path, &row.PathBasename, &row.Title, &row.DocType, &row.ContentHash, &row.Summary, &row.MetaJSON, &row.CreatedAt, &row.UpdatedAt) if err != nil { @@ -227,7 +235,7 @@ func (s *PGVaultStore) GetDocumentsByIDs(ctx context.Context, tenantID string, d end := min(start+chunkSize, len(docIDs)) var scanned []vaultDocRow if err := pkgSqlxDB.SelectContext(ctx, &scanned, - `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at + `SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at FROM vault_documents WHERE id = ANY($1) AND tenant_id = $2`, pqStringArray(docIDs[start:end]), tid); err != nil { return nil, err @@ -246,7 +254,7 @@ func (s *PGVaultStore) GetDocumentByBasename(ctx context.Context, tenantID, agen if err != nil { return nil, fmt.Errorf("vault get by basename: tenant: %w", err) } - q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at + q := `SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at FROM vault_documents WHERE tenant_id = $1 AND path_basename = lower($2)` args := []any{tid, basename} @@ -264,7 +272,7 @@ func (s *PGVaultStore) GetDocumentByBasename(ctx context.Context, tenantID, agen // Scan order MUST match SELECT order above: 15 columns including // path_basename (generated column added in migration 000047). err = s.db.QueryRowContext(ctx, q, args...).Scan( - &row.ID, &row.TenantID, &row.AgentID, &row.TeamID, &row.Scope, &row.CustomScope, + &row.ID, &row.TenantID, &row.AgentID, &row.TeamID, &row.ChatID, &row.Scope, &row.CustomScope, &row.Path, &row.PathBasename, &row.Title, &row.DocType, &row.ContentHash, &row.Summary, &row.MetaJSON, &row.CreatedAt, &row.UpdatedAt) if err != nil { @@ -321,7 +329,7 @@ func (s *PGVaultStore) ListDocuments(ctx context.Context, tenantID, agentID stri return nil, fmt.Errorf("vault list documents: tenant: %w", err) } - q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at + q := `SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at FROM vault_documents WHERE tenant_id = $1` args := []any{tid} p := 2 @@ -445,6 +453,8 @@ func (s *PGVaultStore) Search(ctx context.Context, opts store.VaultSearchOptions // Build team filter for search sub-queries. tf := buildSearchTeamFilter(opts.TeamID, opts.TeamIDs) + // Chat-scope filter (applies only when team is isolated + chat_id non-nil/non-empty). + cf := buildSearchChatFilter(opts.ChatID, opts.TeamIsolated) maxResults := opts.MaxResults if maxResults <= 0 { @@ -452,7 +462,7 @@ func (s *PGVaultStore) Search(ctx context.Context, opts store.VaultSearchOptions } // FTS search - ftsResults, err := s.ftsSearch(ctx, opts.Query, tid, aid, tf, opts.Scope, opts.DocTypes, maxResults*2) + ftsResults, err := s.ftsSearch(ctx, opts.Query, tid, aid, tf, cf, opts.Scope, opts.DocTypes, maxResults*2) if err != nil { return nil, err } @@ -463,7 +473,7 @@ func (s *PGVaultStore) Search(ctx context.Context, opts store.VaultSearchOptions vecs, embErr := s.embProvider.Embed(ctx, []string{opts.Query}) if embErr == nil && len(vecs) > 0 { var vecErr error - vecResults, vecErr = s.vectorSearch(ctx, vecs[0], tid, aid, tf, opts.Scope, opts.DocTypes, maxResults*2) + vecResults, vecErr = s.vectorSearch(ctx, vecs[0], tid, aid, tf, cf, opts.Scope, opts.DocTypes, maxResults*2) if vecErr != nil { slog.Debug("vault.vector_search_fallback", "err", vecErr) vecResults = nil @@ -535,8 +545,32 @@ func (tf searchTeamFilter) append(q string, args []any, p int) (string, []any, i return q, args, p } -func (s *PGVaultStore) ftsSearch(ctx context.Context, query string, tenantID uuid.UUID, agentID *uuid.UUID, tf searchTeamFilter, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) { - q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at, +// searchChatFilter isolates vault search by chat_id when the calling team uses isolated workspace. +// Predicate: (chat_id = $N OR chat_id IS NULL). NULL = team-wide doc (legacy or shared-mode write). +type searchChatFilter struct { + chatID string + active bool +} + +func buildSearchChatFilter(chatID *string, teamIsolated bool) searchChatFilter { + if !teamIsolated || chatID == nil || *chatID == "" { + return searchChatFilter{} + } + return searchChatFilter{chatID: *chatID, active: true} +} + +func (cf searchChatFilter) append(q string, args []any, p int) (string, []any, int) { + if !cf.active { + return q, args, p + } + q += fmt.Sprintf(" AND (chat_id = $%d OR chat_id IS NULL)", p) + args = append(args, cf.chatID) + p++ + return q, args, p +} + +func (s *PGVaultStore) ftsSearch(ctx context.Context, query string, tenantID uuid.UUID, agentID *uuid.UUID, tf searchTeamFilter, cf searchChatFilter, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) { + q := `SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at, ts_rank(tsv, plainto_tsquery('simple', $1)) AS score FROM vault_documents WHERE tenant_id = $2 AND tsv @@ plainto_tsquery('simple', $1)` @@ -550,6 +584,7 @@ func (s *PGVaultStore) ftsSearch(ctx context.Context, query string, tenantID uui } q, args, p = tf.append(q, args, p) + q, args, p = cf.append(q, args, p) if scope != "" { q += fmt.Sprintf(" AND scope = $%d", p) @@ -572,9 +607,9 @@ func (s *PGVaultStore) ftsSearch(ctx context.Context, query string, tenantID uui return vaultSearchRowsToResults(scanned, "vault"), nil } -func (s *PGVaultStore) vectorSearch(ctx context.Context, embedding []float32, tenantID uuid.UUID, agentID *uuid.UUID, tf searchTeamFilter, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) { +func (s *PGVaultStore) vectorSearch(ctx context.Context, embedding []float32, tenantID uuid.UUID, agentID *uuid.UUID, tf searchTeamFilter, cf searchChatFilter, scope string, docTypes []string, limit int) ([]store.VaultSearchResult, error) { vecStr := vectorToString(embedding) - q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at, + q := `SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at, 1 - (embedding <=> $1) AS score FROM vault_documents WHERE tenant_id = $2 AND embedding IS NOT NULL` @@ -588,6 +623,7 @@ func (s *PGVaultStore) vectorSearch(ctx context.Context, embedding []float32, te } q, args, p = tf.append(q, args, p) + q, args, p = cf.append(q, args, p) if scope != "" { q += fmt.Sprintf(" AND scope = $%d", p) diff --git a/internal/store/pg/vault_documents_enrichment.go b/internal/store/pg/vault_documents_enrichment.go index f212cb57..8236eb66 100644 --- a/internal/store/pg/vault_documents_enrichment.go +++ b/internal/store/pg/vault_documents_enrichment.go @@ -16,7 +16,7 @@ func (s *PGVaultStore) ListUnenrichedDocs(ctx context.Context, tenantID string, return nil, fmt.Errorf("vault list unenriched: tenant: %w", err) } - q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, + q := `SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at FROM vault_documents WHERE tenant_id = $1 AND (summary IS NULL OR summary = '') @@ -102,7 +102,7 @@ func (s *PGVaultStore) FindSimilarDocs(ctx context.Context, tenantID, agentID, d return nil, nil // no embedding = no neighbors } - q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, + q := `SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at, 1 - (embedding <=> $1::vector) AS score FROM vault_documents diff --git a/internal/store/pg/vault_scan_rows.go b/internal/store/pg/vault_scan_rows.go index 97efcfea..51c9a092 100644 --- a/internal/store/pg/vault_scan_rows.go +++ b/internal/store/pg/vault_scan_rows.go @@ -16,6 +16,7 @@ type vaultDocRow struct { TenantID uuid.UUID `db:"tenant_id"` AgentID *uuid.UUID `db:"agent_id"` TeamID *uuid.UUID `db:"team_id"` + ChatID *string `db:"chat_id"` Scope string `db:"scope"` CustomScope *string `db:"custom_scope"` Path string `db:"path"` @@ -53,6 +54,10 @@ func (r *vaultDocRow) toVaultDocument() store.VaultDocument { s := r.TeamID.String() doc.TeamID = &s } + if r.ChatID != nil { + s := *r.ChatID + doc.ChatID = &s + } if len(r.MetaJSON) > 0 { json.Unmarshal(r.MetaJSON, &doc.Metadata) //nolint:errcheck } diff --git a/internal/store/pg/vault_source_cleanup.go b/internal/store/pg/vault_source_cleanup.go index e973da12..8d84074f 100644 --- a/internal/store/pg/vault_source_cleanup.go +++ b/internal/store/pg/vault_source_cleanup.go @@ -71,7 +71,7 @@ func (s *PGVaultStore) BatchFindByDelegationIDs( q := ` WITH ranked AS ( SELECT - vd.id, vd.tenant_id, vd.agent_id, vd.team_id, vd.scope, vd.custom_scope, + vd.id, vd.tenant_id, vd.agent_id, vd.team_id, vd.chat_id, vd.scope, vd.custom_scope, vd.path, vd.path_basename, vd.title, vd.doc_type, vd.content_hash, vd.summary, vd.metadata, vd.created_at, vd.updated_at, vd.metadata->>'delegation_id' AS deleg_id, @@ -90,7 +90,7 @@ WITH ranked AS ( args = append(args, pqStringArray(excludeUUIDs)) } q += `) -SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, +SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at, deleg_id FROM ranked WHERE rn <= $` + fmt.Sprintf("%d", len(args)+1) + ` @@ -109,6 +109,7 @@ ORDER BY deleg_id, created_at DESC var ( id, tenantIDVal uuid.UUID agentID, teamID *uuid.UUID + chatID *string customScope *string metaJSON []byte delegID string @@ -118,12 +119,16 @@ ORDER BY deleg_id, created_at DESC ) doc := store.VaultDocument{} if err := rows.Scan( - &id, &tenantIDVal, &agentID, &teamID, &scope, &customScope, + &id, &tenantIDVal, &agentID, &teamID, &chatID, &scope, &customScope, &path, &pathBase, &title, &docTyp, &contentHash, &summary, &metaJSON, &doc.CreatedAt, &doc.UpdatedAt, &delegID, ); err != nil { return nil, err } + if chatID != nil { + v := *chatID + doc.ChatID = &v + } doc.ID = id.String() doc.TenantID = tenantIDVal.String() if agentID != nil { diff --git a/internal/store/run_context.go b/internal/store/run_context.go index 7c397ee6..35c51faf 100644 --- a/internal/store/run_context.go +++ b/internal/store/run_context.go @@ -52,6 +52,7 @@ type RunContext struct { TeamID string WorkspaceChannel string WorkspaceChatID string + TeamIsolated bool // true when team.workspace_scope != "shared" — drives chat_id filtering in vault search TeamTaskID string DelegationID string // delegation identifier for vault auto-linking (empty when not in delegation) LeaderAgentID string // leader's agent UUID for member memory read fallback diff --git a/internal/store/sqlitestore/schema.go b/internal/store/sqlitestore/schema.go index 04738efd..6d9ce401 100644 --- a/internal/store/sqlitestore/schema.go +++ b/internal/store/sqlitestore/schema.go @@ -16,7 +16,7 @@ var schemaSQL string // SchemaVersion is the current SQLite schema version. // Bump this when adding new migration steps below. -const SchemaVersion = 24 +const SchemaVersion = 25 // migrations maps version → SQL to apply when upgrading FROM that version. // schema.sql always represents the LATEST full schema (for fresh DBs). @@ -496,6 +496,11 @@ CREATE TRIGGER IF NOT EXISTS trg_vault_docs_scope_consistency_upd BEGIN SELECT RAISE(ABORT, 'vault_documents_scope_consistency violation'); END;`, + + // Version 24 → 25: add chat_id column + composite index (mirrors PG migration 000056). + // SQLite lacks regex by default — skip backfill (desktop is single-user; cross-chat risk minimal). + 24: `ALTER TABLE vault_documents ADD COLUMN chat_id TEXT; +CREATE INDEX IF NOT EXISTS idx_vault_docs_team_chat ON vault_documents(team_id, chat_id) WHERE team_id IS NOT NULL;`, } // addHooksTables is the SQLite incremental migration for schema v19 → v20. diff --git a/internal/store/sqlitestore/schema.sql b/internal/store/sqlitestore/schema.sql index 680dae87..266353fc 100644 --- a/internal/store/sqlitestore/schema.sql +++ b/internal/store/sqlitestore/schema.sql @@ -1521,6 +1521,7 @@ CREATE TABLE IF NOT EXISTS vault_documents ( tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL, team_id TEXT REFERENCES agent_teams(id) ON DELETE SET NULL, + chat_id TEXT, -- NULL = team-wide (shared / legacy); non-NULL = scoped to specific chat for isolated teams scope TEXT NOT NULL DEFAULT 'personal', custom_scope TEXT, path TEXT NOT NULL, @@ -1547,6 +1548,7 @@ CREATE INDEX IF NOT EXISTS idx_vault_docs_agent_scope ON vault_documents(agent_i CREATE INDEX IF NOT EXISTS idx_vault_docs_type ON vault_documents(agent_id, doc_type); CREATE INDEX IF NOT EXISTS idx_vault_docs_hash ON vault_documents(content_hash); CREATE INDEX IF NOT EXISTS idx_vault_docs_team ON vault_documents(team_id); +CREATE INDEX IF NOT EXISTS idx_vault_docs_team_chat ON vault_documents(team_id, chat_id) WHERE team_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_vault_docs_basename ON vault_documents(tenant_id, path_basename); CREATE INDEX IF NOT EXISTS idx_vault_docs_path_prefix ON vault_documents(tenant_id, path); CREATE INDEX IF NOT EXISTS idx_vault_docs_delegation diff --git a/internal/store/sqlitestore/schema_migration_test.go b/internal/store/sqlitestore/schema_migration_test.go index 671367b4..2260ea46 100644 --- a/internal/store/sqlitestore/schema_migration_test.go +++ b/internal/store/sqlitestore/schema_migration_test.go @@ -330,6 +330,13 @@ func openTestDBAtVersion(t *testing.T, targetVersion int) *sql.DB { db.Exec(`DROP TABLE episodic_summaries_old`) } + if targetVersion < 25 { + // Migration 24→25 adds vault_documents.chat_id + idx_vault_docs_team_chat. + // Drop both so the migration's ALTER TABLE / CREATE INDEX succeed. + db.Exec(`DROP INDEX IF EXISTS idx_vault_docs_team_chat`) + db.Exec(`ALTER TABLE vault_documents DROP COLUMN chat_id`) + } + // Set version back to target. db.Exec("UPDATE schema_version SET version = ?", targetVersion) return db diff --git a/internal/store/sqlitestore/vault_documents.go b/internal/store/sqlitestore/vault_documents.go index 2055558c..b1fbf8c6 100644 --- a/internal/store/sqlitestore/vault_documents.go +++ b/internal/store/sqlitestore/vault_documents.go @@ -63,6 +63,11 @@ func (s *SQLiteVaultStore) UpsertDocument(ctx context.Context, doc *store.VaultD if doc.AgentID != nil && *doc.AgentID != "" { agentIDVal = *doc.AgentID } + // Normalize chat_id: empty string → NULL (treat as team-wide). + var chatIDVal any + if doc.ChatID != nil && *doc.ChatID != "" { + chatIDVal = *doc.ChatID + } // SQLite has no GENERATED column equivalent via modernc driver; compute // the basename app-side. PG auto-populates via GENERATED so this is a // no-op on PG callers that share the struct. @@ -71,8 +76,8 @@ func (s *SQLiteVaultStore) UpsertDocument(ctx context.Context, doc *store.VaultD } err = s.db.QueryRowContext(ctx, ` INSERT INTO vault_documents - (id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (tenant_id, COALESCE(agent_id,''), COALESCE(team_id,''), scope, path) DO UPDATE SET path_basename = excluded.path_basename, title = excluded.title, @@ -80,10 +85,11 @@ func (s *SQLiteVaultStore) UpsertDocument(ctx context.Context, doc *store.VaultD content_hash = excluded.content_hash, summary = excluded.summary, metadata = excluded.metadata, + chat_id = COALESCE(excluded.chat_id, vault_documents.chat_id), tenant_id = excluded.tenant_id, updated_at = excluded.updated_at RETURNING id`, - id, doc.TenantID, agentIDVal, doc.TeamID, doc.Scope, doc.CustomScope, + id, doc.TenantID, agentIDVal, doc.TeamID, chatIDVal, doc.Scope, doc.CustomScope, doc.Path, doc.PathBasename, doc.Title, doc.DocType, doc.ContentHash, doc.Summary, string(meta), now, now, ).Scan(&doc.ID) if err != nil { @@ -96,7 +102,7 @@ func (s *SQLiteVaultStore) UpsertDocument(ctx context.Context, doc *store.VaultD // Empty agentID means no agent filter. // Team scoping via RunContext: present+TeamID → filter; present+empty → personal; nil → any match. func (s *SQLiteVaultStore) GetDocument(ctx context.Context, tenantID, agentID, path string) (*store.VaultDocument, error) { - q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at + q := `SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at FROM vault_documents WHERE tenant_id = ? AND path = ?` args := []any{tenantID, path} @@ -121,7 +127,7 @@ func (s *SQLiteVaultStore) GetDocument(ctx context.Context, tenantID, agentID, p // GetDocumentByID retrieves a vault document by ID with tenant isolation. func (s *SQLiteVaultStore) GetDocumentByID(ctx context.Context, tenantID, id string) (*store.VaultDocument, error) { row := s.db.QueryRowContext(ctx, ` - SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at + SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at FROM vault_documents WHERE id = ? AND tenant_id = ?`, id, tenantID) return scanVaultDoc(row) } @@ -143,7 +149,7 @@ func (s *SQLiteVaultStore) GetDocumentsByIDs(ctx context.Context, tenantID strin args[i] = id } args = append(args, tenantID) - q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at + q := `SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at FROM vault_documents WHERE id IN (` + strings.Join(ph, ",") + `) AND tenant_id = ?` rows, err := s.db.QueryContext(ctx, q, args...) if err != nil { @@ -167,7 +173,7 @@ func (s *SQLiteVaultStore) GetDocumentsByIDs(ctx context.Context, tenantID strin // GetDocumentByBasename finds a document by path basename (case-insensitive). func (s *SQLiteVaultStore) GetDocumentByBasename(ctx context.Context, tenantID, agentID, basename string) (*store.VaultDocument, error) { - q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at + q := `SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at FROM vault_documents WHERE tenant_id = ? AND lower(replace(path, rtrim(path, replace(path, '/', '')), '')) = lower(?)` args := []any{tenantID, basename} @@ -207,7 +213,7 @@ func (s *SQLiteVaultStore) DeleteDocument(ctx context.Context, tenantID, agentID // ListDocuments returns vault documents with optional scope/type filters. func (s *SQLiteVaultStore) ListDocuments(ctx context.Context, tenantID, agentID string, opts store.VaultListOptions) ([]store.VaultDocument, error) { - q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at + q := `SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at FROM vault_documents WHERE tenant_id = ?` args := []any{tenantID} @@ -298,7 +304,7 @@ func (s *SQLiteVaultStore) UpdateHash(ctx context.Context, tenantID, id, newHash // ListUnenrichedDocs returns documents with empty summary for re-enrichment. // limit=0 means no limit. func (s *SQLiteVaultStore) ListUnenrichedDocs(ctx context.Context, tenantID string, limit int) ([]store.VaultDocument, error) { - q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at + q := `SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at FROM vault_documents WHERE tenant_id = ? AND (summary IS NULL OR summary = '') ORDER BY created_at ASC` @@ -355,7 +361,7 @@ func (s *SQLiteVaultStore) Search(ctx context.Context, opts store.VaultSearchOpt maxResults = 10 } - q := `SELECT id, tenant_id, agent_id, team_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at + q := `SELECT id, tenant_id, agent_id, team_id, chat_id, scope, custom_scope, path, path_basename, title, doc_type, content_hash, summary, metadata, created_at, updated_at FROM vault_documents WHERE tenant_id = ? AND (title LIKE ? ESCAPE '\' OR path LIKE ? ESCAPE '\')` @@ -368,6 +374,11 @@ func (s *SQLiteVaultStore) Search(ctx context.Context, opts store.VaultSearchOpt q, args = sqliteAppendTeamFilter(q, args, opts.TeamID, opts.TeamIDs) + if opts.TeamIsolated && opts.ChatID != nil && *opts.ChatID != "" { + q += " AND (chat_id = ? OR chat_id IS NULL)" + args = append(args, *opts.ChatID) + } + q += " ORDER BY updated_at DESC LIMIT ?" args = append(args, maxResults*2) @@ -524,14 +535,15 @@ func sqliteAppendTreeFilters(q string, args []any, opts store.VaultTreeOptions) func scanVaultDoc(row *sql.Row) (*store.VaultDocument, error) { var doc store.VaultDocument var meta []byte - var agentID *string + var agentID, chatID *string ca, ua := &sqliteTime{}, &sqliteTime{} - err := row.Scan(&doc.ID, &doc.TenantID, &agentID, &doc.TeamID, &doc.Scope, &doc.CustomScope, + err := row.Scan(&doc.ID, &doc.TenantID, &agentID, &doc.TeamID, &chatID, &doc.Scope, &doc.CustomScope, &doc.Path, &doc.PathBasename, &doc.Title, &doc.DocType, &doc.ContentHash, &doc.Summary, &meta, ca, ua) if err != nil { return nil, err } doc.AgentID = agentID + doc.ChatID = chatID doc.CreatedAt = ca.Time doc.UpdatedAt = ua.Time if len(meta) > 2 { @@ -543,14 +555,15 @@ func scanVaultDoc(row *sql.Row) (*store.VaultDocument, error) { func scanVaultDocRow(rows *sql.Rows) (*store.VaultDocument, error) { var doc store.VaultDocument var meta []byte - var agentID *string + var agentID, chatID *string ca, ua := &sqliteTime{}, &sqliteTime{} - err := rows.Scan(&doc.ID, &doc.TenantID, &agentID, &doc.TeamID, &doc.Scope, &doc.CustomScope, + err := rows.Scan(&doc.ID, &doc.TenantID, &agentID, &doc.TeamID, &chatID, &doc.Scope, &doc.CustomScope, &doc.Path, &doc.PathBasename, &doc.Title, &doc.DocType, &doc.ContentHash, &doc.Summary, &meta, ca, ua) if err != nil { return nil, err } doc.AgentID = agentID + doc.ChatID = chatID doc.CreatedAt = ca.Time doc.UpdatedAt = ua.Time if len(meta) > 2 { diff --git a/internal/store/vault_store.go b/internal/store/vault_store.go index e5f9768b..dec586ac 100644 --- a/internal/store/vault_store.go +++ b/internal/store/vault_store.go @@ -11,6 +11,7 @@ type VaultDocument struct { TenantID string `json:"tenant_id" db:"tenant_id"` AgentID *string `json:"agent_id,omitempty" db:"agent_id"` TeamID *string `json:"team_id,omitempty" db:"team_id"` + ChatID *string `json:"chat_id,omitempty" db:"chat_id"` // nil = team-wide (shared / legacy); non-nil = scoped to specific chat in isolated teams Scope string `json:"scope" db:"scope"` // personal, team, shared CustomScope *string `json:"custom_scope,omitempty" db:"custom_scope"` Path string `json:"path" db:"path"` // workspace-relative path @@ -58,6 +59,8 @@ type VaultSearchOptions struct { TenantID string TeamID *string // nil = no filter, ptr-to-empty = personal (NULL team_id), ptr-to-uuid = specific team TeamIDs []string // non-nil = personal (NULL) + these team UUIDs (used for "all accessible" view) + ChatID *string // isolated-team scope: when non-nil + TeamIsolated, filter (chat_id = ChatID OR chat_id IS NULL) + TeamIsolated bool // true = apply ChatID filter; false = shared/no-team mode (ignore ChatID) Scope string // empty = all scopes DocTypes []string // empty = all types MaxResults int // default 10 diff --git a/internal/tools/vault_interceptor.go b/internal/tools/vault_interceptor.go index 0e627e5f..2367c1e1 100644 --- a/internal/tools/vault_interceptor.go +++ b/internal/tools/vault_interceptor.go @@ -36,6 +36,21 @@ func inferScopeFromContext(ctx context.Context) (scope string, teamID *string, a return "personal", nil, true } +// inferChatIDFromContext returns the chat_id to stamp on a vault doc. +// Non-nil only when team uses isolated workspace scope AND WorkspaceChatID is set. +// Shared/personal scope → nil (team-wide, matches any chat in search). +func inferChatIDFromContext(ctx context.Context) *string { + rc := store.RunContextFromCtx(ctx) + if rc == nil || rc.TeamID == "" || !rc.TeamIsolated { + return nil + } + chatID := WorkspaceChatIDFromCtx(ctx) + if chatID == "" { + return nil + } + return &chatID +} + // AfterWrite registers or updates a vault document after a file write. // Non-blocking: errors logged but not propagated. func (v *VaultInterceptor) AfterWrite(ctx context.Context, resolvedPath, content string) { @@ -73,6 +88,7 @@ func (v *VaultInterceptor) AfterWrite(ctx context.Context, resolvedPath, content TenantID: tenantID, AgentID: agentIDPtr, TeamID: teamID, + ChatID: inferChatIDFromContext(ctx), Scope: scope, Path: relPath, Title: title, @@ -164,6 +180,7 @@ func (v *VaultInterceptor) AfterWriteMedia(ctx context.Context, resolvedPath, su TenantID: tenantID, AgentID: agentIDPtr, TeamID: teamID, + ChatID: inferChatIDFromContext(ctx), Scope: scope, Path: relPath, Title: title, diff --git a/internal/tools/vault_read.go b/internal/tools/vault_read.go index bcdf8adb..e00c3c05 100644 --- a/internal/tools/vault_read.go +++ b/internal/tools/vault_read.go @@ -326,7 +326,19 @@ func (t *VaultReadTool) allowed(ctx context.Context, doc *store.VaultDocument) b if rc == nil || rc.TeamID == "" { return false } - return rc.TeamID == *doc.TeamID + if rc.TeamID != *doc.TeamID { + return false + } + // Chat scope: isolated teams restrict cross-chat reads. Docs with + // chat_id = NULL are team-wide (legacy or shared-mode writes); docs + // with chat_id set must match caller's WorkspaceChatID. + if rc.TeamIsolated && doc.ChatID != nil && *doc.ChatID != "" { + callerChat := WorkspaceChatIDFromCtx(ctx) + if callerChat == "" || callerChat != *doc.ChatID { + return false + } + } + return true default: return false } diff --git a/internal/tools/vault_read_test.go b/internal/tools/vault_read_test.go index 2a7d86c9..f80f3757 100644 --- a/internal/tools/vault_read_test.go +++ b/internal/tools/vault_read_test.go @@ -244,6 +244,93 @@ func TestVaultRead_TeamScope_NoContext_Deny(t *testing.T) { } } +// --- 5a. isolated team, cross-chat doc → deny. --- +func TestVaultRead_TeamScope_IsolatedCrossChat_Deny(t *testing.T) { + tenantID := uuid.New() + agentID := uuid.New() + docID := uuid.New() + teamID := uuid.New().String() + tid := teamID + chatA := "chatA" + doc := &store.VaultDocument{ + ID: docID.String(), TenantID: tenantID.String(), + TeamID: &tid, ChatID: &chatA, + Scope: "team", Path: "team/doc.md", + Title: "Team Doc", DocType: "note", + } + tool, ws := newVaultReadTestTool(t, doc) + writeFile(t, ws, "team/doc.md", "team body") + + // Caller bound to chatB in isolated team → deny. + ctx := store.WithRunContext( + makeCtx(tenantID, agentID), + &store.RunContext{ + TenantID: tenantID, AgentID: agentID, + TeamID: teamID, TeamIsolated: true, WorkspaceChatID: "chatB", + }) + res := tool.Execute(ctx, map[string]any{"doc_id": docID.String()}) + if !res.IsError || !strings.Contains(res.ForLLM, "not accessible") { + t.Fatalf("expected cross-chat deny, got: %s", res.ForLLM) + } +} + +// --- 5b. isolated team, same-chat doc → allow. --- +func TestVaultRead_TeamScope_IsolatedSameChat_Allow(t *testing.T) { + tenantID := uuid.New() + agentID := uuid.New() + docID := uuid.New() + teamID := uuid.New().String() + tid := teamID + chatA := "chatA" + doc := &store.VaultDocument{ + ID: docID.String(), TenantID: tenantID.String(), + TeamID: &tid, ChatID: &chatA, + Scope: "team", Path: "team/doc.md", + Title: "Team Doc", DocType: "note", + } + tool, ws := newVaultReadTestTool(t, doc) + writeFile(t, ws, "team/doc.md", "team body") + + ctx := store.WithRunContext( + makeCtx(tenantID, agentID), + &store.RunContext{ + TenantID: tenantID, AgentID: agentID, + TeamID: teamID, TeamIsolated: true, WorkspaceChatID: "chatA", + }) + res := tool.Execute(ctx, map[string]any{"doc_id": docID.String()}) + if res.IsError { + t.Fatalf("expected allow for same-chat, got error: %s", res.ForLLM) + } +} + +// --- 5c. isolated team, team-wide doc (chat_id NULL) → allow regardless of chat. --- +func TestVaultRead_TeamScope_IsolatedTeamWide_Allow(t *testing.T) { + tenantID := uuid.New() + agentID := uuid.New() + docID := uuid.New() + teamID := uuid.New().String() + tid := teamID + doc := &store.VaultDocument{ + ID: docID.String(), TenantID: tenantID.String(), + TeamID: &tid, ChatID: nil, // team-wide + Scope: "team", Path: "team/doc.md", + Title: "Team Doc", DocType: "note", + } + tool, ws := newVaultReadTestTool(t, doc) + writeFile(t, ws, "team/doc.md", "team body") + + ctx := store.WithRunContext( + makeCtx(tenantID, agentID), + &store.RunContext{ + TenantID: tenantID, AgentID: agentID, + TeamID: teamID, TeamIsolated: true, WorkspaceChatID: "chatZ", + }) + res := tool.Execute(ctx, map[string]any{"doc_id": docID.String()}) + if res.IsError { + t.Fatalf("team-wide doc should be accessible in isolated team, got: %s", res.ForLLM) + } +} + // --- 6. cross-tenant (different tenant in ctx) → not-found. --- func TestVaultRead_CrossTenant_NotFound(t *testing.T) { tenantA := uuid.New() diff --git a/internal/tools/vault_search.go b/internal/tools/vault_search.go index be1b33f6..4d1ad1cd 100644 --- a/internal/tools/vault_search.go +++ b/internal/tools/vault_search.go @@ -92,9 +92,15 @@ func (t *VaultSearchTool) Execute(ctx context.Context, args map[string]any) *Res UserID: userID, TenantID: tenantID.String(), } - // Team context from RunContext — cannot be spoofed via tool args. + // Team + chat context from RunContext — cannot be spoofed via tool args. if rc := store.RunContextFromCtx(ctx); rc != nil && rc.TeamID != "" { opts.TeamID = &rc.TeamID + if rc.TeamIsolated { + opts.TeamIsolated = true + if chatID := WorkspaceChatIDFromCtx(ctx); chatID != "" { + opts.ChatID = &chatID + } + } } if scope, ok := args["scope"].(string); ok && scope != "" { diff --git a/internal/upgrade/version.go b/internal/upgrade/version.go index d7787a6c..83df8b2e 100644 --- a/internal/upgrade/version.go +++ b/internal/upgrade/version.go @@ -2,4 +2,4 @@ package upgrade // RequiredSchemaVersion is the schema migration version this binary requires. // Bump this whenever adding a new SQL migration file. -const RequiredSchemaVersion uint = 55 +const RequiredSchemaVersion uint = 56 diff --git a/internal/vault/search.go b/internal/vault/search.go index 3473fab8..ffe39439 100644 --- a/internal/vault/search.go +++ b/internal/vault/search.go @@ -35,16 +35,18 @@ func DefaultSearchWeights() SearchWeights { // UnifiedSearchOptions configures a cross-store search query. type UnifiedSearchOptions struct { - Query string - AgentID string - UserID string - TenantID string - TeamID *string // nil = no filter (owner), ptr-to-empty = personal, ptr-to-uuid = team - Scope string - DocTypes []string - MaxResults int - MinScore float64 - Weights SearchWeights + Query string + AgentID string + UserID string + TenantID string + TeamID *string // nil = no filter (owner), ptr-to-empty = personal, ptr-to-uuid = team + ChatID *string // isolated-team scope: filter docs to (chat_id = ChatID OR chat_id IS NULL) + TeamIsolated bool // true = apply ChatID filter; false = shared/personal (ignore ChatID) + Scope string + DocTypes []string + MaxResults int + MinScore float64 + Weights SearchWeights } // UnifiedSearchResult is a normalized result from any search source. @@ -91,14 +93,16 @@ func (s *VaultSearchService) Search(ctx context.Context, opts UnifiedSearchOptio if s.vaultStore != nil { wg.Go(func() { results, err := s.vaultStore.Search(ctx, store.VaultSearchOptions{ - Query: opts.Query, - AgentID: opts.AgentID, - TenantID: opts.TenantID, - TeamID: opts.TeamID, - Scope: opts.Scope, - DocTypes: opts.DocTypes, - MaxResults: opts.MaxResults * 2, - MinScore: opts.MinScore, + Query: opts.Query, + AgentID: opts.AgentID, + TenantID: opts.TenantID, + TeamID: opts.TeamID, + ChatID: opts.ChatID, + TeamIsolated: opts.TeamIsolated, + Scope: opts.Scope, + DocTypes: opts.DocTypes, + MaxResults: opts.MaxResults * 2, + MinScore: opts.MinScore, }) if err != nil { return diff --git a/migrations/000056_vault_chat_id.down.sql b/migrations/000056_vault_chat_id.down.sql new file mode 100644 index 00000000..2800854a --- /dev/null +++ b/migrations/000056_vault_chat_id.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_vault_docs_team_chat; +ALTER TABLE vault_documents DROP COLUMN IF EXISTS chat_id; diff --git a/migrations/000056_vault_chat_id.up.sql b/migrations/000056_vault_chat_id.up.sql new file mode 100644 index 00000000..4edadb44 --- /dev/null +++ b/migrations/000056_vault_chat_id.up.sql @@ -0,0 +1,19 @@ +-- Add chat_id to vault_documents for cross-chat isolation within isolated teams. +-- NULL = team-wide doc (shared mode or legacy); non-NULL = scoped to specific chat. +ALTER TABLE vault_documents ADD COLUMN IF NOT EXISTS chat_id TEXT; + +-- Composite index for team + chat filtering (primary query pattern for isolated teams). +CREATE INDEX IF NOT EXISTS idx_vault_docs_team_chat + ON vault_documents(team_id, chat_id) + WHERE team_id IS NOT NULL; + +-- Backfill chat_id for isolated teams. Two path layouts exist: +-- master tenant: teams///... +-- non-master tenant: tenants//teams///... +-- Chat segments starting with '.' (e.g. '.goclaw') are config dirs, not real chats — skip. +UPDATE vault_documents vd +SET chat_id = (regexp_match(vd.path, '^(?:tenants/[^/]+/)?teams/[^/]+/([^/]+)/'))[1] +FROM agent_teams t +WHERE vd.team_id = t.id + AND (t.settings->>'workspace_scope' IS NULL OR t.settings->>'workspace_scope' != 'shared') + AND vd.path ~ '^(?:tenants/[^/]+/)?teams/[^/]+/[^.][^/]*/'; diff --git a/tests/integration/v3_vault_chat_isolation_test.go b/tests/integration/v3_vault_chat_isolation_test.go new file mode 100644 index 00000000..6284e3db --- /dev/null +++ b/tests/integration/v3_vault_chat_isolation_test.go @@ -0,0 +1,127 @@ +//go:build integration + +package integration + +import ( + "context" + "sort" + "testing" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// vaultSearchPathsWithChat runs a search bound to a specific chat scope and +// returns sorted result paths. Used by cross-chat isolation assertions. +func vaultSearchPathsWithChat(t *testing.T, vs store.VaultStore, ctx context.Context, tenantID, agentID string, teamID *string, chatID string, isolated bool) []string { + t.Helper() + var chatPtr *string + if chatID != "" { + chatPtr = &chatID + } + results, err := vs.Search(ctx, store.VaultSearchOptions{ + TenantID: tenantID, + AgentID: agentID, + TeamID: teamID, + ChatID: chatPtr, + TeamIsolated: isolated, + Query: "note", + MaxResults: 100, + }) + if err != nil { + t.Fatalf("Search(chat=%s isolated=%v): %v", chatID, isolated, err) + } + paths := make([]string, 0, len(results)) + for _, r := range results { + paths = append(paths, r.Document.Path) + } + sort.Strings(paths) + return paths +} + +// upsertChatDoc inserts a team-scoped vault doc tagged with a chat_id. +func upsertChatDoc(t *testing.T, vs store.VaultStore, ctx context.Context, tenantID, teamID uuid.UUID, chatID, path string) { + t.Helper() + var chatPtr *string + if chatID != "" { + cid := chatID + chatPtr = &cid + } + teamStr := teamID.String() + doc := &store.VaultDocument{ + TenantID: tenantID.String(), + TeamID: &teamStr, + ChatID: chatPtr, + Scope: "team", + Path: path, + Title: "note-" + path, + DocType: "note", + ContentHash: "h-" + path, + Summary: "note summary for " + path, + } + if err := vs.UpsertDocument(ctx, doc); err != nil { + t.Fatalf("UpsertDocument(%s chat=%s): %v", path, chatID, err) + } +} + +// TestVaultChatIDIsolation_Isolated verifies that isolated-team vault search +// excludes cross-chat docs while still returning team-wide (chat_id IS NULL) +// docs and same-chat docs. +func TestVaultChatIDIsolation_Isolated(t *testing.T) { + db := testDB(t) + vs := newVaultStore(db) + tenantA, _, agentA, _ := seedTwoTenants(t, db) + ctx := tenantCtx(tenantA) + teamID, _ := seedTeam(t, db, tenantA, agentA) + + // Three docs for the same team: chatA-only, chatB-only, team-wide (NULL). + upsertChatDoc(t, vs, ctx, tenantA, teamID, "chatA", "teams/t/chatA/noteA.md") + upsertChatDoc(t, vs, ctx, tenantA, teamID, "chatB", "teams/t/chatB/noteB.md") + upsertChatDoc(t, vs, ctx, tenantA, teamID, "", "teams/t/shared-note.md") + + teamStr := teamID.String() + + // Agent in chatA with isolated scope: sees chatA + team-wide, NOT chatB. + gotA := vaultSearchPathsWithChat(t, vs, ctx, tenantA.String(), agentA.String(), &teamStr, "chatA", true) + wantA := []string{"teams/t/chatA/noteA.md", "teams/t/shared-note.md"} + assertEqualPaths(t, "isolated chatA", wantA, gotA) + + // Agent in chatB with isolated scope: sees chatB + team-wide, NOT chatA. + gotB := vaultSearchPathsWithChat(t, vs, ctx, tenantA.String(), agentA.String(), &teamStr, "chatB", true) + wantB := []string{"teams/t/chatB/noteB.md", "teams/t/shared-note.md"} + assertEqualPaths(t, "isolated chatB", wantB, gotB) +} + +// TestVaultChatIDIsolation_SharedUnfiltered verifies that when TeamIsolated +// is false (shared workspace) the chat_id filter is skipped entirely — agents +// see every team doc regardless of origin chat. This is the pre-chat_id +// behavior and must not regress for shared teams. +func TestVaultChatIDIsolation_SharedUnfiltered(t *testing.T) { + db := testDB(t) + vs := newVaultStore(db) + tenantA, _, agentA, _ := seedTwoTenants(t, db) + ctx := tenantCtx(tenantA) + teamID, _ := seedTeam(t, db, tenantA, agentA) + + upsertChatDoc(t, vs, ctx, tenantA, teamID, "chatA", "teams/t/chatA/noteA.md") + upsertChatDoc(t, vs, ctx, tenantA, teamID, "chatB", "teams/t/chatB/noteB.md") + upsertChatDoc(t, vs, ctx, tenantA, teamID, "", "teams/t/shared-note.md") + + teamStr := teamID.String() + got := vaultSearchPathsWithChat(t, vs, ctx, tenantA.String(), agentA.String(), &teamStr, "chatA", false) + want := []string{"teams/t/chatA/noteA.md", "teams/t/chatB/noteB.md", "teams/t/shared-note.md"} + assertEqualPaths(t, "shared mode (no filter)", want, got) +} + +func assertEqualPaths(t *testing.T, label string, want, got []string) { + t.Helper() + if len(want) != len(got) { + t.Fatalf("%s: paths mismatch\n want: %v\n got: %v", label, want, got) + } + for i := range want { + if want[i] != got[i] { + t.Fatalf("%s: paths mismatch\n want: %v\n got: %v", label, want, got) + } + } +}