mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-16 08:15:05 +00:00
Add group file writer management for Telegram with permission-based file editing
Implement group file writer allowlist system with Telegram commands (/addwriter, /removewriter, /writers) for managing who can edit protected files in group chats. Wire AgentStore through Telegram factory, inject SenderID context for permission checks, and auto-bootstrap first group member as writer. Only existing writers can manage the list, preventing removal of the last writer.
This commit is contained in:
1 parent
8221cc2d37
commit
86b1724050
14 files changed
+342
-15
No files matched your search
+2
-2
@@ -610,7 +610,7 @@ func runGateway() {
|
||||
var loadedNames map[string]struct{}
|
||||
if managedStores != nil && managedStores.ChannelInstances != nil {
|
||||
instanceLoader = channels.NewInstanceLoader(managedStores.ChannelInstances, managedStores.Agents, channelMgr, msgBus, pairingStore)
|
||||
instanceLoader.RegisterFactory("telegram", telegram.Factory)
|
||||
instanceLoader.RegisterFactory("telegram", telegram.FactoryWithAgentStore(managedStores.Agents))
|
||||
instanceLoader.RegisterFactory("discord", discord.Factory)
|
||||
instanceLoader.RegisterFactory("feishu", feishu.Factory)
|
||||
instanceLoader.RegisterFactory("zalo_oa", zalo.Factory)
|
||||
@@ -626,7 +626,7 @@ func runGateway() {
|
||||
// Other channels: skip config registration in managed mode if any DB instances loaded.
|
||||
if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token != "" {
|
||||
if _, loaded := loadedNames["telegram"]; !loaded {
|
||||
tg, err := telegram.New(cfg.Channels.Telegram, msgBus, pairingStore)
|
||||
tg, err := telegram.New(cfg.Channels.Telegram, msgBus, pairingStore, nil)
|
||||
if err != nil {
|
||||
slog.Error("failed to initialize telegram channel", "error", err)
|
||||
} else {
|
||||
|
||||
@@ -134,6 +134,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
|
||||
ChatID: msg.ChatID,
|
||||
PeerKind: peerKind,
|
||||
UserID: userID,
|
||||
SenderID: msg.SenderID,
|
||||
RunID: runID,
|
||||
Stream: enableStream,
|
||||
HistoryLimit: msg.HistoryLimit,
|
||||
|
||||
@@ -3,6 +3,7 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
@@ -60,6 +61,23 @@ func wireManagedExtras(
|
||||
if !isNew {
|
||||
return nil // already profiled = already seeded
|
||||
}
|
||||
|
||||
// Auto-add first group member as a file writer (bootstrap the allowlist).
|
||||
if strings.HasPrefix(userID, "group:") {
|
||||
senderID := store.SenderIDFromContext(ctx)
|
||||
if senderID != "" {
|
||||
parts := strings.SplitN(senderID, "|", 2)
|
||||
numericID := parts[0]
|
||||
senderUsername := ""
|
||||
if len(parts) > 1 {
|
||||
senderUsername = parts[1]
|
||||
}
|
||||
if addErr := as.AddGroupFileWriter(ctx, agentID, userID, numericID, "", senderUsername); addErr != nil {
|
||||
slog.Warn("failed to auto-add group file writer", "error", addErr, "sender", numericID, "group", userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err = bootstrap.SeedUserFiles(ctx, as, agentID, userID, agentType)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -205,6 +205,7 @@ type RunRequest struct {
|
||||
PeerKind string // "direct" or "group" (for session key building and tool context)
|
||||
RunID string // unique run identifier
|
||||
UserID string // external user ID (TEXT, free-form) for multi-tenant scoping
|
||||
SenderID string // original individual sender ID (preserved in group chats for permission checks)
|
||||
Stream bool // whether to stream response chunks
|
||||
ExtraSystemPrompt string // optional: injected into system prompt (skills, subagent context, etc.)
|
||||
HistoryLimit int // max user turns to keep in context (0=unlimited, from channel config)
|
||||
@@ -314,6 +315,10 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
if l.agentType != "" {
|
||||
ctx = store.WithAgentType(ctx, l.agentType)
|
||||
}
|
||||
// Inject original sender ID for group file writer permission checks
|
||||
if req.SenderID != "" {
|
||||
ctx = store.WithSenderID(ctx, req.SenderID)
|
||||
}
|
||||
|
||||
// Ensure per-user context files exist (first-chat seeding, managed mode)
|
||||
if l.ensureUserFiles != nil && req.UserID != "" {
|
||||
|
||||
@@ -24,13 +24,14 @@ type Channel struct {
|
||||
bot *telego.Bot
|
||||
config config.TelegramConfig
|
||||
pairingService store.PairingStore
|
||||
placeholders sync.Map // localKey string → messageID int
|
||||
stopThinking sync.Map // localKey string → *thinkingCancel
|
||||
streams sync.Map // localKey string → *DraftStream (streaming preview)
|
||||
reactions sync.Map // localKey string → *StatusReactionController
|
||||
pairingReplySent sync.Map // userID string → time.Time (debounce pairing replies)
|
||||
threadIDs sync.Map // localKey string → messageThreadID int (for forum topic routing)
|
||||
approvedGroups sync.Map // chatIDStr string → true (cached group pairing approval)
|
||||
agentStore store.AgentStore // for group file writer management (nil in standalone)
|
||||
placeholders sync.Map // localKey string → messageID int
|
||||
stopThinking sync.Map // localKey string → *thinkingCancel
|
||||
streams sync.Map // localKey string → *DraftStream (streaming preview)
|
||||
reactions sync.Map // localKey string → *StatusReactionController
|
||||
pairingReplySent sync.Map // userID string → time.Time (debounce pairing replies)
|
||||
threadIDs sync.Map // localKey string → messageThreadID int (for forum topic routing)
|
||||
approvedGroups sync.Map // chatIDStr string → true (cached group pairing approval)
|
||||
groupHistory *channels.PendingHistory
|
||||
historyLimit int
|
||||
requireMention bool
|
||||
@@ -50,7 +51,8 @@ func (c *thinkingCancel) Cancel() {
|
||||
|
||||
// New creates a new Telegram channel from config.
|
||||
// pairingSvc is optional (nil = fall back to allowlist only).
|
||||
func New(cfg config.TelegramConfig, msgBus *bus.MessageBus, pairingSvc store.PairingStore) (*Channel, error) {
|
||||
// agentStore is optional (nil = group file writer commands disabled).
|
||||
func New(cfg config.TelegramConfig, msgBus *bus.MessageBus, pairingSvc store.PairingStore, agentStore store.AgentStore) (*Channel, error) {
|
||||
var opts []telego.BotOption
|
||||
|
||||
if cfg.Proxy != "" {
|
||||
@@ -87,6 +89,7 @@ func New(cfg config.TelegramConfig, msgBus *bus.MessageBus, pairingSvc store.Pai
|
||||
bot: bot,
|
||||
config: cfg,
|
||||
pairingService: pairingSvc,
|
||||
agentStore: agentStore,
|
||||
groupHistory: channels.NewPendingHistory(),
|
||||
historyLimit: historyLimit,
|
||||
requireMention: requireMention,
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
|
||||
@@ -15,7 +16,7 @@ import (
|
||||
|
||||
// handleBotCommand checks if the message is a known bot command and handles it.
|
||||
// Returns true if the message was handled as a command.
|
||||
func (c *Channel) handleBotCommand(ctx context.Context, chatID int64, chatIDStr, localKey, text, senderID string, isGroup, isForum bool, messageThreadID int) bool {
|
||||
func (c *Channel) handleBotCommand(ctx context.Context, message *telego.Message, chatID int64, chatIDStr, localKey, text, senderID string, isGroup, isForum bool, messageThreadID int) bool {
|
||||
if len(text) == 0 || text[0] != '/' {
|
||||
return false
|
||||
}
|
||||
@@ -47,6 +48,9 @@ func (c *Channel) handleBotCommand(ctx context.Context, chatID int64, chatIDStr,
|
||||
"/help — Show this help message\n" +
|
||||
"/reset — Reset conversation history\n" +
|
||||
"/status — Show bot status\n" +
|
||||
"/writers — List file writers for this group\n" +
|
||||
"/addwriter — Add a file writer (reply to their message)\n" +
|
||||
"/removewriter — Remove a file writer (reply to their message)\n" +
|
||||
"\nJust send a message to chat with the AI."
|
||||
msg := tu.Message(chatIDObj, helpText)
|
||||
setThread(msg)
|
||||
@@ -85,11 +89,157 @@ func (c *Channel) handleBotCommand(ctx context.Context, chatID int64, chatIDStr,
|
||||
setThread(msg)
|
||||
c.bot.SendMessage(ctx, msg)
|
||||
return true
|
||||
|
||||
case "/addwriter":
|
||||
c.handleWriterCommand(ctx, message, chatID, chatIDStr, senderID, isGroup, setThread, "add")
|
||||
return true
|
||||
|
||||
case "/removewriter":
|
||||
c.handleWriterCommand(ctx, message, chatID, chatIDStr, senderID, isGroup, setThread, "remove")
|
||||
return true
|
||||
|
||||
case "/writers":
|
||||
c.handleListWriters(ctx, chatID, chatIDStr, isGroup, setThread)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// handleWriterCommand handles /addwriter and /removewriter commands.
|
||||
// The target user is identified by replying to one of their messages.
|
||||
func (c *Channel) handleWriterCommand(ctx context.Context, message *telego.Message, chatID int64, chatIDStr, senderID string, isGroup bool, setThread func(*telego.SendMessageParams), action string) {
|
||||
chatIDObj := tu.ID(chatID)
|
||||
|
||||
send := func(text string) {
|
||||
msg := tu.Message(chatIDObj, text)
|
||||
setThread(msg)
|
||||
c.bot.SendMessage(ctx, msg)
|
||||
}
|
||||
|
||||
if !isGroup {
|
||||
send("This command only works in group chats.")
|
||||
return
|
||||
}
|
||||
|
||||
if c.agentStore == nil {
|
||||
send("File writer management is not available.")
|
||||
return
|
||||
}
|
||||
|
||||
agentID, err := uuid.Parse(c.AgentID())
|
||||
if err != nil {
|
||||
send("File writer management is not available (no agent).")
|
||||
return
|
||||
}
|
||||
|
||||
groupID := fmt.Sprintf("group:%s:%s", c.Name(), chatIDStr)
|
||||
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)
|
||||
if err != nil {
|
||||
slog.Warn("writer check failed", "error", err, "sender", senderNumericID)
|
||||
send("Failed to check permissions. Please try again.")
|
||||
return
|
||||
}
|
||||
if !isWriter {
|
||||
send("Only existing file writers can manage the writer list.")
|
||||
return
|
||||
}
|
||||
|
||||
// Extract target user from reply-to message
|
||||
if message.ReplyToMessage == nil || message.ReplyToMessage.From == nil {
|
||||
send(fmt.Sprintf("Reply to a message from the user you want to %s as a writer.", action))
|
||||
return
|
||||
}
|
||||
|
||||
targetUser := message.ReplyToMessage.From
|
||||
targetID := fmt.Sprintf("%d", targetUser.ID)
|
||||
targetName := targetUser.FirstName
|
||||
if targetUser.Username != "" {
|
||||
targetName = "@" + targetUser.Username
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "add":
|
||||
if err := c.agentStore.AddGroupFileWriter(ctx, agentID, groupID, targetID, targetUser.FirstName, targetUser.Username); 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))
|
||||
|
||||
case "remove":
|
||||
// Prevent removing the last writer
|
||||
writers, _ := c.agentStore.ListGroupFileWriters(ctx, agentID, groupID)
|
||||
if len(writers) <= 1 {
|
||||
send("Cannot remove the last file writer.")
|
||||
return
|
||||
}
|
||||
if err := c.agentStore.RemoveGroupFileWriter(ctx, agentID, groupID, 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))
|
||||
}
|
||||
}
|
||||
|
||||
// handleListWriters handles the /writers command.
|
||||
func (c *Channel) handleListWriters(ctx context.Context, chatID int64, chatIDStr string, isGroup bool, setThread func(*telego.SendMessageParams)) {
|
||||
chatIDObj := tu.ID(chatID)
|
||||
|
||||
send := func(text string) {
|
||||
msg := tu.Message(chatIDObj, text)
|
||||
setThread(msg)
|
||||
c.bot.SendMessage(ctx, msg)
|
||||
}
|
||||
|
||||
if !isGroup {
|
||||
send("This command only works in group chats.")
|
||||
return
|
||||
}
|
||||
|
||||
if c.agentStore == nil {
|
||||
send("File writer management is not available.")
|
||||
return
|
||||
}
|
||||
|
||||
agentID, err := uuid.Parse(c.AgentID())
|
||||
if err != nil {
|
||||
send("File writer management is not available (no agent).")
|
||||
return
|
||||
}
|
||||
|
||||
groupID := fmt.Sprintf("group:%s:%s", c.Name(), chatIDStr)
|
||||
|
||||
writers, err := c.agentStore.ListGroupFileWriters(ctx, agentID, groupID)
|
||||
if err != nil {
|
||||
slog.Warn("list writers failed", "error", err)
|
||||
send("Failed to list writers. Please try again.")
|
||||
return
|
||||
}
|
||||
|
||||
if len(writers) == 0 {
|
||||
send("No file writers configured for this group. The first person to interact with the bot will be added automatically.")
|
||||
return
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("File writers for this group (%d):\n", len(writers)))
|
||||
for i, w := range writers {
|
||||
label := w.UserID
|
||||
if w.Username != nil && *w.Username != "" {
|
||||
label = "@" + *w.Username
|
||||
} else if w.DisplayName != nil && *w.DisplayName != "" {
|
||||
label = *w.DisplayName
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%d. %s (ID: %s)\n", i+1, label, w.UserID))
|
||||
}
|
||||
send(sb.String())
|
||||
}
|
||||
|
||||
// --- Pairing UX ---
|
||||
|
||||
// buildPairingReply builds the pairing reply message matching TS behavior.
|
||||
@@ -201,5 +351,8 @@ func DefaultMenuCommands() []telego.BotCommand {
|
||||
{Command: "help", Description: "Show available commands"},
|
||||
{Command: "reset", Description: "Reset conversation history"},
|
||||
{Command: "status", Description: "Show bot status"},
|
||||
{Command: "writers", Description: "List file writers for this group"},
|
||||
{Command: "addwriter", Description: "Add a file writer (reply to their message)"},
|
||||
{Command: "removewriter", Description: "Remove a file writer (reply to their message)"},
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,23 @@ type telegramInstanceConfig struct {
|
||||
AllowFrom []string `json:"allow_from,omitempty"`
|
||||
}
|
||||
|
||||
// Factory creates a Telegram channel from DB instance data.
|
||||
// Factory creates a Telegram channel from DB instance data (no agent store = no group file writer commands).
|
||||
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)
|
||||
}
|
||||
|
||||
// FactoryWithAgentStore returns a ChannelFactory that includes the agent store
|
||||
// for group file writer management (/addwriter, /removewriter, /writers commands).
|
||||
func FactoryWithAgentStore(agentStore store.AgentStore) 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)
|
||||
}
|
||||
}
|
||||
|
||||
func buildChannel(name string, creds json.RawMessage, cfg json.RawMessage,
|
||||
msgBus *bus.MessageBus, pairingSvc store.PairingStore, agentStore store.AgentStore) (channels.Channel, error) {
|
||||
|
||||
var c telegramCreds
|
||||
if len(creds) > 0 {
|
||||
@@ -71,7 +85,7 @@ func Factory(name string, creds json.RawMessage, cfg json.RawMessage,
|
||||
tgCfg.GroupPolicy = "pairing"
|
||||
}
|
||||
|
||||
ch, err := New(tgCfg, msgBus, pairingSvc)
|
||||
ch, err := New(tgCfg, msgBus, pairingSvc, agentStore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -210,8 +210,8 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) {
|
||||
content = "[empty message]"
|
||||
}
|
||||
|
||||
// Handle bot commands (/start, /help, /reset, /status).
|
||||
if handled := c.handleBotCommand(ctx, chatID, chatIDStr, localKey, content, senderID, isGroup, isForum, messageThreadID); handled {
|
||||
// Handle bot commands (/start, /help, /reset, /status, /addwriter, /removewriter, /writers).
|
||||
if handled := c.handleBotCommand(ctx, message, chatID, chatIDStr, localKey, content, senderID, isGroup, isForum, messageThreadID); handled {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -167,4 +167,17 @@ type AgentStore interface {
|
||||
|
||||
// User-agent profiles
|
||||
GetOrCreateUserProfile(ctx context.Context, agentID uuid.UUID, userID, workspace string) (isNew bool, err 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)
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
@@ -15,6 +15,10 @@ const (
|
||||
AgentIDKey contextKey = "goclaw_agent_id"
|
||||
// AgentTypeKey is the context key for the agent type ("open" or "predefined").
|
||||
AgentTypeKey contextKey = "goclaw_agent_type"
|
||||
// SenderIDKey is the original individual sender's ID (not group-scoped).
|
||||
// In group chats, UserIDKey is group-scoped but SenderIDKey preserves
|
||||
// the actual person who sent the message.
|
||||
SenderIDKey contextKey = "goclaw_sender_id"
|
||||
)
|
||||
|
||||
// WithUserID returns a new context with the given user ID.
|
||||
@@ -55,3 +59,16 @@ func AgentTypeFromContext(ctx context.Context) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// WithSenderID returns a new context with the original individual sender ID.
|
||||
func WithSenderID(ctx context.Context, id string) context.Context {
|
||||
return context.WithValue(ctx, SenderIDKey, id)
|
||||
}
|
||||
|
||||
// SenderIDFromContext extracts the sender ID from context. Returns "" if not set.
|
||||
func SenderIDFromContext(ctx context.Context) string {
|
||||
if v, ok := ctx.Value(SenderIDKey).(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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) 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
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -13,6 +15,16 @@ import (
|
||||
"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{
|
||||
"SOUL.md": true,
|
||||
"IDENTITY.md": true,
|
||||
"AGENTS.md": true,
|
||||
"HEARTBEAT.md": true,
|
||||
"USER.md": true,
|
||||
}
|
||||
|
||||
// contextFileSet is the set of filenames routed to DB in managed mode.
|
||||
var contextFileSet = map[string]bool{
|
||||
"SOUL.md": true,
|
||||
@@ -233,6 +245,23 @@ func (b *ContextFileInterceptor) WriteFile(ctx context.Context, path, content st
|
||||
userID := store.UserIDFromContext(ctx)
|
||||
agentType := store.AgentTypeFromContext(ctx)
|
||||
|
||||
// Permission check: protected files in group context require allowlist membership.
|
||||
if strings.HasPrefix(userID, "group:") && protectedFileSet[fileName] {
|
||||
senderID := store.SenderIDFromContext(ctx)
|
||||
if senderID != "" {
|
||||
numericID := strings.SplitN(senderID, "|", 2)[0]
|
||||
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
|
||||
if fileName == "BOOTSTRAP.md" && content == "" && userID != "" {
|
||||
err := b.agentStore.DeleteUserContextFile(ctx, agentID, userID, fileName)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS group_file_writers;
|
||||
@@ -0,0 +1,9 @@
|
||||
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)
|
||||
);
|
||||
Reference in new issue
Block a user