feat(vault): chat_id isolation for isolated teams

- 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.
This commit is contained in:
viettranx committed 2026-04-23 18:43:31 +07:00
1 parent 955b7a512f
commit c7b2df9e34
22 files changed
+457 -68

No files matched your search

+1
View File
@@ -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,
+39 -3
View File
@@ -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,
+8 -7
View File
@@ -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
+53 -17
View File
@@ -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)
@@ -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
+5
View File
@@ -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
}
+8 -3
View File
@@ -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 {
+1
View File
@@ -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
+6 -1
View File
@@ -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.
+2
View File
@@ -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
@@ -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
+27 -14
View File
@@ -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 {
+3
View File
@@ -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
+17
View File
@@ -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,
+13 -1
View File
@@ -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
}
+87
View File
@@ -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()
+7 -1
View File
@@ -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 != "" {
+1 -1
View File
@@ -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
+22 -18
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_vault_docs_team_chat;
ALTER TABLE vault_documents DROP COLUMN IF EXISTS chat_id;
+19
View File
@@ -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/<team_uuid>/<chat>/...
-- non-master tenant: tenants/<slug>/teams/<team_uuid>/<chat>/...
-- 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/[^/]+/[^.][^/]*/';
@@ -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)
}
}
}