package tools import ( "context" "fmt" "log/slog" "path/filepath" "strings" "time" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/bootstrap" "github.com/nextlevelbuilder/goclaw/internal/cache" "github.com/nextlevelbuilder/goclaw/internal/store" ) // protectedFileSet defines files that require group file writer permission in group chats. // These files control the agent's identity and behavior — only allowlisted users can modify them. var protectedFileSet = map[string]bool{ bootstrap.SoulFile: true, bootstrap.IdentityFile: true, bootstrap.AgentsFile: true, bootstrap.UserFile: true, bootstrap.UserPredefinedFile: true, } // contextFileSet is the set of filenames routed to the DB store. // TOOLS.md excluded — not applicable. var contextFileSet = map[string]bool{ bootstrap.SoulFile: true, bootstrap.AgentsFile: true, bootstrap.IdentityFile: true, bootstrap.UserFile: true, bootstrap.UserPredefinedFile: true, bootstrap.BootstrapFile: true, // first-run file (deleted after completion) bootstrap.HeartbeatFile: true, // agent-level heartbeat checklist } // isContextFile checks if a path refers to a workspace-root context file. // Handles both relative ("SOUL.md") and absolute ("/workspace/SOUL.md") paths. // Also matches absolute paths under per-user workspace subdirectories // (e.g. "/workspace//USER.md") since context files at any depth // under the workspace root should be routed to DB. func isContextFile(path, workspace string) (fileName string, ok bool) { base := filepath.Base(path) if !contextFileSet[base] { return "", false } // Relative root-level: "SOUL.md", "./SOUL.md" dir := filepath.Dir(path) if dir == "." || dir == "/" || dir == "" { return base, true } // Absolute path under workspace (includes per-user subdirectories): // "/workspace/SOUL.md" or "/workspace//SOUL.md" if workspace != "" && filepath.IsAbs(path) { cleanPath := filepath.Clean(path) cleanWS := filepath.Clean(workspace) if strings.HasPrefix(filepath.Dir(cleanPath), cleanWS) { return base, true } } return "", false } const defaultContextCacheTTL = 5 * time.Minute // ContextFileInterceptor routes context file reads/writes to the agent store. // Keeps SOUL.md, IDENTITY.md etc. in Postgres. // Routes based on agent type: "open" → all per-user, "predefined" → only USER.md per-user. type ContextFileInterceptor struct { agentStore store.AgentStore workspace string // workspace root for matching absolute paths agentCache cache.Cache[[]store.AgentContextFileData] // agent-level files, keyed by agentID.String() userCache cache.Cache[[]store.AgentContextFileData] // user-level files, keyed by "agentID:userID" ttl time.Duration groupWriterCache *store.GroupWriterCache // nil = use direct DB call (backward compat) } // NewContextFileInterceptor creates an interceptor backed by the given agent store. // Cache implementations are injected (in-memory or Redis) so callers control the backend. func NewContextFileInterceptor( as store.AgentStore, workspace string, agentCache, userCache cache.Cache[[]store.AgentContextFileData], ) *ContextFileInterceptor { return &ContextFileInterceptor{ agentStore: as, workspace: workspace, agentCache: agentCache, userCache: userCache, ttl: defaultContextCacheTTL, } } // SetGroupWriterCache sets the shared cache for group writer permission checks (defense-in-depth). func (b *ContextFileInterceptor) SetGroupWriterCache(c *store.GroupWriterCache) { b.groupWriterCache = c } // ReadFile attempts to read a context file from the DB (with cache). // Routes based on agent type from context: // - "open": all files per-user → fallback to agent-level // - "predefined": USER.md + BOOTSTRAP.md per-user → all others agent-level // // Returns (content, true, nil) if handled, or ("", false, nil) if not a context file. func (b *ContextFileInterceptor) ReadFile(ctx context.Context, path string) (string, bool, error) { fileName, ok := isContextFile(path, b.workspace) if !ok { return "", false, nil } agentID := store.AgentIDFromContext(ctx) if agentID == uuid.Nil { return "", false, nil // no agent context } userID := store.UserIDFromContext(ctx) agentType := store.AgentTypeFromContext(ctx) // Open agent: ALL files per-user → fallback to agent-level if agentType == store.AgentTypeOpen && userID != "" { content, handled, err := b.readUserFile(ctx, agentID, userID, fileName) if err != nil { return "", handled, err } if content != "" { return content, handled, nil } // User file not found → fall back to agent-level template return b.readAgentFile(ctx, agentID, fileName) } // Predefined agent: USER.md and BOOTSTRAP.md per-user if agentType == store.AgentTypePredefined && userID != "" && (fileName == bootstrap.UserFile || fileName == bootstrap.BootstrapFile) { content, handled, err := b.readUserFile(ctx, agentID, userID, fileName) if err != nil { return "", handled, err } if content != "" { return content, handled, nil } return b.readAgentFile(ctx, agentID, fileName) } // Predefined agent: block reads of shared identity files (SOUL.md, IDENTITY.md, AGENTS.md). // These are already injected into the system prompt — allowing read_file would let the // agent echo their full contents to users, leaking persona configuration. if agentType == store.AgentTypePredefined && fileName != bootstrap.UserFile && fileName != bootstrap.BootstrapFile && fileName != bootstrap.HeartbeatFile { return "", true, fmt.Errorf( "this file (%s) is already loaded into your context. You don't need to read it again — refer to your system instructions instead.", fileName, ) } // Default: agent-level return b.readAgentFile(ctx, agentID, fileName) } func (b *ContextFileInterceptor) readAgentFile(ctx context.Context, agentID uuid.UUID, fileName string) (string, bool, error) { for _, f := range b.cachedAgentFiles(ctx, agentID) { if f.FileName == fileName { return f.Content, true, nil } } return "", true, nil } func (b *ContextFileInterceptor) readUserFile(ctx context.Context, agentID uuid.UUID, userID, fileName string) (string, bool, error) { for _, f := range b.cachedUserFiles(ctx, agentID, userID) { if f.FileName == fileName { return f.Content, true, nil } } return "", true, nil } // WriteFile attempts to write a context file to the DB. // Routes based on agent type: // - "open": all files per-user // - "predefined": only USER.md per-user, others agent-level // - BOOTSTRAP.md with empty content → delete (first-run completed) // // Returns (true, nil) if handled, or (false, nil) if not a context file. func (b *ContextFileInterceptor) WriteFile(ctx context.Context, path, content string) (bool, error) { fileName, ok := isContextFile(path, b.workspace) if !ok { return false, nil } agentID := store.AgentIDFromContext(ctx) if agentID == uuid.Nil { return false, nil // no agent context } userID := store.UserIDFromContext(ctx) agentType := store.AgentTypeFromContext(ctx) // Permission check: protected files in group context require allowlist membership. // Exception: during bootstrap onboarding (BOOTSTRAP.md still exists for this user), // USER.md writes are allowed so the bot can complete the first-run ritual. if (strings.HasPrefix(userID, "group:") || strings.HasPrefix(userID, "guild:")) && protectedFileSet[fileName] { skipCheck := false if fileName == bootstrap.UserFile && b.hasBootstrapFile(ctx, agentID, userID) { skipCheck = true // onboarding in progress — allow USER.md write } if !skipCheck { senderID := store.SenderIDFromContext(ctx) if senderID != "" { numericID := strings.SplitN(senderID, "|", 2)[0] var isWriter bool var err error if b.groupWriterCache != nil { isWriter, err = b.groupWriterCache.IsWriter(ctx, agentID, userID, numericID) } else { isWriter, err = b.agentStore.IsGroupFileWriter(ctx, agentID, userID, numericID) } if err != nil { slog.Warn("security.group_file_writer_check_failed", "error", err, "sender", numericID, "file", fileName, "group", userID) // fail open: allow write if DB check fails } else if !isWriter { return true, fmt.Errorf("permission denied: you are not authorized to modify %s in this group. Ask a group file writer to add you with /addwriter", fileName) } } // senderID empty = system context (cron, subagent) → fail open } } // BOOTSTRAP.md deletion: empty content = first-run completed → delete row. // Must come BEFORE the predefined write block so bootstrap completion works // for both open and predefined agents. if fileName == bootstrap.BootstrapFile && content == "" && userID != "" { err := b.agentStore.DeleteUserContextFile(ctx, agentID, userID, fileName) if err == nil { b.invalidateUser(agentID, userID) } return true, err } // Predefined agent: block writes to shared files (only USER.md + HEARTBEAT.md allowed). // Exception: SOUL.md is allowed when self_evolve is enabled (style/tone evolution). if agentType == store.AgentTypePredefined && fileName != bootstrap.UserFile && fileName != bootstrap.HeartbeatFile { allowSoulEvolve := fileName == bootstrap.SoulFile && store.SelfEvolveFromContext(ctx) if !allowSoulEvolve { return true, fmt.Errorf( "this file (%s) is part of the agent's predefined configuration and cannot be modified through chat. "+ "Only the agent owner can edit it from the management dashboard.", fileName, ) } // SOUL.md with self_evolve: write to agent-level (shared across all users) slog.Info("self-evolve: SOUL.md updated", "agent_id", agentID, "user_id", userID, ) err := b.agentStore.SetAgentContextFile(ctx, agentID, fileName, content) if err == nil { b.InvalidateAgent(agentID) } return true, err } // Open agent: all files per-user if agentType == store.AgentTypeOpen && userID != "" { err := b.agentStore.SetUserContextFile(ctx, agentID, userID, fileName, content) if err == nil { b.invalidateUser(agentID, userID) } return true, err } // Predefined agent: only USER.md per-user if agentType == store.AgentTypePredefined && userID != "" && fileName == bootstrap.UserFile { err := b.agentStore.SetUserContextFile(ctx, agentID, userID, fileName, content) if err == nil { b.invalidateUser(agentID, userID) } return true, err } // Default: agent-level err := b.agentStore.SetAgentContextFile(ctx, agentID, fileName, content) if err == nil { b.InvalidateAgent(agentID) } return true, err } // LoadContextFiles loads context files for a specific user+agent combination. // Used by the agent loop to dynamically resolve context files for system prompt. // Uses the same agentCache/userCache as ReadFile — invalidated on WriteFile and pubsub events. func (b *ContextFileInterceptor) LoadContextFiles(ctx context.Context, agentID uuid.UUID, userID, agentType string) []bootstrap.ContextFile { // Open agent: all files from user_context_files if agentType == store.AgentTypeOpen && userID != "" { files := b.cachedUserFiles(ctx, agentID, userID) var result []bootstrap.ContextFile for _, f := range files { if f.Content == "" { continue } result = append(result, bootstrap.ContextFile{ Path: f.FileName, Content: f.Content, }) } if len(result) > 0 { return result } // No user files yet → fall through to agent-level } // Predefined agent: agent files + override USER.md from user if agentType == store.AgentTypePredefined && userID != "" { agentFiles := b.cachedAgentFiles(ctx, agentID) userFiles := b.cachedUserFiles(ctx, agentID, userID) // Build user file map for override lookup userMap := make(map[string]string, len(userFiles)) for _, f := range userFiles { if f.Content != "" { userMap[f.FileName] = f.Content } } var result []bootstrap.ContextFile for _, f := range agentFiles { content := f.Content // Override with user version if available if uc, ok := userMap[f.FileName]; ok { content = uc } if content == "" { continue } result = append(result, bootstrap.ContextFile{ Path: f.FileName, Content: content, }) } // Include user-only files not present at agent level // (e.g. BOOTSTRAP.md — seeded per-user for onboarding, not at agent level) agentFileSet := make(map[string]bool, len(agentFiles)) for _, f := range agentFiles { agentFileSet[f.FileName] = true } for _, f := range userFiles { if !agentFileSet[f.FileName] && f.Content != "" { result = append(result, bootstrap.ContextFile{ Path: f.FileName, Content: f.Content, }) } } return result } // Fallback: agent-level only agentFiles := b.cachedAgentFiles(ctx, agentID) var result []bootstrap.ContextFile for _, f := range agentFiles { if f.Content == "" { continue } result = append(result, bootstrap.ContextFile{ Path: f.FileName, Content: f.Content, }) } return result } // cachedAgentFiles returns agent-level context files, using agentCache. // Same cache used by readAgentFile — invalidated by WriteFile and pubsub events. func (b *ContextFileInterceptor) cachedAgentFiles(ctx context.Context, agentID uuid.UUID) []store.AgentContextFileData { key := agentID.String() if files, ok := b.agentCache.Get(ctx, key); ok { return files } files, err := b.agentStore.GetAgentContextFiles(ctx, agentID) if err != nil { return nil } b.agentCache.Set(ctx, key, files, b.ttl) return files } // cachedUserFiles returns user-level context files, using userCache. // Same cache used by readUserFile — invalidated by WriteFile and pubsub events. func (b *ContextFileInterceptor) cachedUserFiles(ctx context.Context, agentID uuid.UUID, userID string) []store.AgentContextFileData { key := agentID.String() + ":" + userID if files, ok := b.userCache.Get(ctx, key); ok { return files } files, err := b.agentStore.GetUserContextFiles(ctx, agentID, userID) if err != nil { return nil } // Convert to AgentContextFileData for unified cache storage cached := make([]store.AgentContextFileData, len(files)) for i, f := range files { cached[i] = store.AgentContextFileData{ AgentID: f.AgentID, FileName: f.FileName, Content: f.Content, } } b.userCache.Set(ctx, key, cached, b.ttl) return cached } // InvalidateAgent clears the cache for a specific agent (called from event handler). func (b *ContextFileInterceptor) InvalidateAgent(agentID uuid.UUID) { ctx := context.Background() b.agentCache.Delete(ctx, agentID.String()) // Also clear user caches for this agent b.userCache.DeleteByPrefix(ctx, agentID.String()+":") } // InvalidateAll clears all cached entries. func (b *ContextFileInterceptor) InvalidateAll() { ctx := context.Background() b.agentCache.Clear(ctx) b.userCache.Clear(ctx) } // hasBootstrapFile checks if BOOTSTRAP.md still exists in user_context_files, // indicating the user is still in onboarding. Used to exempt USER.md writes // from group permission checks during the first-run ritual. func (b *ContextFileInterceptor) hasBootstrapFile(ctx context.Context, agentID uuid.UUID, userID string) bool { content, _, err := b.readUserFile(ctx, agentID, userID, bootstrap.BootstrapFile) return err == nil && content != "" } // InvalidateUser clears the per-user cache for a specific agent+user combination. func (b *ContextFileInterceptor) InvalidateUser(agentID uuid.UUID, userID string) { b.invalidateUser(agentID, userID) } func (b *ContextFileInterceptor) invalidateUser(agentID uuid.UUID, userID string) { b.userCache.Delete(context.Background(), agentID.String()+":"+userID) } // normalizeToRelative strips the workspace prefix from an absolute path, // returning a workspace-relative path for consistent DB storage. // e.g. "/home/user/workspace/SOUL.md" → "SOUL.md" func normalizeToRelative(path, workspace string) string { if workspace == "" || !filepath.IsAbs(path) { return path } rel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(path)) if err != nil || strings.HasPrefix(rel, "..") { return path // outside workspace, return as-is } return rel }