mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 20:17:20 +00:00
refactor: merge file writers into config permissions + agent hard delete
Migration 000023: - Migrate group_file_writers data into agent_config_permissions (config_type='file_writer') - Add FK CASCADE/SET NULL on 16 tables for agent hard delete - Clean up soft-deleted zombie agents - ALTER scope VARCHAR(100) → VARCHAR(255) - Partial unique index on agent_key (allows reuse after delete) Store layer: - Remove GroupWriterCache, GroupFileWriterData, 5 AgentStore methods - Add CheckFileWriterPermission (fail-open), ListFileWriters (cached) - Add scope filter to ConfigPermissionStore.List() Tools: Replace GroupWriterCache with ConfigPermissionStore in read_file, write_file, edit, cron, context_file_interceptor Agent loop: buildGroupWriterPrompt uses ListFileWriters + metadata parsing Channels: Telegram commands rewritten to use configPermStore.Grant/Revoke/List HTTP API: Writer endpoints rewired to configPermStore (same response shapes) Hard delete: agents.go DELETE FROM instead of soft delete
This commit is contained in:
1 parent
68a4ee39b4
commit
6595c0185a
35 files changed
+519
-436
No files matched your search
+1
-1
@@ -436,7 +436,7 @@ func runGateway() {
|
||||
instanceLoader = channels.NewInstanceLoader(pgStores.ChannelInstances, pgStores.Agents, channelMgr, msgBus, pgStores.Pairing)
|
||||
instanceLoader.SetProviderRegistry(providerRegistry)
|
||||
instanceLoader.SetPendingCompactionConfig(cfg.Channels.PendingCompaction)
|
||||
instanceLoader.RegisterFactory(channels.TypeTelegram, telegram.FactoryWithStores(pgStores.Agents, pgStores.Teams, pgStores.PendingMessages))
|
||||
instanceLoader.RegisterFactory(channels.TypeTelegram, telegram.FactoryWithStores(pgStores.Agents, pgStores.ConfigPermissions, pgStores.Teams, pgStores.PendingMessages))
|
||||
instanceLoader.RegisterFactory(channels.TypeDiscord, discord.FactoryWithPendingStore(pgStores.PendingMessages))
|
||||
instanceLoader.RegisterFactory(channels.TypeFeishu, feishu.FactoryWithPendingStore(pgStores.PendingMessages))
|
||||
instanceLoader.RegisterFactory(channels.TypeZaloOA, zalo.Factory)
|
||||
|
||||
+13
-10
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
@@ -9,15 +10,13 @@ import (
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/agent"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
// buildEnsureUserFiles creates the per-user file seeding callback.
|
||||
// Seeds per-user context files on first chat (new user profile).
|
||||
func buildEnsureUserFiles(as store.AgentStore, msgBus *bus.MessageBus) agent.EnsureUserFilesFunc {
|
||||
func buildEnsureUserFiles(as store.AgentStore, configPermStore store.ConfigPermissionStore) agent.EnsureUserFilesFunc {
|
||||
return func(ctx context.Context, agentID uuid.UUID, userID, agentType, workspace, channel string) (string, error) {
|
||||
isNew, effectiveWs, err := as.GetOrCreateUserProfile(ctx, agentID, userID, workspace, channel)
|
||||
if err != nil {
|
||||
@@ -28,7 +27,7 @@ func buildEnsureUserFiles(as store.AgentStore, msgBus *bus.MessageBus) agent.Ens
|
||||
}
|
||||
|
||||
// Auto-add first group member as a file writer (bootstrap the allowlist).
|
||||
if strings.HasPrefix(userID, "group:") || strings.HasPrefix(userID, "guild:") {
|
||||
if configPermStore != nil && (strings.HasPrefix(userID, "group:") || strings.HasPrefix(userID, "guild:")) {
|
||||
senderID := store.SenderIDFromContext(ctx)
|
||||
if senderID != "" {
|
||||
parts := strings.SplitN(senderID, "|", 2)
|
||||
@@ -37,14 +36,18 @@ func buildEnsureUserFiles(as store.AgentStore, msgBus *bus.MessageBus) agent.Ens
|
||||
if len(parts) > 1 {
|
||||
senderUsername = parts[1]
|
||||
}
|
||||
if addErr := as.AddGroupFileWriter(ctx, agentID, userID, numericID, "", senderUsername); addErr != nil {
|
||||
meta, _ := json.Marshal(map[string]string{"displayName": "", "username": senderUsername})
|
||||
if addErr := configPermStore.Grant(ctx, &store.ConfigPermission{
|
||||
AgentID: agentID,
|
||||
Scope: userID,
|
||||
ConfigType: "file_writer",
|
||||
UserID: numericID,
|
||||
Permission: "allow",
|
||||
Metadata: meta,
|
||||
}); addErr != nil {
|
||||
slog.Warn("failed to auto-add group file writer", "error", addErr, "sender", numericID, "group", userID)
|
||||
} else if msgBus != nil {
|
||||
msgBus.Broadcast(bus.Event{
|
||||
Name: protocol.EventCacheInvalidate,
|
||||
Payload: bus.CacheInvalidatePayload{Kind: bus.CacheKindGroupFileWriters, Key: userID},
|
||||
})
|
||||
}
|
||||
// No bus broadcast needed — Grant already invalidates cache
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import (
|
||||
// registerConfigChannels registers config-based channels as fallback when no DB instances are loaded.
|
||||
func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, msgBus *bus.MessageBus, pgStores *store.Stores, instanceLoader *channels.InstanceLoader) {
|
||||
if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token != "" && instanceLoader == nil {
|
||||
tg, err := telegram.New(cfg.Channels.Telegram, msgBus, pgStores.Pairing, nil, nil, nil)
|
||||
tg, err := telegram.New(cfg.Channels.Telegram, msgBus, pgStores.Pairing, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
slog.Error("failed to initialize telegram channel", "error", err)
|
||||
} else {
|
||||
|
||||
@@ -53,7 +53,7 @@ func wireHTTP(stores *store.Stores, token, defaultWorkspace string, msgBus *bus.
|
||||
}
|
||||
|
||||
if stores != nil && stores.ChannelInstances != nil {
|
||||
channelInstancesH = httpapi.NewChannelInstancesHandler(stores.ChannelInstances, stores.Agents, stores.Contacts, token, msgBus)
|
||||
channelInstancesH = httpapi.NewChannelInstancesHandler(stores.ChannelInstances, stores.Agents, stores.ConfigPermissions, stores.Contacts, token, msgBus)
|
||||
}
|
||||
|
||||
if stores != nil && stores.Providers != nil {
|
||||
|
||||
+8
-32
@@ -49,7 +49,7 @@ func wireExtras(
|
||||
redisClient any, // nil when built without -tags redis or when Redis is unconfigured
|
||||
) (*tools.ContextFileInterceptor, *mcpbridge.Pool, *media.Store, tools.PostTurnProcessor) {
|
||||
// 1. Build cache instances (in-memory or Redis depending on build tags)
|
||||
agentCtxCache, userCtxCache, gwCache := makeCaches(redisClient)
|
||||
agentCtxCache, userCtxCache := makeCaches(redisClient)
|
||||
|
||||
// 1a. Context file interceptor (created before resolver so callbacks can reference it)
|
||||
var contextFileInterceptor *tools.ContextFileInterceptor
|
||||
@@ -57,12 +57,6 @@ func wireExtras(
|
||||
contextFileInterceptor = tools.NewContextFileInterceptor(stores.Agents, workspace, agentCtxCache, userCtxCache)
|
||||
}
|
||||
|
||||
// 1b. Group writer cache (wraps ListGroupFileWriters with TTL cache)
|
||||
var groupWriterCache *store.GroupWriterCache
|
||||
if stores.Agents != nil {
|
||||
groupWriterCache = store.NewGroupWriterCache(stores.Agents, gwCache)
|
||||
}
|
||||
|
||||
// 1c. Persistent media storage for cross-turn image/document access
|
||||
mediaStore, err := media.NewStore(filepath.Join(workspace, ".media"))
|
||||
if err != nil {
|
||||
@@ -96,7 +90,7 @@ func wireExtras(
|
||||
// 2. User seeding callback: seeds per-user context files on first chat
|
||||
var ensureUserFiles agent.EnsureUserFilesFunc
|
||||
if stores.Agents != nil {
|
||||
ensureUserFiles = buildEnsureUserFiles(stores.Agents, msgBus)
|
||||
ensureUserFiles = buildEnsureUserFiles(stores.Agents, stores.ConfigPermissions)
|
||||
}
|
||||
|
||||
// 3. Context file loader callback: loads per-user context files dynamically
|
||||
@@ -159,7 +153,7 @@ func wireExtras(
|
||||
BuiltinToolStore: stores.BuiltinTools,
|
||||
MCPStore: stores.MCP,
|
||||
MCPPool: mcpPool,
|
||||
GroupWriterCache: groupWriterCache,
|
||||
ConfigPermStore: stores.ConfigPermissions,
|
||||
MediaStore: mediaStore,
|
||||
ModelPricing: appCfg.Telemetry.ModelPricing,
|
||||
TracingStore: stores.Tracing,
|
||||
@@ -222,17 +216,17 @@ func wireExtras(
|
||||
}
|
||||
}
|
||||
|
||||
// Wire group writer cache for permission checks
|
||||
if groupWriterCache != nil {
|
||||
// Wire config perm store for file writer permission checks
|
||||
if stores.ConfigPermissions != nil {
|
||||
for _, toolName := range []string{"read_file", "write_file", "edit", "cron"} {
|
||||
if t, ok := toolsReg.Get(toolName); ok {
|
||||
if gwa, ok := t.(tools.GroupWriterAware); ok {
|
||||
gwa.SetGroupWriterCache(groupWriterCache)
|
||||
if cpa, ok := t.(tools.ConfigPermAware); ok {
|
||||
cpa.SetConfigPermStore(stores.ConfigPermissions)
|
||||
}
|
||||
}
|
||||
}
|
||||
if contextFileInterceptor != nil {
|
||||
contextFileInterceptor.SetGroupWriterCache(groupWriterCache)
|
||||
contextFileInterceptor.SetConfigPermStore(stores.ConfigPermissions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,24 +469,6 @@ func wireExtras(
|
||||
}
|
||||
})
|
||||
|
||||
// Group writer cache: invalidate on writer list changes
|
||||
if groupWriterCache != nil {
|
||||
msgBus.Subscribe(bus.TopicCacheGroupFileWriters, func(event bus.Event) {
|
||||
if event.Name != protocol.EventCacheInvalidate {
|
||||
return
|
||||
}
|
||||
payload, ok := event.Payload.(bus.CacheInvalidatePayload)
|
||||
if !ok || payload.Kind != bus.CacheKindGroupFileWriters {
|
||||
return
|
||||
}
|
||||
if payload.Key != "" {
|
||||
groupWriterCache.Invalidate(payload.Key)
|
||||
} else {
|
||||
groupWriterCache.InvalidateAll()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Provider cache: re-register ACP providers on create/update/delete
|
||||
msgBus.Subscribe(bus.TopicCacheProvider, func(event bus.Event) {
|
||||
if event.Name != protocol.EventCacheInvalidate {
|
||||
|
||||
@@ -33,19 +33,16 @@ func initRedisClient(cfg *config.Config) any {
|
||||
func makeCaches(raw any) (
|
||||
agentCtxCache cache.Cache[[]store.AgentContextFileData],
|
||||
userCtxCache cache.Cache[[]store.AgentContextFileData],
|
||||
groupWriterCache cache.Cache[[]store.GroupFileWriterData],
|
||||
) {
|
||||
client, _ := raw.(*redis.Client)
|
||||
if client == nil {
|
||||
slog.Info("cache backend: in-memory (Redis not connected)")
|
||||
return cache.NewInMemoryCache[[]store.AgentContextFileData](),
|
||||
cache.NewInMemoryCache[[]store.AgentContextFileData](),
|
||||
cache.NewInMemoryCache[[]store.GroupFileWriterData]()
|
||||
cache.NewInMemoryCache[[]store.AgentContextFileData]()
|
||||
}
|
||||
slog.Info("cache backend: redis")
|
||||
return cache.NewRedisCache[[]store.AgentContextFileData](client, "ctx:agent"),
|
||||
cache.NewRedisCache[[]store.AgentContextFileData](client, "ctx:user"),
|
||||
cache.NewRedisCache[[]store.GroupFileWriterData](client, "grp:writers")
|
||||
cache.NewRedisCache[[]store.AgentContextFileData](client, "ctx:user")
|
||||
}
|
||||
|
||||
// shutdownRedis closes the Redis client connection.
|
||||
|
||||
@@ -18,12 +18,10 @@ func initRedisClient(_ *config.Config) any { return nil }
|
||||
func makeCaches(_ any) (
|
||||
agentCtxCache cache.Cache[[]store.AgentContextFileData],
|
||||
userCtxCache cache.Cache[[]store.AgentContextFileData],
|
||||
groupWriterCache cache.Cache[[]store.GroupFileWriterData],
|
||||
) {
|
||||
slog.Info("cache backend: in-memory")
|
||||
return cache.NewInMemoryCache[[]store.AgentContextFileData](),
|
||||
cache.NewInMemoryCache[[]store.AgentContextFileData](),
|
||||
cache.NewInMemoryCache[[]store.GroupFileWriterData]()
|
||||
cache.NewInMemoryCache[[]store.AgentContextFileData]()
|
||||
}
|
||||
|
||||
// shutdownRedis is a no-op when built without the "redis" tag.
|
||||
|
||||
@@ -2,6 +2,7 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
@@ -124,7 +125,7 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s
|
||||
}
|
||||
|
||||
// Group writer restrictions: filter context files + inject prompt
|
||||
if l.groupWriterCache != nil && (strings.HasPrefix(userID, "group:") || strings.HasPrefix(userID, "guild:")) {
|
||||
if l.configPermStore != nil && (strings.HasPrefix(userID, "group:") || strings.HasPrefix(userID, "guild:")) {
|
||||
senderID := store.SenderIDFromContext(ctx)
|
||||
writerPrompt, filtered := l.buildGroupWriterPrompt(ctx, userID, senderID, contextFiles)
|
||||
contextFiles = filtered
|
||||
@@ -560,7 +561,7 @@ func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) {
|
||||
// buildGroupWriterPrompt builds the system prompt section for group file writer restrictions.
|
||||
// For non-writers: injects refusal instructions + removes SOUL.md/AGENTS.md from context files.
|
||||
func (l *Loop) buildGroupWriterPrompt(ctx context.Context, groupID, senderID string, files []bootstrap.ContextFile) (string, []bootstrap.ContextFile) {
|
||||
writers, err := l.groupWriterCache.ListWriters(ctx, l.agentUUID, groupID)
|
||||
writers, err := l.configPermStore.ListFileWriters(ctx, l.agentUUID, groupID)
|
||||
if err != nil || len(writers) == 0 {
|
||||
return "", files // fail-open
|
||||
}
|
||||
@@ -585,13 +586,19 @@ func (l *Loop) buildGroupWriterPrompt(ctx context.Context, groupID, senderID str
|
||||
}
|
||||
}
|
||||
|
||||
// Build writer display names
|
||||
// Build writer display names from metadata JSON
|
||||
type fwMeta struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
var names []string
|
||||
for _, w := range writers {
|
||||
if w.Username != nil && *w.Username != "" {
|
||||
names = append(names, "@"+*w.Username)
|
||||
} else if w.DisplayName != nil && *w.DisplayName != "" {
|
||||
names = append(names, *w.DisplayName)
|
||||
var meta fwMeta
|
||||
_ = json.Unmarshal(w.Metadata, &meta)
|
||||
if meta.Username != "" {
|
||||
names = append(names, "@"+meta.Username)
|
||||
} else if meta.DisplayName != "" {
|
||||
names = append(names, meta.DisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -119,8 +119,8 @@ type Loop struct {
|
||||
skillEvolve bool
|
||||
skillNudgeInterval int // nudge every N tool calls (0 = disabled, 15 = default)
|
||||
|
||||
// Group writer cache for system prompt injection
|
||||
groupWriterCache *store.GroupWriterCache
|
||||
// Config permission store for group file writer checks
|
||||
configPermStore store.ConfigPermissionStore
|
||||
|
||||
// Team store for cross-session pending task detection
|
||||
teamStore store.TeamStore
|
||||
@@ -236,8 +236,8 @@ type LoopConfig struct {
|
||||
SkillEvolve bool
|
||||
SkillNudgeInterval int // 0 = disabled, 15 = default
|
||||
|
||||
// Group writer cache for system prompt injection
|
||||
GroupWriterCache *store.GroupWriterCache
|
||||
// Config permission store for group file writer checks
|
||||
ConfigPermStore store.ConfigPermissionStore
|
||||
|
||||
// Team store for cross-session pending task detection
|
||||
TeamStore store.TeamStore
|
||||
@@ -335,7 +335,7 @@ func NewLoop(cfg LoopConfig) *Loop {
|
||||
selfEvolve: cfg.SelfEvolve,
|
||||
skillEvolve: cfg.SkillEvolve,
|
||||
skillNudgeInterval: cfg.SkillNudgeInterval,
|
||||
groupWriterCache: cfg.GroupWriterCache,
|
||||
configPermStore: cfg.ConfigPermStore,
|
||||
teamStore: cfg.TeamStore,
|
||||
secureCLIStore: cfg.SecureCLIStore,
|
||||
mediaStore: cfg.MediaStore,
|
||||
|
||||
@@ -75,8 +75,8 @@ type ResolverDeps struct {
|
||||
// Skill access store — for per-agent skill visibility filtering
|
||||
SkillAccessStore store.SkillAccessStore
|
||||
|
||||
// Group file writer cache
|
||||
GroupWriterCache *store.GroupWriterCache
|
||||
// Config permission store for group file writer checks
|
||||
ConfigPermStore store.ConfigPermissionStore
|
||||
|
||||
// Persistent media storage for cross-turn image/document access
|
||||
MediaStore *media.Store
|
||||
@@ -349,7 +349,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
|
||||
SkillNudgeInterval: ag.ParseSkillNudgeInterval(),
|
||||
WorkspaceSharing: ag.ParseWorkspaceSharing(),
|
||||
ShellDenyGroups: ag.ParseShellDenyGroups(),
|
||||
GroupWriterCache: deps.GroupWriterCache,
|
||||
ConfigPermStore: deps.ConfigPermStore,
|
||||
TeamStore: deps.TeamStore,
|
||||
SecureCLIStore: deps.SecureCLIStore,
|
||||
MediaStore: deps.MediaStore,
|
||||
|
||||
@@ -87,21 +87,6 @@ func (s *seedStubStore) SetUserOverride(_ context.Context, _ *store.UserAgentOve
|
||||
func (s *seedStubStore) GetOrCreateUserProfile(_ context.Context, _ uuid.UUID, _, _, _ string) (bool, string, error) {
|
||||
return false, "", nil
|
||||
}
|
||||
func (s *seedStubStore) IsGroupFileWriter(_ context.Context, _ uuid.UUID, _, _ string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (s *seedStubStore) AddGroupFileWriter(_ context.Context, _ uuid.UUID, _, _, _, _ string) error {
|
||||
return nil
|
||||
}
|
||||
func (s *seedStubStore) RemoveGroupFileWriter(_ context.Context, _ uuid.UUID, _, _ string) error {
|
||||
return nil
|
||||
}
|
||||
func (s *seedStubStore) ListGroupFileWriters(_ context.Context, _ uuid.UUID, _ string) ([]store.GroupFileWriterData, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *seedStubStore) ListGroupFileWriterGroups(_ context.Context, _ uuid.UUID) ([]store.GroupWriterGroupInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *seedStubStore) ListUserInstances(_ context.Context, _ uuid.UUID) ([]store.UserInstanceData, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -61,7 +61,6 @@ const (
|
||||
CacheKindBuiltinTools = "builtin_tools"
|
||||
CacheKindTeam = "team"
|
||||
CacheKindUserWorkspace = "user_workspace"
|
||||
CacheKindGroupFileWriters = "group_file_writers"
|
||||
CacheKindSkillGrants = "skill_grants"
|
||||
CacheKindMCP = "mcp"
|
||||
CacheKindProvider = "provider"
|
||||
@@ -81,7 +80,6 @@ const (
|
||||
TopicCacheTeam = "cache:team"
|
||||
TopicCacheUserWorkspace = "cache:user_workspace"
|
||||
TopicCacheChannelInstances = "cache:channel_instances"
|
||||
TopicCacheGroupFileWriters = "cache:group_file_writers"
|
||||
TopicCacheSkillGrants = "cache:skill_grants"
|
||||
TopicCacheMCP = "cache:mcp"
|
||||
TopicCacheProvider = "cache:provider"
|
||||
|
||||
@@ -25,8 +25,9 @@ type Channel struct {
|
||||
config config.TelegramConfig
|
||||
httpClient *http.Client
|
||||
pairingService store.PairingStore
|
||||
agentStore store.AgentStore // for group file writer management (nil if not configured)
|
||||
teamStore store.TeamStore // for /tasks, /task_detail commands (nil if not configured)
|
||||
agentStore store.AgentStore // for agent key lookup (nil if not configured)
|
||||
configPermStore store.ConfigPermissionStore // for group file writer management (nil if not configured)
|
||||
teamStore store.TeamStore // for /tasks, /task_detail commands (nil if not configured)
|
||||
placeholders sync.Map // localKey string → messageID int
|
||||
stopThinking sync.Map // localKey string → *thinkingCancel
|
||||
typingCtrls sync.Map // localKey string → *typing.Controller
|
||||
@@ -54,8 +55,9 @@ func (c *thinkingCancel) Cancel() {
|
||||
// New creates a new Telegram channel from config.
|
||||
// pairingSvc is optional (nil = fall back to allowlist only).
|
||||
// agentStore is optional (nil = group file writer commands disabled).
|
||||
// configPermStore is optional (nil = group file writer commands disabled).
|
||||
// teamStore is optional (nil = /tasks, /task_detail commands disabled).
|
||||
func New(cfg config.TelegramConfig, msgBus *bus.MessageBus, pairingSvc store.PairingStore, agentStore store.AgentStore, teamStore store.TeamStore, pendingStore store.PendingMessageStore) (*Channel, error) {
|
||||
func New(cfg config.TelegramConfig, msgBus *bus.MessageBus, pairingSvc store.PairingStore, agentStore store.AgentStore, configPermStore store.ConfigPermissionStore, teamStore store.TeamStore, pendingStore store.PendingMessageStore) (*Channel, error) {
|
||||
var opts []telego.BotOption
|
||||
|
||||
if cfg.APIServer != "" {
|
||||
@@ -96,16 +98,17 @@ func New(cfg config.TelegramConfig, msgBus *bus.MessageBus, pairingSvc store.Pai
|
||||
}
|
||||
|
||||
return &Channel{
|
||||
BaseChannel: base,
|
||||
bot: bot,
|
||||
config: cfg,
|
||||
httpClient: httpClient,
|
||||
pairingService: pairingSvc,
|
||||
agentStore: agentStore,
|
||||
teamStore: teamStore,
|
||||
groupHistory: channels.MakeHistory(channels.TypeTelegram, pendingStore),
|
||||
historyLimit: historyLimit,
|
||||
requireMention: requireMention,
|
||||
BaseChannel: base,
|
||||
bot: bot,
|
||||
config: cfg,
|
||||
httpClient: httpClient,
|
||||
pairingService: pairingSvc,
|
||||
agentStore: agentStore,
|
||||
configPermStore: configPermStore,
|
||||
teamStore: teamStore,
|
||||
groupHistory: channels.MakeHistory(channels.TypeTelegram, pendingStore),
|
||||
historyLimit: historyLimit,
|
||||
requireMention: requireMention,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -93,12 +93,12 @@ func (c *Channel) handleBotCommand(ctx context.Context, message *telego.Message,
|
||||
|
||||
case "/reset":
|
||||
// In group chats, only file writers can reset conversation history.
|
||||
if isGroup && c.agentStore != nil {
|
||||
if isGroup && c.configPermStore != nil {
|
||||
agentID, err := c.resolveAgentUUID(ctx)
|
||||
if err == nil {
|
||||
groupID := fmt.Sprintf("group:%s:%s", c.Name(), chatIDStr)
|
||||
senderNumericID := strings.SplitN(senderID, "|", 2)[0]
|
||||
isWriter, err := c.agentStore.IsGroupFileWriter(ctx, agentID, groupID, senderNumericID)
|
||||
isWriter, err := c.configPermStore.CheckPermission(ctx, agentID, groupID, "file_writer", senderNumericID)
|
||||
if err != nil {
|
||||
slog.Warn("security.reset_writer_check_failed", "error", err, "sender", senderNumericID)
|
||||
// fail-open: allow reset if DB check fails
|
||||
|
||||
@@ -2,6 +2,7 @@ package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
@@ -9,8 +10,7 @@ import (
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// handleWriterCommand handles /addwriter and /removewriter commands.
|
||||
@@ -29,7 +29,7 @@ func (c *Channel) handleWriterCommand(ctx context.Context, message *telego.Messa
|
||||
return
|
||||
}
|
||||
|
||||
if c.agentStore == nil {
|
||||
if c.configPermStore == nil {
|
||||
send("File writer management is not available.")
|
||||
return
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func (c *Channel) handleWriterCommand(ctx context.Context, message *telego.Messa
|
||||
senderNumericID := strings.SplitN(senderID, "|", 2)[0]
|
||||
|
||||
// Check if sender is an existing writer (only writers can manage the list)
|
||||
isWriter, err := c.agentStore.IsGroupFileWriter(ctx, agentID, groupID, senderNumericID)
|
||||
isWriter, err := c.configPermStore.CheckPermission(ctx, agentID, groupID, "file_writer", senderNumericID)
|
||||
if err != nil {
|
||||
slog.Warn("writer check failed", "error", err, "sender", senderNumericID)
|
||||
send("Failed to check permissions. Please try again.")
|
||||
@@ -75,34 +75,34 @@ func (c *Channel) handleWriterCommand(ctx context.Context, message *telego.Messa
|
||||
|
||||
switch action {
|
||||
case "add":
|
||||
if err := c.agentStore.AddGroupFileWriter(ctx, agentID, groupID, targetID, targetUser.FirstName, targetUser.Username); err != nil {
|
||||
meta, _ := json.Marshal(map[string]string{"displayName": targetUser.FirstName, "username": targetUser.Username})
|
||||
if err := c.configPermStore.Grant(ctx, &store.ConfigPermission{
|
||||
AgentID: agentID,
|
||||
Scope: groupID,
|
||||
ConfigType: "file_writer",
|
||||
UserID: targetID,
|
||||
Permission: "allow",
|
||||
Metadata: meta,
|
||||
}); err != nil {
|
||||
slog.Warn("add writer failed", "error", err, "target", targetID)
|
||||
send("Failed to add writer. Please try again.")
|
||||
return
|
||||
}
|
||||
send(fmt.Sprintf("Added %s as a file writer.", targetName))
|
||||
c.Bus().Broadcast(bus.Event{
|
||||
Name: protocol.EventCacheInvalidate,
|
||||
Payload: bus.CacheInvalidatePayload{Kind: bus.CacheKindGroupFileWriters, Key: groupID},
|
||||
})
|
||||
|
||||
case "remove":
|
||||
// Prevent removing the last writer
|
||||
writers, _ := c.agentStore.ListGroupFileWriters(ctx, agentID, groupID)
|
||||
writers, _ := c.configPermStore.List(ctx, agentID, "file_writer", groupID)
|
||||
if len(writers) <= 1 {
|
||||
send("Cannot remove the last file writer.")
|
||||
return
|
||||
}
|
||||
if err := c.agentStore.RemoveGroupFileWriter(ctx, agentID, groupID, targetID); err != nil {
|
||||
if err := c.configPermStore.Revoke(ctx, agentID, groupID, "file_writer", targetID); err != nil {
|
||||
slog.Warn("remove writer failed", "error", err, "target", targetID)
|
||||
send("Failed to remove writer. Please try again.")
|
||||
return
|
||||
}
|
||||
send(fmt.Sprintf("Removed %s from file writers.", targetName))
|
||||
c.Bus().Broadcast(bus.Event{
|
||||
Name: protocol.EventCacheInvalidate,
|
||||
Payload: bus.CacheInvalidatePayload{Kind: bus.CacheKindGroupFileWriters, Key: groupID},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ func (c *Channel) handleListWriters(ctx context.Context, chatID int64, chatIDStr
|
||||
return
|
||||
}
|
||||
|
||||
if c.agentStore == nil {
|
||||
if c.configPermStore == nil {
|
||||
send("File writer management is not available.")
|
||||
return
|
||||
}
|
||||
@@ -135,7 +135,7 @@ func (c *Channel) handleListWriters(ctx context.Context, chatID int64, chatIDStr
|
||||
|
||||
groupID := fmt.Sprintf("group:%s:%s", c.Name(), chatIDStr)
|
||||
|
||||
writers, err := c.agentStore.ListGroupFileWriters(ctx, agentID, groupID)
|
||||
writers, err := c.configPermStore.List(ctx, agentID, "file_writer", groupID)
|
||||
if err != nil {
|
||||
slog.Warn("list writers failed", "error", err)
|
||||
send("Failed to list writers. Please try again.")
|
||||
@@ -147,14 +147,21 @@ func (c *Channel) handleListWriters(ctx context.Context, chatID int64, chatIDStr
|
||||
return
|
||||
}
|
||||
|
||||
type fwMeta struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("File writers for this group (%d):\n", len(writers)))
|
||||
for i, w := range writers {
|
||||
var meta fwMeta
|
||||
_ = json.Unmarshal(w.Metadata, &meta)
|
||||
label := w.UserID
|
||||
if w.Username != nil && *w.Username != "" {
|
||||
label = "@" + *w.Username
|
||||
} else if w.DisplayName != nil && *w.DisplayName != "" {
|
||||
label = *w.DisplayName
|
||||
if meta.Username != "" {
|
||||
label = "@" + meta.Username
|
||||
} else if meta.DisplayName != "" {
|
||||
label = meta.DisplayName
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%d. %s (ID: %s)\n", i+1, label, w.UserID))
|
||||
}
|
||||
|
||||
@@ -40,19 +40,19 @@ type telegramInstanceConfig struct {
|
||||
// Factory creates a Telegram channel from DB instance data (no extra stores).
|
||||
func Factory(name string, creds json.RawMessage, cfg json.RawMessage,
|
||||
msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) {
|
||||
return buildChannel(name, creds, cfg, msgBus, pairingSvc, nil, nil, nil)
|
||||
return buildChannel(name, creds, cfg, msgBus, pairingSvc, nil, nil, nil, nil)
|
||||
}
|
||||
|
||||
// FactoryWithStores returns a ChannelFactory that includes agent, team, and pending message stores.
|
||||
func FactoryWithStores(agentStore store.AgentStore, teamStore store.TeamStore, pendingStore store.PendingMessageStore) channels.ChannelFactory {
|
||||
// FactoryWithStores returns a ChannelFactory that includes agent, configPerm, team, and pending message stores.
|
||||
func FactoryWithStores(agentStore store.AgentStore, configPermStore store.ConfigPermissionStore, teamStore store.TeamStore, pendingStore store.PendingMessageStore) channels.ChannelFactory {
|
||||
return func(name string, creds json.RawMessage, cfg json.RawMessage,
|
||||
msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) {
|
||||
return buildChannel(name, creds, cfg, msgBus, pairingSvc, agentStore, teamStore, pendingStore)
|
||||
return buildChannel(name, creds, cfg, msgBus, pairingSvc, agentStore, configPermStore, teamStore, pendingStore)
|
||||
}
|
||||
}
|
||||
|
||||
func buildChannel(name string, creds json.RawMessage, cfg json.RawMessage,
|
||||
msgBus *bus.MessageBus, pairingSvc store.PairingStore, agentStore store.AgentStore, teamStore store.TeamStore, pendingStore store.PendingMessageStore) (channels.Channel, error) {
|
||||
msgBus *bus.MessageBus, pairingSvc store.PairingStore, agentStore store.AgentStore, configPermStore store.ConfigPermissionStore, teamStore store.TeamStore, pendingStore store.PendingMessageStore) (channels.Channel, error) {
|
||||
|
||||
var c telegramCreds
|
||||
if len(creds) > 0 {
|
||||
@@ -107,7 +107,7 @@ func buildChannel(name string, creds json.RawMessage, cfg json.RawMessage,
|
||||
tgCfg.GroupPolicy = "pairing"
|
||||
}
|
||||
|
||||
ch, err := New(tgCfg, msgBus, pairingSvc, agentStore, teamStore, pendingStore)
|
||||
ch, err := New(tgCfg, msgBus, pairingSvc, agentStore, configPermStore, teamStore, pendingStore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -80,21 +80,6 @@ func (s *createCaptureStore) SetUserOverride(_ context.Context, _ *store.UserAge
|
||||
func (s *createCaptureStore) GetOrCreateUserProfile(_ context.Context, _ uuid.UUID, _, _, _ string) (bool, string, error) {
|
||||
return false, "", nil
|
||||
}
|
||||
func (s *createCaptureStore) IsGroupFileWriter(_ context.Context, _ uuid.UUID, _, _ string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (s *createCaptureStore) AddGroupFileWriter(_ context.Context, _ uuid.UUID, _, _, _, _ string) error {
|
||||
return nil
|
||||
}
|
||||
func (s *createCaptureStore) RemoveGroupFileWriter(_ context.Context, _ uuid.UUID, _, _ string) error {
|
||||
return nil
|
||||
}
|
||||
func (s *createCaptureStore) ListGroupFileWriters(_ context.Context, _ uuid.UUID, _ string) ([]store.GroupFileWriterData, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *createCaptureStore) ListGroupFileWriterGroups(_ context.Context, _ uuid.UUID) ([]store.GroupWriterGroupInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *createCaptureStore) ListUserInstances(_ context.Context, _ uuid.UUID) ([]store.UserInstanceData, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ func (m *ConfigPermissionsMethods) handleList(ctx context.Context, client *gatew
|
||||
return
|
||||
}
|
||||
|
||||
perms, err := m.permStore.List(ctx, agentUUID, params.ConfigType)
|
||||
perms, err := m.permStore.List(ctx, agentUUID, params.ConfigType, "")
|
||||
if err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, configPermInternalErr("list", err)))
|
||||
return
|
||||
|
||||
@@ -17,16 +17,17 @@ import (
|
||||
|
||||
// ChannelInstancesHandler handles channel instance CRUD endpoints.
|
||||
type ChannelInstancesHandler struct {
|
||||
store store.ChannelInstanceStore
|
||||
agentStore store.AgentStore
|
||||
contactStore store.ContactStore
|
||||
token string
|
||||
msgBus *bus.MessageBus
|
||||
store store.ChannelInstanceStore
|
||||
agentStore store.AgentStore
|
||||
configPermStore store.ConfigPermissionStore
|
||||
contactStore store.ContactStore
|
||||
token string
|
||||
msgBus *bus.MessageBus
|
||||
}
|
||||
|
||||
// NewChannelInstancesHandler creates a handler for channel instance management endpoints.
|
||||
func NewChannelInstancesHandler(s store.ChannelInstanceStore, agentStore store.AgentStore, contactStore store.ContactStore, token string, msgBus *bus.MessageBus) *ChannelInstancesHandler {
|
||||
return &ChannelInstancesHandler{store: s, agentStore: agentStore, contactStore: contactStore, token: token, msgBus: msgBus}
|
||||
func NewChannelInstancesHandler(s store.ChannelInstanceStore, agentStore store.AgentStore, configPermStore store.ConfigPermissionStore, contactStore store.ContactStore, token string, msgBus *bus.MessageBus) *ChannelInstancesHandler {
|
||||
return &ChannelInstancesHandler{store: s, agentStore: agentStore, configPermStore: configPermStore, contactStore: contactStore, token: token, msgBus: msgBus}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers all channel instance routes on the given mux.
|
||||
@@ -44,7 +45,7 @@ func (h *ChannelInstancesHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
}
|
||||
|
||||
// Group file writers (nested under channel instances)
|
||||
if h.agentStore != nil {
|
||||
if h.configPermStore != nil {
|
||||
mux.HandleFunc("GET /v1/channels/instances/{id}/writers/groups", h.auth(h.handleWriterGroups))
|
||||
mux.HandleFunc("GET /v1/channels/instances/{id}/writers", h.auth(h.handleListWriters))
|
||||
mux.HandleFunc("POST /v1/channels/instances/{id}/writers", h.auth(h.handleAddWriter))
|
||||
@@ -303,15 +304,27 @@ func (h *ChannelInstancesHandler) handleWriterGroups(w http.ResponseWriter, r *h
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
groups, err := h.agentStore.ListGroupFileWriterGroups(r.Context(), agentID)
|
||||
perms, err := h.configPermStore.List(r.Context(), agentID, "file_writer", "")
|
||||
if err != nil {
|
||||
slog.Error("channel_instances.writer_groups", "error", err)
|
||||
locale := store.LocaleFromContext(r.Context())
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "writer groups")})
|
||||
return
|
||||
}
|
||||
if groups == nil {
|
||||
groups = []store.GroupWriterGroupInfo{}
|
||||
// Group by scope
|
||||
counts := make(map[string]int)
|
||||
for _, p := range perms {
|
||||
if p.Permission == "allow" {
|
||||
counts[p.Scope]++
|
||||
}
|
||||
}
|
||||
type groupInfo struct {
|
||||
GroupID string `json:"group_id"`
|
||||
WriterCount int `json:"writer_count"`
|
||||
}
|
||||
groups := make([]groupInfo, 0, len(counts))
|
||||
for scope, count := range counts {
|
||||
groups = append(groups, groupInfo{GroupID: scope, WriterCount: count})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"groups": groups})
|
||||
}
|
||||
@@ -327,14 +340,36 @@ func (h *ChannelInstancesHandler) handleListWriters(w http.ResponseWriter, r *ht
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "group_id")})
|
||||
return
|
||||
}
|
||||
writers, err := h.agentStore.ListGroupFileWriters(r.Context(), agentID, groupID)
|
||||
perms, err := h.configPermStore.List(r.Context(), agentID, "file_writer", groupID)
|
||||
if err != nil {
|
||||
slog.Error("channel_instances.list_writers", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "writers")})
|
||||
return
|
||||
}
|
||||
if writers == nil {
|
||||
writers = []store.GroupFileWriterData{}
|
||||
type writerData struct {
|
||||
UserID string `json:"user_id"`
|
||||
DisplayName *string `json:"display_name,omitempty"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
}
|
||||
writers := make([]writerData, 0, len(perms))
|
||||
for _, p := range perms {
|
||||
if p.Permission != "allow" {
|
||||
continue
|
||||
}
|
||||
wd := writerData{UserID: p.UserID}
|
||||
var meta struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
if json.Unmarshal(p.Metadata, &meta) == nil {
|
||||
if meta.DisplayName != "" {
|
||||
wd.DisplayName = &meta.DisplayName
|
||||
}
|
||||
if meta.Username != "" {
|
||||
wd.Username = &meta.Username
|
||||
}
|
||||
}
|
||||
writers = append(writers, wd)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"writers": writers})
|
||||
}
|
||||
@@ -359,7 +394,15 @@ func (h *ChannelInstancesHandler) handleAddWriter(w http.ResponseWriter, r *http
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "group_id and user_id")})
|
||||
return
|
||||
}
|
||||
if err := h.agentStore.AddGroupFileWriter(r.Context(), agentID, body.GroupID, body.UserID, body.DisplayName, body.Username); err != nil {
|
||||
meta, _ := json.Marshal(map[string]string{"displayName": body.DisplayName, "username": body.Username})
|
||||
if err := h.configPermStore.Grant(r.Context(), &store.ConfigPermission{
|
||||
AgentID: agentID,
|
||||
Scope: body.GroupID,
|
||||
ConfigType: "file_writer",
|
||||
UserID: body.UserID,
|
||||
Permission: "allow",
|
||||
Metadata: meta,
|
||||
}); err != nil {
|
||||
slog.Error("channel_instances.add_writer", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToCreate, "writer", err.Error())})
|
||||
return
|
||||
@@ -379,7 +422,19 @@ func (h *ChannelInstancesHandler) handleRemoveWriter(w http.ResponseWriter, r *h
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "group_id and userId")})
|
||||
return
|
||||
}
|
||||
if err := h.agentStore.RemoveGroupFileWriter(r.Context(), agentID, groupID, userID); err != nil {
|
||||
// Prevent removing the last writer (same guard as Telegram /removewriter)
|
||||
writers, _ := h.configPermStore.List(r.Context(), agentID, "file_writer", groupID)
|
||||
allowCount := 0
|
||||
for _, p := range writers {
|
||||
if p.Permission == "allow" {
|
||||
allowCount++
|
||||
}
|
||||
}
|
||||
if allowCount <= 1 {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "cannot remove the last file writer"})
|
||||
return
|
||||
}
|
||||
if err := h.configPermStore.Revoke(r.Context(), agentID, groupID, "file_writer", userID); err != nil {
|
||||
slog.Error("channel_instances.remove_writer", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToDelete, "writer", err.Error())})
|
||||
return
|
||||
|
||||
@@ -313,12 +313,6 @@ type AgentStore interface {
|
||||
ListUserInstances(ctx context.Context, agentID uuid.UUID) ([]UserInstanceData, error)
|
||||
UpdateUserProfileMetadata(ctx context.Context, agentID uuid.UUID, userID string, metadata map[string]string) error
|
||||
|
||||
// Group file writers (allowlist for protected file edits in group chats)
|
||||
IsGroupFileWriter(ctx context.Context, agentID uuid.UUID, groupID, userID string) (bool, error)
|
||||
AddGroupFileWriter(ctx context.Context, agentID uuid.UUID, groupID, userID, displayName, username string) error
|
||||
RemoveGroupFileWriter(ctx context.Context, agentID uuid.UUID, groupID, userID string) error
|
||||
ListGroupFileWriters(ctx context.Context, agentID uuid.UUID, groupID string) ([]GroupFileWriterData, error)
|
||||
ListGroupFileWriterGroups(ctx context.Context, agentID uuid.UUID) ([]GroupWriterGroupInfo, error)
|
||||
}
|
||||
|
||||
// UserInstanceData represents a user instance for a predefined agent.
|
||||
@@ -330,15 +324,3 @@ type UserInstanceData struct {
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// GroupFileWriterData represents a group file writer entry.
|
||||
type GroupFileWriterData struct {
|
||||
UserID string `json:"user_id"`
|
||||
DisplayName *string `json:"display_name,omitempty"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
}
|
||||
|
||||
// GroupWriterGroupInfo represents a group that has writers configured.
|
||||
type GroupWriterGroupInfo struct {
|
||||
GroupID string `json:"group_id"`
|
||||
WriterCount int `json:"writer_count"`
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -13,7 +15,7 @@ type ConfigPermission struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
AgentID uuid.UUID `json:"agentId"`
|
||||
Scope string `json:"scope"` // "agent" | "group:telegram:-100456" | "group:*" | "*"
|
||||
ConfigType string `json:"configType"` // "heartbeat" | "cron" | "context_files" | "*"
|
||||
ConfigType string `json:"configType"` // "heartbeat" | "cron" | "context_files" | "file_writer" | "*"
|
||||
UserID string `json:"userId"`
|
||||
Permission string `json:"permission"` // "allow" | "deny"
|
||||
GrantedBy *string `json:"grantedBy,omitempty"`
|
||||
@@ -30,5 +32,39 @@ type ConfigPermissionStore interface {
|
||||
|
||||
Grant(ctx context.Context, perm *ConfigPermission) error
|
||||
Revoke(ctx context.Context, agentID uuid.UUID, scope, configType, userID string) error
|
||||
List(ctx context.Context, agentID uuid.UUID, configType string) ([]ConfigPermission, error)
|
||||
// List returns permissions for agentID+configType. If scope != "" only rows with that scope are returned.
|
||||
List(ctx context.Context, agentID uuid.UUID, configType, scope string) ([]ConfigPermission, error)
|
||||
// ListFileWriters returns cached file_writer allow permissions for a given agentID+scope (hot-path).
|
||||
ListFileWriters(ctx context.Context, agentID uuid.UUID, scope string) ([]ConfigPermission, error)
|
||||
}
|
||||
|
||||
// CheckFileWriterPermission returns an error if the caller is in a group context
|
||||
// and is not a file writer. Returns nil if write is allowed.
|
||||
// Fail-open: returns nil on DB errors or missing context (cron, subagent).
|
||||
// Replaces the deleted CheckGroupWritePermission / GroupWriterCache.
|
||||
func CheckFileWriterPermission(ctx context.Context, permStore ConfigPermissionStore) error {
|
||||
if permStore == nil {
|
||||
return nil
|
||||
}
|
||||
userID := UserIDFromContext(ctx)
|
||||
if !strings.HasPrefix(userID, "group:") && !strings.HasPrefix(userID, "guild:") {
|
||||
return nil // not a group context
|
||||
}
|
||||
agentID := AgentIDFromContext(ctx)
|
||||
if agentID == uuid.Nil {
|
||||
return nil // no agent context
|
||||
}
|
||||
senderID := SenderIDFromContext(ctx)
|
||||
if senderID == "" {
|
||||
return nil // system context (cron, subagent)
|
||||
}
|
||||
numericID := strings.SplitN(senderID, "|", 2)[0]
|
||||
allowed, err := permStore.CheckPermission(ctx, agentID, userID, "file_writer", numericID)
|
||||
if err != nil {
|
||||
return nil // fail-open
|
||||
}
|
||||
if !allowed {
|
||||
return fmt.Errorf("permission denied: only file writers can modify files in this group. Use /addwriter to get write access")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/cache"
|
||||
)
|
||||
|
||||
const groupWriterCacheTTL = 5 * time.Minute
|
||||
|
||||
// GroupWriterCache wraps AgentStore.ListGroupFileWriters with a TTL cache.
|
||||
// Used by tools and agent loop to check group write permissions without repeated DB queries.
|
||||
type GroupWriterCache struct {
|
||||
agentStore AgentStore
|
||||
cache cache.Cache[[]GroupFileWriterData]
|
||||
}
|
||||
|
||||
// NewGroupWriterCache creates a new cache backed by the given agent store.
|
||||
// The cache implementation is injected (in-memory or Redis) so callers control the backend.
|
||||
func NewGroupWriterCache(as AgentStore, c cache.Cache[[]GroupFileWriterData]) *GroupWriterCache {
|
||||
return &GroupWriterCache{
|
||||
agentStore: as,
|
||||
cache: c,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *GroupWriterCache) cacheKey(agentID uuid.UUID, groupID string) string {
|
||||
return agentID.String() + ":" + groupID
|
||||
}
|
||||
|
||||
// ListWriters returns cached writers, falling back to DB on miss/expiry.
|
||||
func (c *GroupWriterCache) ListWriters(ctx context.Context, agentID uuid.UUID, groupID string) ([]GroupFileWriterData, error) {
|
||||
key := c.cacheKey(agentID, groupID)
|
||||
if writers, ok := c.cache.Get(ctx, key); ok {
|
||||
return writers, nil
|
||||
}
|
||||
writers, err := c.agentStore.ListGroupFileWriters(ctx, agentID, groupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.cache.Set(ctx, key, writers, groupWriterCacheTTL)
|
||||
return writers, nil
|
||||
}
|
||||
|
||||
// IsWriter checks if senderNumericID is in the cached writer list.
|
||||
func (c *GroupWriterCache) IsWriter(ctx context.Context, agentID uuid.UUID, groupID, senderNumericID string) (bool, error) {
|
||||
writers, err := c.ListWriters(ctx, agentID, groupID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, w := range writers {
|
||||
if w.UserID == senderNumericID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Invalidate clears cache entries matching the given groupID.
|
||||
func (c *GroupWriterCache) Invalidate(groupID string) {
|
||||
// DeleteByPrefix can't match suffix, so we use a sentinel prefix approach:
|
||||
// keys are "agentUUID:groupID" — walk via Clear is not viable, so we
|
||||
// store a reverse suffix index would add complexity. Instead, clear all
|
||||
// and let the next access re-populate. Invalidate is called rarely (writer list changes).
|
||||
c.cache.Clear(context.Background())
|
||||
}
|
||||
|
||||
// InvalidateAll clears all cached entries.
|
||||
func (c *GroupWriterCache) InvalidateAll() {
|
||||
c.cache.Clear(context.Background())
|
||||
}
|
||||
|
||||
// CheckGroupWritePermission returns an error if the caller is in a group context
|
||||
// and is not a file writer. Returns nil if write is allowed.
|
||||
// Fail-open: returns nil on DB errors or missing context (cron, subagent).
|
||||
func CheckGroupWritePermission(ctx context.Context, cache *GroupWriterCache) error {
|
||||
userID := UserIDFromContext(ctx)
|
||||
if !strings.HasPrefix(userID, "group:") && !strings.HasPrefix(userID, "guild:") {
|
||||
return nil // not a group context
|
||||
}
|
||||
agentID := AgentIDFromContext(ctx)
|
||||
if agentID == uuid.Nil {
|
||||
return nil // no agent context
|
||||
}
|
||||
senderID := SenderIDFromContext(ctx)
|
||||
if senderID == "" {
|
||||
return nil // system context (cron, subagent)
|
||||
}
|
||||
numericID := strings.SplitN(senderID, "|", 2)[0]
|
||||
isWriter, err := cache.IsWriter(ctx, agentID, userID, numericID)
|
||||
if err != nil {
|
||||
return nil // fail-open
|
||||
}
|
||||
if !isWriter {
|
||||
return fmt.Errorf("permission denied: only file writers can modify files in this group. Use /addwriter to get write access")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -183,8 +183,7 @@ func (s *PGAgentStore) Update(ctx context.Context, id uuid.UUID, updates map[str
|
||||
}
|
||||
|
||||
func (s *PGAgentStore) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
// Soft delete
|
||||
_, err := s.db.ExecContext(ctx, "UPDATE agents SET deleted_at = NOW() WHERE id = $1", id)
|
||||
_, err := s.db.ExecContext(ctx, "DELETE FROM agents WHERE id = $1", id)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -27,23 +28,39 @@ type permRow struct {
|
||||
Permission string
|
||||
}
|
||||
|
||||
// fwCacheEntry holds cached file_writer ConfigPermission rows for a scope.
|
||||
type fwCacheEntry struct {
|
||||
rows []store.ConfigPermission
|
||||
fetched time.Time
|
||||
}
|
||||
|
||||
// PGConfigPermissionStore implements store.ConfigPermissionStore backed by Postgres.
|
||||
// Includes a TTL cache for CheckPermission to avoid per-request DB queries.
|
||||
type PGConfigPermissionStore struct {
|
||||
db *sql.DB
|
||||
mu sync.RWMutex
|
||||
cache map[string]permCacheEntry // key: "agentID:userID"
|
||||
db *sql.DB
|
||||
mu sync.RWMutex
|
||||
cache map[string]permCacheEntry // key: "agentID:userID"
|
||||
fwMu sync.RWMutex
|
||||
fwCache map[string]fwCacheEntry // key: "agentID:scope"
|
||||
}
|
||||
|
||||
func NewPGConfigPermissionStore(db *sql.DB) *PGConfigPermissionStore {
|
||||
return &PGConfigPermissionStore{db: db, cache: make(map[string]permCacheEntry)}
|
||||
return &PGConfigPermissionStore{
|
||||
db: db,
|
||||
cache: make(map[string]permCacheEntry),
|
||||
fwCache: make(map[string]fwCacheEntry),
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidateCache clears all cached permission entries.
|
||||
func (s *PGConfigPermissionStore) InvalidateCache() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.cache = make(map[string]permCacheEntry)
|
||||
s.mu.Unlock()
|
||||
|
||||
s.fwMu.Lock()
|
||||
s.fwCache = make(map[string]fwCacheEntry)
|
||||
s.fwMu.Unlock()
|
||||
}
|
||||
|
||||
// CheckPermission evaluates deny-first, allow-second permission with Go-level wildcard matching.
|
||||
@@ -155,14 +172,18 @@ func (s *PGConfigPermissionStore) Revoke(ctx context.Context, agentID uuid.UUID,
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PGConfigPermissionStore) List(ctx context.Context, agentID uuid.UUID, configType string) ([]store.ConfigPermission, error) {
|
||||
func (s *PGConfigPermissionStore) List(ctx context.Context, agentID uuid.UUID, configType, scope string) ([]store.ConfigPermission, error) {
|
||||
query := `SELECT id, agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at
|
||||
FROM agent_config_permissions WHERE agent_id = $1`
|
||||
args := []any{agentID}
|
||||
|
||||
if configType != "" {
|
||||
query += ` AND config_type = $2`
|
||||
args = append(args, configType)
|
||||
query += ` AND config_type = $` + itoa(len(args))
|
||||
}
|
||||
if scope != "" {
|
||||
args = append(args, scope)
|
||||
query += ` AND scope = $` + itoa(len(args))
|
||||
}
|
||||
query += ` ORDER BY created_at`
|
||||
|
||||
@@ -172,6 +193,46 @@ func (s *PGConfigPermissionStore) List(ctx context.Context, agentID uuid.UUID, c
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanConfigPermissions(rows)
|
||||
}
|
||||
|
||||
// ListFileWriters returns cached file_writer allow permissions for a given agentID+scope.
|
||||
// Hot-path: called during system prompt injection for every group message.
|
||||
func (s *PGConfigPermissionStore) ListFileWriters(ctx context.Context, agentID uuid.UUID, scope string) ([]store.ConfigPermission, error) {
|
||||
cacheKey := agentID.String() + ":" + scope
|
||||
|
||||
s.fwMu.RLock()
|
||||
if entry, ok := s.fwCache[cacheKey]; ok && time.Since(entry.fetched) < permCacheTTL {
|
||||
s.fwMu.RUnlock()
|
||||
return entry.rows, nil
|
||||
}
|
||||
s.fwMu.RUnlock()
|
||||
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, agent_id, scope, config_type, user_id, permission, granted_by, metadata, created_at, updated_at
|
||||
FROM agent_config_permissions
|
||||
WHERE agent_id = $1 AND config_type = 'file_writer' AND scope = $2 AND permission = 'allow'
|
||||
ORDER BY created_at`,
|
||||
agentID, scope,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
perms, err := scanConfigPermissions(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.fwMu.Lock()
|
||||
s.fwCache[cacheKey] = fwCacheEntry{rows: perms, fetched: time.Now()}
|
||||
s.fwMu.Unlock()
|
||||
|
||||
return perms, nil
|
||||
}
|
||||
|
||||
func scanConfigPermissions(rows *sql.Rows) ([]store.ConfigPermission, error) {
|
||||
var perms []store.ConfigPermission
|
||||
for rows.Next() {
|
||||
var p store.ConfigPermission
|
||||
@@ -189,3 +250,8 @@ func (s *PGConfigPermissionStore) List(ctx context.Context, agentID uuid.UUID, c
|
||||
}
|
||||
return perms, nil
|
||||
}
|
||||
|
||||
// itoa converts an int to its decimal string representation.
|
||||
func itoa(n int) string {
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
package pg
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
func (s *PGAgentStore) IsGroupFileWriter(ctx context.Context, agentID uuid.UUID, groupID, userID string) (bool, error) {
|
||||
var exists bool
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM group_file_writers WHERE agent_id=$1 AND group_id=$2 AND user_id=$3)`,
|
||||
agentID, groupID, userID,
|
||||
).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func (s *PGAgentStore) AddGroupFileWriter(ctx context.Context, agentID uuid.UUID, groupID, userID, displayName, username string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO group_file_writers (agent_id, group_id, user_id, display_name, username)
|
||||
VALUES ($1, $2, $3, NULLIF($4,''), NULLIF($5,''))
|
||||
ON CONFLICT (agent_id, group_id, user_id) DO UPDATE SET display_name=NULLIF($4,''), username=NULLIF($5,'')`,
|
||||
agentID, groupID, userID, displayName, username,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PGAgentStore) RemoveGroupFileWriter(ctx context.Context, agentID uuid.UUID, groupID, userID string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM group_file_writers WHERE agent_id=$1 AND group_id=$2 AND user_id=$3`,
|
||||
agentID, groupID, userID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PGAgentStore) ListGroupFileWriterGroups(ctx context.Context, agentID uuid.UUID) ([]store.GroupWriterGroupInfo, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT group_id, COUNT(*) as writer_count FROM group_file_writers WHERE agent_id=$1 GROUP BY group_id ORDER BY group_id`,
|
||||
agentID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var groups []store.GroupWriterGroupInfo
|
||||
for rows.Next() {
|
||||
var g store.GroupWriterGroupInfo
|
||||
if err := rows.Scan(&g.GroupID, &g.WriterCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
return groups, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PGAgentStore) ListGroupFileWriters(ctx context.Context, agentID uuid.UUID, groupID string) ([]store.GroupFileWriterData, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT user_id, display_name, username FROM group_file_writers WHERE agent_id=$1 AND group_id=$2 ORDER BY created_at`,
|
||||
agentID, groupID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var writers []store.GroupFileWriterData
|
||||
for rows.Next() {
|
||||
var w store.GroupFileWriterData
|
||||
if err := rows.Scan(&w.UserID, &w.DisplayName, &w.Username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
writers = append(writers, w)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return writers, nil
|
||||
}
|
||||
@@ -78,7 +78,7 @@ type ContextFileInterceptor struct {
|
||||
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)
|
||||
permStore store.ConfigPermissionStore // nil = no group write restriction
|
||||
}
|
||||
|
||||
// NewContextFileInterceptor creates an interceptor backed by the given agent store.
|
||||
@@ -97,9 +97,9 @@ func NewContextFileInterceptor(
|
||||
}
|
||||
}
|
||||
|
||||
// SetGroupWriterCache sets the shared cache for group writer permission checks (defense-in-depth).
|
||||
func (b *ContextFileInterceptor) SetGroupWriterCache(c *store.GroupWriterCache) {
|
||||
b.groupWriterCache = c
|
||||
// SetConfigPermStore sets the config permission store for group writer permission checks.
|
||||
func (b *ContextFileInterceptor) SetConfigPermStore(s store.ConfigPermissionStore) {
|
||||
b.permStore = s
|
||||
}
|
||||
|
||||
// ReadFile attempts to read a context file from the DB (with cache).
|
||||
@@ -210,24 +210,18 @@ func (b *ContextFileInterceptor) WriteFile(ctx context.Context, path, content st
|
||||
}
|
||||
if !skipCheck {
|
||||
senderID := store.SenderIDFromContext(ctx)
|
||||
if senderID != "" {
|
||||
if senderID != "" && b.permStore != nil {
|
||||
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)
|
||||
}
|
||||
allowed, err := b.permStore.CheckPermission(ctx, agentID, userID, "file_writer", 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 {
|
||||
// fail open: allow write if check fails
|
||||
} else if !allowed {
|
||||
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
|
||||
// senderID empty or no permStore = system context (cron, subagent) → fail open
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,21 +69,6 @@ func (s *stubAgentStore) SetUserOverride(_ context.Context, _ *store.UserAgentOv
|
||||
func (s *stubAgentStore) GetOrCreateUserProfile(_ context.Context, _ uuid.UUID, _, _, _ string) (bool, string, error) {
|
||||
return false, "", nil
|
||||
}
|
||||
func (s *stubAgentStore) IsGroupFileWriter(_ context.Context, _ uuid.UUID, _, _ string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (s *stubAgentStore) AddGroupFileWriter(_ context.Context, _ uuid.UUID, _, _, _, _ string) error {
|
||||
return nil
|
||||
}
|
||||
func (s *stubAgentStore) RemoveGroupFileWriter(_ context.Context, _ uuid.UUID, _, _ string) error {
|
||||
return nil
|
||||
}
|
||||
func (s *stubAgentStore) ListGroupFileWriters(_ context.Context, _ uuid.UUID, _ string) ([]store.GroupFileWriterData, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubAgentStore) ListGroupFileWriterGroups(_ context.Context, _ uuid.UUID) ([]store.GroupWriterGroupInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubAgentStore) ListUserInstances(_ context.Context, _ uuid.UUID) ([]store.UserInstanceData, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -12,17 +12,17 @@ import (
|
||||
// CronTool lets agents manage Gateway cron jobs.
|
||||
// Matching OpenClaw src/agents/tools/cron-tool.ts.
|
||||
type CronTool struct {
|
||||
cronStore store.CronStore
|
||||
groupWriterCache *store.GroupWriterCache // nil = no group restriction
|
||||
cronStore store.CronStore
|
||||
permStore store.ConfigPermissionStore // nil = no group restriction
|
||||
}
|
||||
|
||||
func NewCronTool(cronStore store.CronStore) *CronTool {
|
||||
return &CronTool{cronStore: cronStore}
|
||||
}
|
||||
|
||||
// SetGroupWriterCache enables group cron mutation restriction.
|
||||
func (t *CronTool) SetGroupWriterCache(c *store.GroupWriterCache) {
|
||||
t.groupWriterCache = c
|
||||
// SetConfigPermStore enables group cron mutation restriction.
|
||||
func (t *CronTool) SetConfigPermStore(s store.ConfigPermissionStore) {
|
||||
t.permStore = s
|
||||
}
|
||||
|
||||
func (t *CronTool) Name() string { return "cron" }
|
||||
@@ -117,8 +117,8 @@ func (t *CronTool) Execute(ctx context.Context, args map[string]any) *Result {
|
||||
}
|
||||
|
||||
// Group write permission check for mutation actions
|
||||
if t.groupWriterCache != nil && (action == "add" || action == "update" || action == "remove") {
|
||||
if err := store.CheckGroupWritePermission(ctx, t.groupWriterCache); err != nil {
|
||||
if t.permStore != nil && (action == "add" || action == "update" || action == "remove") {
|
||||
if err := store.CheckFileWriterPermission(ctx, t.permStore); err != nil {
|
||||
return ErrorResult("permission denied: only file writers can manage cron jobs in group chats")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ type EditTool struct {
|
||||
sandboxMgr sandbox.Manager
|
||||
contextFileIntc *ContextFileInterceptor
|
||||
memIntc *MemoryInterceptor
|
||||
groupWriterCache *store.GroupWriterCache // nil = no group write restriction
|
||||
permStore store.ConfigPermissionStore // nil = no group write restriction
|
||||
}
|
||||
|
||||
// DenyPaths adds path prefixes that edit must reject.
|
||||
@@ -36,9 +36,9 @@ func (t *EditTool) SetMemoryInterceptor(intc *MemoryInterceptor) {
|
||||
t.memIntc = intc
|
||||
}
|
||||
|
||||
// SetGroupWriterCache enables group write permission checks.
|
||||
func (t *EditTool) SetGroupWriterCache(c *store.GroupWriterCache) {
|
||||
t.groupWriterCache = c
|
||||
// SetConfigPermStore enables group write permission checks.
|
||||
func (t *EditTool) SetConfigPermStore(s store.ConfigPermissionStore) {
|
||||
t.permStore = s
|
||||
}
|
||||
|
||||
func NewEditTool(workspace string, restrict bool) *EditTool {
|
||||
@@ -98,8 +98,8 @@ func (t *EditTool) Execute(ctx context.Context, args map[string]any) *Result {
|
||||
}
|
||||
|
||||
// Group write permission check
|
||||
if t.groupWriterCache != nil {
|
||||
if err := store.CheckGroupWritePermission(ctx, t.groupWriterCache); err != nil {
|
||||
if t.permStore != nil {
|
||||
if err := store.CheckFileWriterPermission(ctx, t.permStore); err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ type ReadFileTool struct {
|
||||
sandboxMgr sandbox.Manager // nil = direct host access
|
||||
contextFileIntc *ContextFileInterceptor // nil = no virtual FS routing
|
||||
memIntc *MemoryInterceptor // nil = no memory routing
|
||||
groupWriterCache *store.GroupWriterCache // nil = no group read restriction
|
||||
permStore store.ConfigPermissionStore // nil = no group read restriction
|
||||
}
|
||||
|
||||
// SetContextFileInterceptor enables virtual FS routing for context files.
|
||||
@@ -43,9 +43,9 @@ func (t *ReadFileTool) SetMemoryInterceptor(intc *MemoryInterceptor) {
|
||||
t.memIntc = intc
|
||||
}
|
||||
|
||||
// SetGroupWriterCache enables group read restriction for SOUL.md/AGENTS.md.
|
||||
func (t *ReadFileTool) SetGroupWriterCache(c *store.GroupWriterCache) {
|
||||
t.groupWriterCache = c
|
||||
// SetConfigPermStore enables group read restriction for SOUL.md/AGENTS.md.
|
||||
func (t *ReadFileTool) SetConfigPermStore(s store.ConfigPermissionStore) {
|
||||
t.permStore = s
|
||||
}
|
||||
|
||||
func NewReadFileTool(workspace string, restrict bool) *ReadFileTool {
|
||||
@@ -92,10 +92,10 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *Result
|
||||
}
|
||||
|
||||
// Group read restriction: block non-writers from reading SOUL.md/AGENTS.md
|
||||
if t.groupWriterCache != nil {
|
||||
if t.permStore != nil {
|
||||
base := filepath.Base(path)
|
||||
if base == bootstrap.SoulFile || base == bootstrap.AgentsFile {
|
||||
if err := store.CheckGroupWritePermission(ctx, t.groupWriterCache); err != nil {
|
||||
if err := store.CheckFileWriterPermission(ctx, t.permStore); err != nil {
|
||||
return ErrorResult(fmt.Sprintf("permission denied: %s is restricted in this group", base))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ type WriteFileTool struct {
|
||||
sandboxMgr sandbox.Manager
|
||||
contextFileIntc *ContextFileInterceptor // nil = no virtual FS routing
|
||||
memIntc *MemoryInterceptor // nil = no memory routing
|
||||
groupWriterCache *store.GroupWriterCache // nil = no group write restriction
|
||||
workspaceIntc *WorkspaceInterceptor // nil = no team workspace validation
|
||||
permStore store.ConfigPermissionStore // nil = no group write restriction
|
||||
workspaceIntc *WorkspaceInterceptor // nil = no team workspace validation
|
||||
}
|
||||
|
||||
// DenyPaths adds path prefixes that write_file must reject.
|
||||
@@ -38,9 +38,9 @@ func (t *WriteFileTool) SetMemoryInterceptor(intc *MemoryInterceptor) {
|
||||
t.memIntc = intc
|
||||
}
|
||||
|
||||
// SetGroupWriterCache enables group write permission checks.
|
||||
func (t *WriteFileTool) SetGroupWriterCache(c *store.GroupWriterCache) {
|
||||
t.groupWriterCache = c
|
||||
// SetConfigPermStore enables group write permission checks.
|
||||
func (t *WriteFileTool) SetConfigPermStore(s store.ConfigPermissionStore) {
|
||||
t.permStore = s
|
||||
}
|
||||
|
||||
// SetWorkspaceInterceptor enables team workspace validation and event broadcasting.
|
||||
@@ -103,8 +103,8 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *Resul
|
||||
}
|
||||
|
||||
// Group write permission check
|
||||
if t.groupWriterCache != nil {
|
||||
if err := store.CheckGroupWritePermission(ctx, t.groupWriterCache); err != nil {
|
||||
if t.permStore != nil {
|
||||
if err := store.CheckFileWriterPermission(ctx, t.permStore); err != nil {
|
||||
return ErrorResult(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,9 +50,9 @@ type InterceptorAware interface {
|
||||
SetMemoryInterceptor(*MemoryInterceptor)
|
||||
}
|
||||
|
||||
// GroupWriterAware tools receive a GroupWriterCache for group permission checks.
|
||||
type GroupWriterAware interface {
|
||||
SetGroupWriterCache(*store.GroupWriterCache)
|
||||
// ConfigPermAware tools receive a ConfigPermissionStore for group permission checks.
|
||||
type ConfigPermAware interface {
|
||||
SetConfigPermStore(store.ConfigPermissionStore)
|
||||
}
|
||||
|
||||
// WorkspaceInterceptorAware tools can receive a WorkspaceInterceptor for team workspace validation.
|
||||
|
||||
@@ -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 = 22
|
||||
const RequiredSchemaVersion uint = 23
|
||||
@@ -0,0 +1,98 @@
|
||||
-- ============================================================
|
||||
-- Reverse Part B: restore all FK constraints to NO ACTION
|
||||
-- ============================================================
|
||||
|
||||
ALTER TABLE sessions DROP CONSTRAINT sessions_agent_id_fkey;
|
||||
ALTER TABLE sessions ADD CONSTRAINT sessions_agent_id_fkey
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id);
|
||||
|
||||
ALTER TABLE cron_jobs DROP CONSTRAINT cron_jobs_agent_id_fkey;
|
||||
ALTER TABLE cron_jobs ADD CONSTRAINT cron_jobs_agent_id_fkey
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id);
|
||||
|
||||
ALTER TABLE heartbeat_run_logs DROP CONSTRAINT heartbeat_run_logs_agent_id_fkey;
|
||||
ALTER TABLE heartbeat_run_logs ADD CONSTRAINT heartbeat_run_logs_agent_id_fkey
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id);
|
||||
|
||||
ALTER TABLE agent_teams DROP CONSTRAINT agent_teams_lead_agent_id_fkey;
|
||||
ALTER TABLE agent_teams ADD CONSTRAINT agent_teams_lead_agent_id_fkey
|
||||
FOREIGN KEY (lead_agent_id) REFERENCES agents(id);
|
||||
|
||||
ALTER TABLE team_messages DROP CONSTRAINT team_messages_from_agent_id_fkey;
|
||||
ALTER TABLE team_messages ADD CONSTRAINT team_messages_from_agent_id_fkey
|
||||
FOREIGN KEY (from_agent_id) REFERENCES agents(id);
|
||||
|
||||
ALTER TABLE delegation_history DROP CONSTRAINT delegation_history_source_agent_id_fkey;
|
||||
ALTER TABLE delegation_history ADD CONSTRAINT delegation_history_source_agent_id_fkey
|
||||
FOREIGN KEY (source_agent_id) REFERENCES agents(id);
|
||||
|
||||
ALTER TABLE delegation_history DROP CONSTRAINT delegation_history_target_agent_id_fkey;
|
||||
ALTER TABLE delegation_history ADD CONSTRAINT delegation_history_target_agent_id_fkey
|
||||
FOREIGN KEY (target_agent_id) REFERENCES agents(id);
|
||||
|
||||
ALTER TABLE team_workspace_files DROP CONSTRAINT team_workspace_files_uploaded_by_fkey;
|
||||
ALTER TABLE team_workspace_files ADD CONSTRAINT team_workspace_files_uploaded_by_fkey
|
||||
FOREIGN KEY (uploaded_by) REFERENCES agents(id);
|
||||
|
||||
ALTER TABLE team_workspace_file_versions DROP CONSTRAINT team_workspace_file_versions_uploaded_by_fkey;
|
||||
ALTER TABLE team_workspace_file_versions ADD CONSTRAINT team_workspace_file_versions_uploaded_by_fkey
|
||||
FOREIGN KEY (uploaded_by) REFERENCES agents(id);
|
||||
|
||||
ALTER TABLE team_workspace_comments DROP CONSTRAINT team_workspace_comments_agent_id_fkey;
|
||||
ALTER TABLE team_workspace_comments ADD CONSTRAINT team_workspace_comments_agent_id_fkey
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id);
|
||||
|
||||
ALTER TABLE cron_run_logs DROP CONSTRAINT cron_run_logs_agent_id_fkey;
|
||||
ALTER TABLE cron_run_logs ADD CONSTRAINT cron_run_logs_agent_id_fkey
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id);
|
||||
|
||||
ALTER TABLE team_tasks DROP CONSTRAINT team_tasks_owner_agent_id_fkey;
|
||||
ALTER TABLE team_tasks ADD CONSTRAINT team_tasks_owner_agent_id_fkey
|
||||
FOREIGN KEY (owner_agent_id) REFERENCES agents(id);
|
||||
|
||||
ALTER TABLE team_tasks DROP CONSTRAINT team_tasks_created_by_agent_id_fkey;
|
||||
ALTER TABLE team_tasks ADD CONSTRAINT team_tasks_created_by_agent_id_fkey
|
||||
FOREIGN KEY (created_by_agent_id) REFERENCES agents(id);
|
||||
|
||||
ALTER TABLE team_messages DROP CONSTRAINT team_messages_to_agent_id_fkey;
|
||||
ALTER TABLE team_messages ADD CONSTRAINT team_messages_to_agent_id_fkey
|
||||
FOREIGN KEY (to_agent_id) REFERENCES agents(id);
|
||||
|
||||
ALTER TABLE team_task_comments DROP CONSTRAINT team_task_comments_agent_id_fkey;
|
||||
ALTER TABLE team_task_comments ADD CONSTRAINT team_task_comments_agent_id_fkey
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id);
|
||||
|
||||
ALTER TABLE team_task_attachments DROP CONSTRAINT team_task_attachments_added_by_fkey;
|
||||
ALTER TABLE team_task_attachments ADD CONSTRAINT team_task_attachments_added_by_fkey
|
||||
FOREIGN KEY (added_by) REFERENCES agents(id);
|
||||
|
||||
-- Restore agent_key unique constraint
|
||||
DROP INDEX IF EXISTS idx_agents_agent_key_active;
|
||||
ALTER TABLE agents ADD CONSTRAINT agents_agent_key_key UNIQUE (agent_key);
|
||||
|
||||
-- ============================================================
|
||||
-- Reverse Part A: restore group_file_writers
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE group_file_writers (
|
||||
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
group_id VARCHAR(255) NOT NULL,
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
display_name VARCHAR(255),
|
||||
username VARCHAR(255),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (agent_id, group_id, user_id)
|
||||
);
|
||||
|
||||
-- Migrate data back from config permissions
|
||||
INSERT INTO group_file_writers (agent_id, group_id, user_id, display_name, username, created_at)
|
||||
SELECT agent_id, scope, user_id,
|
||||
metadata->>'displayName', metadata->>'username',
|
||||
created_at
|
||||
FROM agent_config_permissions
|
||||
WHERE config_type = 'file_writer' AND permission = 'allow';
|
||||
|
||||
DELETE FROM agent_config_permissions WHERE config_type = 'file_writer';
|
||||
|
||||
-- Restore scope column size
|
||||
ALTER TABLE agent_config_permissions ALTER COLUMN scope TYPE VARCHAR(100);
|
||||
@@ -0,0 +1,97 @@
|
||||
-- ============================================================
|
||||
-- Part A: File Writer → Config Permissions merge
|
||||
-- ============================================================
|
||||
|
||||
-- Widen scope column to match group_file_writers.group_id size
|
||||
ALTER TABLE agent_config_permissions ALTER COLUMN scope TYPE VARCHAR(255);
|
||||
|
||||
-- Migrate group_file_writers → agent_config_permissions
|
||||
INSERT INTO agent_config_permissions (agent_id, scope, config_type, user_id, permission, metadata, created_at)
|
||||
SELECT agent_id, group_id, 'file_writer', user_id, 'allow',
|
||||
jsonb_build_object(
|
||||
'displayName', COALESCE(display_name, ''),
|
||||
'username', COALESCE(username, '')
|
||||
),
|
||||
created_at
|
||||
FROM group_file_writers
|
||||
ON CONFLICT (agent_id, scope, config_type, user_id) DO NOTHING;
|
||||
|
||||
DROP TABLE group_file_writers;
|
||||
|
||||
-- ============================================================
|
||||
-- Part B: Agent hard delete — FK cascade constraints
|
||||
-- ============================================================
|
||||
|
||||
-- Allow soft-deleted agents to reuse agent_key
|
||||
ALTER TABLE agents DROP CONSTRAINT agents_agent_key_key;
|
||||
CREATE UNIQUE INDEX idx_agents_agent_key_active ON agents(agent_key) WHERE deleted_at IS NULL;
|
||||
|
||||
-- CASCADE: data belongs to agent, delete with it
|
||||
ALTER TABLE sessions DROP CONSTRAINT sessions_agent_id_fkey;
|
||||
ALTER TABLE sessions ADD CONSTRAINT sessions_agent_id_fkey
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE cron_jobs DROP CONSTRAINT cron_jobs_agent_id_fkey;
|
||||
ALTER TABLE cron_jobs ADD CONSTRAINT cron_jobs_agent_id_fkey
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE heartbeat_run_logs DROP CONSTRAINT heartbeat_run_logs_agent_id_fkey;
|
||||
ALTER TABLE heartbeat_run_logs ADD CONSTRAINT heartbeat_run_logs_agent_id_fkey
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE agent_teams DROP CONSTRAINT agent_teams_lead_agent_id_fkey;
|
||||
ALTER TABLE agent_teams ADD CONSTRAINT agent_teams_lead_agent_id_fkey
|
||||
FOREIGN KEY (lead_agent_id) REFERENCES agents(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE team_messages DROP CONSTRAINT team_messages_from_agent_id_fkey;
|
||||
ALTER TABLE team_messages ADD CONSTRAINT team_messages_from_agent_id_fkey
|
||||
FOREIGN KEY (from_agent_id) REFERENCES agents(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE delegation_history DROP CONSTRAINT delegation_history_source_agent_id_fkey;
|
||||
ALTER TABLE delegation_history ADD CONSTRAINT delegation_history_source_agent_id_fkey
|
||||
FOREIGN KEY (source_agent_id) REFERENCES agents(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE delegation_history DROP CONSTRAINT delegation_history_target_agent_id_fkey;
|
||||
ALTER TABLE delegation_history ADD CONSTRAINT delegation_history_target_agent_id_fkey
|
||||
FOREIGN KEY (target_agent_id) REFERENCES agents(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE team_workspace_files DROP CONSTRAINT team_workspace_files_uploaded_by_fkey;
|
||||
ALTER TABLE team_workspace_files ADD CONSTRAINT team_workspace_files_uploaded_by_fkey
|
||||
FOREIGN KEY (uploaded_by) REFERENCES agents(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE team_workspace_file_versions DROP CONSTRAINT team_workspace_file_versions_uploaded_by_fkey;
|
||||
ALTER TABLE team_workspace_file_versions ADD CONSTRAINT team_workspace_file_versions_uploaded_by_fkey
|
||||
FOREIGN KEY (uploaded_by) REFERENCES agents(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE team_workspace_comments DROP CONSTRAINT team_workspace_comments_agent_id_fkey;
|
||||
ALTER TABLE team_workspace_comments ADD CONSTRAINT team_workspace_comments_agent_id_fkey
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE;
|
||||
|
||||
-- SET NULL: keep row, clear reference
|
||||
ALTER TABLE cron_run_logs DROP CONSTRAINT cron_run_logs_agent_id_fkey;
|
||||
ALTER TABLE cron_run_logs ADD CONSTRAINT cron_run_logs_agent_id_fkey
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE team_tasks DROP CONSTRAINT team_tasks_owner_agent_id_fkey;
|
||||
ALTER TABLE team_tasks ADD CONSTRAINT team_tasks_owner_agent_id_fkey
|
||||
FOREIGN KEY (owner_agent_id) REFERENCES agents(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE team_tasks DROP CONSTRAINT team_tasks_created_by_agent_id_fkey;
|
||||
ALTER TABLE team_tasks ADD CONSTRAINT team_tasks_created_by_agent_id_fkey
|
||||
FOREIGN KEY (created_by_agent_id) REFERENCES agents(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE team_messages DROP CONSTRAINT team_messages_to_agent_id_fkey;
|
||||
ALTER TABLE team_messages ADD CONSTRAINT team_messages_to_agent_id_fkey
|
||||
FOREIGN KEY (to_agent_id) REFERENCES agents(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE team_task_comments DROP CONSTRAINT team_task_comments_agent_id_fkey;
|
||||
ALTER TABLE team_task_comments ADD CONSTRAINT team_task_comments_agent_id_fkey
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE team_task_attachments DROP CONSTRAINT team_task_attachments_added_by_fkey;
|
||||
ALTER TABLE team_task_attachments ADD CONSTRAINT team_task_attachments_added_by_fkey
|
||||
FOREIGN KEY (added_by) REFERENCES agents(id) ON DELETE SET NULL;
|
||||
|
||||
-- Clean up previously soft-deleted agents (zombie rows)
|
||||
-- Placed after CASCADE constraints so related data is auto-cleaned
|
||||
DELETE FROM agents WHERE deleted_at IS NOT NULL;
|
||||
Reference in new issue
Block a user